* Create Reviews by Product block

* Honor Content settings

* Fix wrong className

* Load new wc-packages file

* Add reviews-by-product JS files to webpack config

* Cleanup

* Remove error messages

* Add Reviews by Product icon

* Update sort options

* Allow additional CSS classes attribute

* Refactor block styles

* Fix wrong default for reviews_orderby

* Don't enforce CSS chunks

* Add reviews count to Reviews by Product controls (https://github.com/woocommerce/woocommerce-blocks/pull/671)

* Add label to Reviews by Product controls count (https://github.com/woocommerce/woocommerce-blocks/pull/677)

* Add reviews count to Reviews by Product controls

* Add label to Reviews by Product controls count

* Add label to Reviews by Product controls count

* Update components package

* Review ordering and placeholders (https://github.com/woocommerce/woocommerce-blocks/pull/688)

* Add support for comment_count ordering and add to productcontrol

* Add a placeholder if rating count is 0

* Update assets/js/components/utils/index.js

Co-Authored-By: Albert Juhé Lluveras <contact@albertjuhe.com>

* Update assets/js/blocks/reviews-by-product/block.js

Co-Authored-By: Albert Juhé Lluveras <contact@albertjuhe.com>

* grammar

* Fix some linting errors and warnings (https://github.com/woocommerce/woocommerce-blocks/pull/693)

* Create Reviews by Product block placeholder (https://github.com/woocommerce/woocommerce-blocks/pull/691)

* Create Reviews by Product block placeholder

* Reviews by Product: load and render reviews with JS (https://github.com/woocommerce/woocommerce-blocks/pull/696)

* Reviews by Product: load and render reviews with JS

* Add dangerouslySetInnerHTML explanatory comment

* Fix wrong dependency source

* Debounce getReviews call when creating the Reviews by Product block

* Rename 'Reviewer Picture' with 'Avatar' (https://github.com/woocommerce/woocommerce-blocks/pull/702)

* Lint errors

* Replace stringify query with addQueryArgs (https://github.com/woocommerce/woocommerce-blocks/pull/707)

* Add reviews endpoint (https://github.com/woocommerce/woocommerce-blocks/pull/705)

* Prevent state updates on unmounted components (https://github.com/woocommerce/woocommerce-blocks/pull/715)

* Add Order by and Load more controls in Reviews by Product frontend (https://github.com/woocommerce/woocommerce-blocks/pull/716)

* Export IconReviewsByProduct (https://github.com/woocommerce/woocommerce-blocks/pull/721)

* Fix Reviews by Product layout in IE11 (https://github.com/woocommerce/woocommerce-blocks/pull/723)

* Set minimum to per page input field (https://github.com/woocommerce/woocommerce-blocks/pull/731)

* Hide avatars in Reviews by Products if 'show_avatars' settings is false (https://github.com/woocommerce/woocommerce-blocks/pull/730)

* Blocks API - Reviews endpoint with rating sort and category filtering (https://github.com/woocommerce/woocommerce-blocks/pull/726)

* Move file to correct location

* We are only using the reviews endpoint not revioews/id

* Remove sensistive data and make endpoint public

* Allow guest access to approved reviews

* Add support for rating sorting

* category filtering

* update arg name

* fix category query

* Reviews by Product: add placeholders when loading reviews (https://github.com/woocommerce/woocommerce-blocks/pull/732)

* Add placeholder animation (https://github.com/woocommerce/woocommerce-blocks/pull/733)

* Hook up Reviews by Product 'Order by' with endpoint (https://github.com/woocommerce/woocommerce-blocks/pull/736)

* Hook up Reviews by Product 'Order by' with endpoint

* Use onChange instead of onBlur in select control

* Reviews by Product: Hide ratings if they are disabled in settings (https://github.com/woocommerce/woocommerce-blocks/pull/740)

* Hide ratings in Reviews by Product if disabled in settings

* Hide order by select if ratings are disabled

* Reviews by Product cleanup (https://github.com/woocommerce/woocommerce-blocks/pull/773)

* Fix wrong method name

* Reduce the number of dependencies used in Reviews by Product (https://github.com/woocommerce/woocommerce-blocks/pull/774)

* Reduce the number of dependencies used in Reviews by Product

* Use 'withComponentId' HOC

* Remove debounce

* Fix wrong proptype

* Get rid of JS warning

* Load render from react-dom

* Add formatted_date_created item schema (https://github.com/woocommerce/woocommerce-blocks/pull/788)

* Fix import of WithComponentID

* Add new settings to Reviews by Product block (https://github.com/woocommerce/woocommerce-blocks/pull/787)

* Add new settings to Reviews by Product block

* Remove helpText and add notices

* Use RangeControl for numeric settings

* Prevent fetching new reviews if all were already fetched

* Enable product image in reviews

* Remove unnecessary catch

* Refactor getReviews

* Move getReviews back to block's code

* Cleanup

* Fix wrong order in editor

* Hide 'Load More Reviews' if showLoadMore is false

* Move getReviews to utils.js

* Add @woocommerce/navigation to package.json

* Make notices non-dismissable

* Reviews by Product: prevent importing all HOCs and import only withComponentId (https://github.com/woocommerce/woocommerce-blocks/pull/811)

* Reviews by product: Update review styling and content (https://github.com/woocommerce/woocommerce-blocks/pull/806)

* Add new settings to Reviews by Product block

* Remove helpText and add notices

* Use RangeControl for numeric settings

* Prevent fetching new reviews if all were already fetched

* Enable product image in reviews

* Remove unnecessary catch

* Refactor getReviews

* Move getReviews back to block's code

* Cleanup

* Fix wrong order in editor

* Hide 'Load More Reviews' if showLoadMore is false

* Move getReviews to utils.js

* Add @woocommerce/navigation to package.json

* Make notices non-dismissable

* Review design/layout

* verified icons

* Read more component

* remove comment

* expanded -> isExpanded

* Localise and change default elipses

* Simplify ReadMore

* Support children rather than passing content

* remove outside

* remove list style

* Update assets/js/components/read-more/index.js

Co-Authored-By: Albert Juhé Lluveras <contact@albertjuhe.com>

* Update assets/js/components/read-more/index.js

Co-Authored-By: Albert Juhé Lluveras <contact@albertjuhe.com>

* merge set state

* Add missing parameter doc in renderReview (https://github.com/woocommerce/woocommerce-blocks/pull/820)

* Fix Reviews by Product order by select not honoring default setting (https://github.com/woocommerce/woocommerce-blocks/pull/818)

* Read more component - change how clamped content is shown (https://github.com/woocommerce/woocommerce-blocks/pull/821)

* Pass review as components

* Build summary from content and track both

* Toggle display after inital load

* remove unused variable

* remove componentDidUpdate

* Simplify clampLines

* Put back componentDidUpdate, but store final summary in state

* clampEnabled

* Call clampLines from componentDidMount (https://github.com/woocommerce/woocommerce-blocks/pull/826)

* truncate html tests

* implement trimHTML and pass test

* Feedback

* test short content

* Use withProduct HOC in ReviewsByProductEditor (https://github.com/woocommerce/woocommerce-blocks/pull/828)

* Use withProduct HOC

* Convert ReviewsByProductEditor to a functional component

* Add loading and error states

* Prevent loading screen appearing when changing products

* Reviews: only save wrapper element to post (https://github.com/woocommerce/woocommerce-blocks/pull/830)

* Fix bundlesize config not picking frontend files (https://github.com/woocommerce/woocommerce-blocks/pull/840)

* Reviews by Product: split 'block.js' into smaller chunks (https://github.com/woocommerce/woocommerce-blocks/pull/841)

* Split 'block.js' into smaller chunks

* Move frontend blocks to their specific folder

* Order imports

* Typo

* Add frontend components proptypes

* Fix indentation

* Call 'this.getDefaultArgs' directly inside 'getReviews'

* Move access to wc_product_block_data to the top of the file

* Rename 'frontend' folder to 'base'

* Rename base components and move styles to their folder

* Fix Reviews by Product using rating count instead of review count (https://github.com/woocommerce/woocommerce-blocks/pull/860)

* Improve Reviews by Product accessibility (https://github.com/woocommerce/woocommerce-blocks/pull/861)

* Improve Reviews by Product accessibility

* Make 'onClick' prop not required in <LoadMoreButton>

* Wrap Reviews by Product editor block with <Disabled>

* Reviews: fix reviews without rating not appearing when sorting by rating (https://github.com/woocommerce/woocommerce-blocks/pull/863)
This commit is contained in:
Albert Juhé Lluveras 2019-08-15 16:55:57 +02:00 committed by GitHub
parent d9c2b4d4c6
commit ad38f9d327
39 changed files with 2219 additions and 135 deletions

View File

@ -14,6 +14,10 @@ module.exports = {
],
rules: {
'@wordpress/dependency-group': 'off',
'camelcase': [ 'error', {
allow: [ 'wc_product_block_data' ],
properties: 'never',
} ],
'valid-jsdoc': 'off',
}
};

View File

@ -15,6 +15,18 @@
}
}
@keyframes loading-fade {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
// Adds animation to placeholder section
@mixin placeholder( $lighten-percentage: 30% ) {
animation: loading-fade 1.6s ease-in-out infinite;
@ -24,6 +36,10 @@
&::after {
content: "\00a0";
}
@media screen and (prefers-reduced-motion: reduce) {
animation: none;
}
}
// Adds animation to transforms

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
export const LoadMoreButton = ( { onClick, label, screenReaderLabel } ) => {
const labelNode = ( screenReaderLabel && label !== screenReaderLabel ) ? (
<Fragment>
<span aria-hidden>
{ label }
</span>
<span className="screen-reader-text">
{ screenReaderLabel }
</span>
</Fragment>
) : label;
return (
<button
className="wc-block-load-more"
onClick={ onClick }
>
{ labelNode }
</button>
);
};
LoadMoreButton.propTypes = {
label: PropTypes.string,
onClick: PropTypes.func,
screenReaderLabel: PropTypes.string,
};
LoadMoreButton.defaultProps = {
label: __( 'Load more', 'woo-gutenberg-products-block' ),
};
export default LoadMoreButton;

View File

@ -0,0 +1,4 @@
.wc-block-load-more {
display: block;
margin: 0 auto;
}

View File

@ -0,0 +1,163 @@
/**
* Show text based content, limited to a number of lines, with a read more link.
*
* Based on https://github.com/zoltantothcom/react-clamp-lines.
*/
import React, { createRef, Component } from 'react';
import PropTypes from 'prop-types';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { clampLines } from './utils';
class ReadMore extends Component {
constructor( props ) {
super( ...arguments );
this.state = {
/**
* This is true when read more has been pressed and the full review is shown.
*/
isExpanded: false,
/**
* True if we are clamping content. False if the review is short. Null during init.
*/
clampEnabled: null,
/**
* Content is passed in via children.
*/
content: props.children,
/**
* Summary content generated from content HTML.
*/
summary: '.',
};
this.reviewSummary = createRef();
this.reviewContent = createRef();
this.getButton = this.getButton.bind( this );
this.onClick = this.onClick.bind( this );
}
componentDidMount() {
if ( this.props.children ) {
const { maxLines, ellipsis } = this.props;
const lineHeight = this.reviewSummary.current.clientHeight + 1;
const reviewHeight = this.reviewContent.current.clientHeight + 1;
const maxHeight = ( lineHeight * maxLines ) + 1;
const clampEnabled = reviewHeight > maxHeight;
this.setState( {
clampEnabled,
} );
if ( clampEnabled ) {
this.setState( {
summary: clampLines( this.reviewContent.current.innerHTML, this.reviewSummary.current, maxHeight, ellipsis ),
} );
}
}
}
getButton() {
const { isExpanded } = this.state;
const { className, lessText, moreText } = this.props;
const buttonText = isExpanded ? lessText : moreText;
if ( ! buttonText ) {
return;
}
return (
<a
href="#more"
className={ className + '__read_more' }
onClick={ this.onClick }
aria-expanded={ ! isExpanded }
role="button"
>
{ buttonText }
</a>
);
}
/**
* Handles the click event for the read more/less button.
*
* @param {obj} e event
*/
onClick( e ) {
e.preventDefault();
const { isExpanded } = this.state;
this.setState( {
isExpanded: ! isExpanded,
} );
}
render() {
const { className } = this.props;
const { content, summary, clampEnabled, isExpanded } = this.state;
if ( ! content ) {
return null;
}
if ( false === clampEnabled ) {
return (
<div className={ className }>
<div ref={ this.reviewContent }>
{ content }
</div>
</div>
);
}
return (
<div className={ className }>
{ ( ! isExpanded || null === clampEnabled ) && (
<div
ref={ this.reviewSummary }
aria-hidden={ isExpanded }
dangerouslySetInnerHTML={ {
__html: summary,
} }
/>
) }
{ ( isExpanded || null === clampEnabled ) && (
<div
ref={ this.reviewContent }
aria-hidden={ ! isExpanded }
>
{ content }
</div>
) }
{ this.getButton() }
</div>
);
}
}
ReadMore.propTypes = {
children: PropTypes.node.isRequired,
maxLines: PropTypes.number,
ellipsis: PropTypes.string,
moreText: PropTypes.string,
lessText: PropTypes.string,
className: PropTypes.string,
};
ReadMore.defaultProps = {
maxLines: 3,
ellipsis: '&hellip;',
moreText: __( 'Read more', 'woo-gutenberg-products-block' ),
lessText: __( 'Read less', 'woo-gutenberg-products-block' ),
className: 'read-more-content',
};
export default ReadMore;

View File

@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import { truncateHtml } from '../utils';
const shortContent =
'<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>';
const longContent =
'<p>Lorem ipsum dolor sit amet, <strong>consectetur adipiscing elit. Nullam a condimentum diam.</strong> Donec finibus enim eros, et lobortis magna varius quis. Nulla lacinia tellus ac neque aliquet, in porttitor metus interdum. Maecenas vestibulum nisi et auctor vestibulum. Maecenas vehicula, lacus et pellentesque tempor, orci nulla mattis purus, id porttitor augue magna et metus. Aenean hendrerit aliquet massa ac convallis. Mauris vestibulum neque in condimentum porttitor. Donec viverra, orci a accumsan vehicula, dui massa lobortis lorem, et cursus est purus pulvinar elit. Vestibulum vitae tincidunt ex, ut vulputate nisi.</p>' +
'<p>Morbi tristique iaculis felis, sed porta urna tincidunt vitae. Etiam nisl sem, eleifend non varius quis, placerat a arcu. Donec consectetur nunc at orci fringilla pulvinar. Nam hendrerit tellus in est aliquet varius id in diam. Donec eu ullamcorper ante. Ut ultricies, felis vel sodales aliquet, nibh massa vestibulum ipsum, sed dignissim mi nunc eget lacus. Curabitur mattis placerat magna a aliquam. Nullam diam elit, cursus nec erat ullamcorper, tempor eleifend mauris. Nunc placerat nunc ut enim ornare tempus. Fusce porta molestie ante eget faucibus. Fusce eu lectus sit amet diam auctor lacinia et in diam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris eu lacus lobortis, faucibus est vel, pulvinar odio. Duis feugiat tortor quis dui euismod varius.</p>';
describe( 'ReadMore Component', () => {
describe( 'Test the truncateHtml function', () => {
it( 'Truncate long HTML content to length of 10', async () => {
const truncatedContent = truncateHtml( longContent, 10 );
expect( truncatedContent ).toEqual( '<p>Lorem ipsum...</p>' );
} );
it( 'Truncate long HTML content, but avoid cutting off HTML tags.', async () => {
const truncatedContent = truncateHtml( longContent, 40 );
expect( truncatedContent ).toEqual( '<p>Lorem ipsum dolor sit amet, <strong>consectetur...</strong></p>' );
} );
it( 'No need to truncate short HTML content.', async () => {
const truncatedContent = truncateHtml( shortContent, 100 );
expect( truncatedContent ).toEqual( '<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>' );
} );
} );
} );

View File

@ -0,0 +1,76 @@
import trimHtml from 'trim-html';
/**
* Truncate some HTML content to a given length.
*
* @param {string} html HTML that will be truncated.
* @param {int} length Legth to truncate the string to.
* @param {string} ellipsis Character to append to truncated content.
*/
export const truncateHtml = ( html, length, ellipsis = '...' ) => {
const trimmed = trimHtml( html, {
suffix: ellipsis,
limit: length,
} );
return trimmed.html;
};
/**
* Clamp lines calculates the height of a line of text and then limits it to the
* value of the lines prop. Content is updated once limited.
*
* @param {string} originalContent Content to be clamped.
* @param {object} targetElement Element which will contain the clamped content.
* @param {integer} maxHeight Max height of the clamped content.
* @param {string} ellipsis Character to append to clamped content.
* @return {string} clamped content
*/
export const clampLines = ( originalContent, targetElement, maxHeight, ellipsis ) => {
const length = calculateLength( originalContent, targetElement, maxHeight );
return truncateHtml( originalContent, length - ellipsis.length, ellipsis );
};
/**
* Calculate how long the content can be based on the maximum number of lines allowed, and client height.
*
* @param {string} originalContent Content to be clamped.
* @param {object} targetElement Element which will contain the clamped content.
* @param {integer} maxHeight Max height of the clamped content.
*/
const calculateLength = ( originalContent, targetElement, maxHeight ) => {
let markers = {
start: 0,
middle: 0,
end: originalContent.length,
};
while ( markers.start <= markers.end ) {
markers.middle = Math.floor( ( markers.start + markers.end ) / 2 );
// We set the innerHTML directly in the DOM here so we can reliably check the clientHeight later in moveMarkers.
targetElement.innerHTML = truncateHtml( originalContent, markers.middle );
markers = moveMarkers( markers, targetElement.clientHeight, maxHeight );
}
return markers.middle;
};
/**
* Move string markers. Used by calculateLength.
*
* @param {object} markers Markers for clamped content.
* @param {integer} currentHeight Current height of clamped content.
* @param {integer} maxHeight Max height of the clamped content.
*/
const moveMarkers = ( markers, currentHeight, maxHeight ) => {
if ( currentHeight <= maxHeight ) {
markers.start = markers.middle + 1;
} else {
markers.end = markers.middle - 1;
}
return markers;
};

View File

@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import ReadMore from '../read-more';
import './style.scss';
function getReviewClasses( isLoading ) {
const classArray = [ 'wc-block-review-list-item__item' ];
if ( isLoading ) {
classArray.push( 'is-loading' );
}
return classArray.join( ' ' );
}
function getReviewImage( review, imageType, isLoading ) {
if ( isLoading || ! review ) {
return (
<div className="wc-block-review-list-item__image" width="48" height="48" />
);
}
return (
<div className="wc-block-review-list-item__image">
{ imageType === 'product' ? (
<img aria-hidden="true" alt="" src={ review.product_picture } className="wc-block-review-list-item__image" width="48" height="48" />
) : (
<img aria-hidden="true" alt="" src={ review.reviewer_avatar_urls[ '48' ] } srcSet={ review.reviewer_avatar_urls[ '96' ] + ' 2x' } className="wc-block-review-list-item__image" width="48" height="48" />
) }
{ review.verified && (
<div className="wc-block-review-list-item__verified" title={ __( 'Verified buyer', 'woo-gutenberg-products-block' ) }>{ __( 'Verified buyer', 'woo-gutenberg-products-block' ) }</div>
) }
</div>
);
}
function getReviewContent( review ) {
return (
<ReadMore
maxLines={ 10 }
moreText={ __( 'Read full review', 'woo-gutenberg-products-block' ) }
lessText={ __( 'Hide full review', 'woo-gutenberg-products-block' ) }
className="wc-block-review-list-item__text"
>
<div
dangerouslySetInnerHTML={ {
// `content` is the `review` parameter returned by the `reviews` endpoint.
// It's filtered with `wp_filter_post_kses()`, which removes dangerous HTML tags,
// so using it inside `dangerouslySetInnerHTML` is safe.
__html: review.review || '',
} }
/>
</ReadMore>
);
}
const ReviewListItem = ( { attributes, review = {} } ) => {
const { imageType, showReviewDate, showReviewerName, showReviewImage, showReviewRating: showReviewRatingAttr } = attributes;
const { date_created: dateCreated, formatted_date_created: formattedDateCreated, rating, reviewer = '' } = review;
const isLoading = ! Object.keys( review ).length > 0;
const showReviewRating = Number.isFinite( rating ) && showReviewRatingAttr;
const classes = getReviewClasses( isLoading );
const starStyle = {
width: ( rating / 5 * 100 ) + '%',
};
return (
<li className={ classes } aria-hidden={ isLoading }>
{ ( showReviewDate || showReviewerName || showReviewImage || showReviewRating ) && (
<div className="wc-block-review-list-item__info">
{ showReviewImage && getReviewImage( review, imageType, isLoading ) }
{ ( showReviewerName || showReviewRating || showReviewDate ) && (
<div className="wc-block-review-list-item__meta">
{ showReviewerName && (
<strong className="wc-block-review-list-item__author">{ reviewer }</strong>
) }
{ showReviewRating && (
<div className="wc-block-review-list-item__rating">
<div className="wc-block-review-list-item__rating__stars" role="img">
<span style={ starStyle }>{ sprintf( __( 'Rated %d out of 5', 'woo-gutenberg-products-block' ), rating ) }</span>
</div>
</div>
) }
{ showReviewDate && (
<time className="wc-block-review-list-item__published-date" dateTime={ dateCreated }>{ formattedDateCreated }</time>
) }
</div>
) }
</div>
) }
{ getReviewContent( review ) }
</li>
);
};
ReviewListItem.propTypes = {
attributes: PropTypes.object.isRequired,
review: PropTypes.object,
};
export default ReviewListItem;

View File

@ -0,0 +1,149 @@
.is-loading {
.wc-block-review-list-item__text {
@include placeholder();
display: block;
width: 60%;
}
.wc-block-review-list-item__info {
.wc-block-review-list-item__image {
@include placeholder();
}
.wc-block-review-list-item__meta {
.wc-block-review-list-item__author {
@include placeholder();
font-size: 1em;
width: 80px;
}
.wc-block-review-list-item__rating {
.wc-block-review-list-item__rating__stars > span {
display: none;
}
}
}
.wc-block-review-list-item__published-date {
@include placeholder();
height: 1em;
width: 120px;
}
}
}
.wc-block-review-list-item__item {
margin: 0 0 $gap-large * 2;
list-style: none;
}
.wc-block-review-list-item__info {
display: grid;
grid-template-columns: 1fr;
margin-bottom: $gap-large;
}
.wc-block-review-list-item__meta {
grid-column: 1;
grid-row: 1;
}
.has-image {
.wc-block-review-list-item__info {
grid-template-columns: #{48px + $gap} 1fr;
}
.wc-block-review-list-item__meta {
grid-column: 2;
}
}
.wc-block-review-list-item__image {
height: 48px;
grid-column: 1;
grid-row: 1 / 3;
width: 48px;
position: relative;
img {
width: 100%;
height: 100%;
display: block;
}
}
.wc-block-review-list-item__verified {
width: 21px;
height: 21px;
text-indent: 21px;
margin: 0;
line-height: 21px;
overflow: hidden;
position: absolute;
right: -7px;
bottom: -7px;
&::before {
width: 21px;
height: 21px;
background: transparent url('data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="21" height="21" fill="none"%3E%3Ccircle cx="10.5" cy="10.5" r="10.5" fill="%23fff"/%3E%3Cpath fill="%23008A21" fill-rule="evenodd" d="M2.1667 10.5003c0-4.6 3.7333-8.3333 8.3333-8.3333s8.3334 3.7333 8.3334 8.3333S15.1 18.8337 10.5 18.8337s-8.3333-3.7334-8.3333-8.3334zm2.5 0l4.1666 4.1667 7.5001-7.5-1.175-1.1833-6.325 6.325-2.9917-2.9834-1.175 1.175z" clip-rule="evenodd"/%3E%3Cmask id="a" width="17" height="17" x="2" y="2" maskUnits="userSpaceOnUse"%3E%3Cpath fill="%23fff" fill-rule="evenodd" d="M2.1667 10.5003c0-4.6 3.7333-8.3333 8.3333-8.3333s8.3334 3.7333 8.3334 8.3333S15.1 18.8337 10.5 18.8337s-8.3333-3.7334-8.3333-8.3334zm2.5 0l4.1666 4.1667 7.5001-7.5-1.175-1.1833-6.325 6.325-2.9917-2.9834-1.175 1.175z" clip-rule="evenodd"/%3E%3C/mask%3E%3Cg mask="url(%23a)"%3E%3Cpath fill="%23008A21" d="M.5.5h20v20H.5z"/%3E%3C/g%3E%3C/svg%3E') center center no-repeat; /* stylelint-disable-line */
display: block;
content: "";
}
}
.wc-block-review-list-item__author {
display: block;
}
.wc-block-review-list-item__rating {
display: inline-block;
vertical-align: middle;
margin-right: $gap;
height: 1em;
> .wc-block-review-list-item__rating__stars {
display: inline-block;
top: -5px;
overflow: hidden;
position: relative;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
width: 5.3em;
font-family: star; /* stylelint-disable-line */
font-weight: 400;
}
> .wc-block-review-list-item__rating__stars::before {
content: "\53\53\53\53\53";
opacity: 0.25;
float: left;
top: 0;
left: 0;
position: absolute;
}
> .wc-block-review-list-item__rating__stars span {
overflow: hidden;
float: left;
top: 0;
left: 0;
position: absolute;
padding-top: 1.5em;
}
> .wc-block-review-list-item__rating__stars span::before {
content: "\53\53\53\53\53";
top: 0;
position: absolute;
left: 0;
color: #e6a237;
}
}
.wc-block-review-list-item__published-date {
display: inline-block;
vertical-align: middle;
color: #808080;
font-size: 0.875em;
}

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import ReviewListItem from '../review-list-item';
import './style.scss';
const ReviewList = ( { attributes, componentId, reviews } ) => {
const showReviewImage = ( wc_product_block_data.showAvatars || attributes.imageType === 'product' ) && attributes.showReviewImage;
const showReviewRating = wc_product_block_data.enableReviewRating && attributes.showReviewRating;
const attrs = {
...attributes,
showReviewImage,
showReviewRating,
};
return (
<ul
key={ `wc-block-review-list-${ componentId }` }
className="wc-block-review-list"
>
{ reviews.length === 0 ?
(
<ReviewListItem attributes={ attrs } />
) : (
reviews.map( ( review, i ) => (
<ReviewListItem key={ review.id || i } attributes={ attrs } review={ review } />
) )
)
}
</ul>
);
};
ReviewList.propTypes = {
attributes: PropTypes.object.isRequired,
componentId: PropTypes.number.isRequired,
reviews: PropTypes.array.isRequired,
};
export default ReviewList;

View File

@ -0,0 +1,3 @@
.wc-block-review-list {
margin: 0;
}

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
const ReviewOrderSelect = ( { componentId, onChange, readOnly, value } ) => {
const selectId = `wc-block-review-order-select__select-${ componentId }`;
return (
<p className="wc-block-review-order-select">
<label className="wc-block-review-order-select__label" htmlFor={ selectId }>
<span aria-hidden>
{ __( 'Order by', 'woo-gutenberg-products-block' ) }
</span>
<span className="screen-reader-text">
{ __( 'Order reviews by', 'woo-gutenberg-products-block' ) }
</span>
</label>
<select // eslint-disable-line jsx-a11y/no-onchange
id={ selectId }
className="wc-block-review-order-select__select"
onChange={ onChange }
readOnly={ readOnly }
value={ value }
>
<option value="most-recent">
{ __( 'Most recent', 'woo-gutenberg-products-block' ) }
</option>
<option value="highest-rating">
{ __( 'Highest rating', 'woo-gutenberg-products-block' ) }
</option>
<option value="lowest-rating">
{ __( 'Lowest rating', 'woo-gutenberg-products-block' ) }
</option>
</select>
</p>
);
};
ReviewOrderSelect.propTypes = {
componentId: PropTypes.number.isRequired,
onChange: PropTypes.func,
readOnly: PropTypes.bool,
value: PropTypes.oneOf( [ 'most-recent', 'highest-rating', 'lowest-rating' ] ),
};
export default ReviewOrderSelect;

View File

@ -0,0 +1,10 @@
.wc-block-review-order-select {
margin-bottom: $gap-small;
text-align: right;
}
.wc-block-review-order-select__label {
margin-right: $gap-small;
display: inline-block;
font-weight: normal;
}

View File

@ -8,7 +8,7 @@ import classnames from 'classnames';
/**
* Internal dependencies
*/
import withComponentId from '../../hocs/with-component-id';
import withComponentId from '../../base/hocs/with-component-id';
/**
* Component displaying the categories as dropdown or list.

View File

@ -0,0 +1,355 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import {
BlockControls,
InspectorControls,
} from '@wordpress/editor';
import {
Button,
Disabled,
Notice,
PanelBody,
Placeholder,
RangeControl,
SelectControl,
Spinner,
ToggleControl,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import classNames from 'classnames';
import { SearchListItem } from '@woocommerce/components';
import { Fragment, RawHTML } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { escapeHTML } from '@wordpress/escape-html';
import PropTypes from 'prop-types';
import { getAdminLink } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import ApiErrorPlaceholder from '../../components/api-error-placeholder';
import EditorBlock from './editor-block.js';
import ProductControl from '../../components/product-control';
import ToggleButtonControl from '../../components/toggle-button-control';
import { IconReviewsByProduct } from '../../components/icons';
import { withProduct } from '../../hocs';
const enableReviewRating = !! ( typeof wc_product_block_data !== 'undefined' && wc_product_block_data.enableReviewRating );
const showAvatars = !! ( typeof wc_product_block_data !== 'undefined' && wc_product_block_data.showAvatars );
/**
* Component to handle edit mode of "Reviews by Product".
*/
const ReviewsByProductEditor = ( { attributes, debouncedSpeak, error, getProduct, isLoading, product, setAttributes } ) => {
const { className, editMode, productId, showReviewDate, showReviewerName } = attributes;
const getBlockControls = () => (
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit' ),
onClick: () => setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
);
const renderProductControlItem = ( args ) => {
const { item = 0 } = args;
return (
<SearchListItem
{ ...args }
countLabel={ sprintf(
_n(
'%d Review',
'%d Reviews',
item.review_count,
'woo-gutenberg-products-block'
),
item.review_count
) }
showCount
aria-label={ sprintf(
_n(
'%s, has %d review',
'%s, has %d reviews',
item.review_count,
'woo-gutenberg-products-block'
),
item.name,
item.review_count
) }
/>
);
};
const getInspectorControls = () => {
const minPerPage = 1;
const maxPerPage = 20;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Product', 'woo-gutenberg-products-block' ) }
initialOpen={ false }
>
<ProductControl
selected={ attributes.productId || 0 }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( { productId: id } );
} }
renderItem={ renderProductControlItem }
/>
</PanelBody>
<PanelBody title={ __( 'Content', 'woo-gutenberg-products-block' ) }>
<ToggleControl
label={ __( 'Product rating', 'woo-gutenberg-products-block' ) }
checked={ attributes.showReviewRating }
onChange={ () => setAttributes( { showReviewRating: ! attributes.showReviewRating } ) }
/>
{ ( attributes.showReviewRating && ! enableReviewRating ) && (
<Notice className="wc-block-reviews-by-product__notice" isDismissible={ false }>
<RawHTML>
{ sprintf( __( 'Product rating is disabled in your %sstore settings%s.', 'woo-gutenberg-products-block' ), `<a href="${ getAdminLink( 'admin.php?page=wc-settings&tab=products' ) }" target="_blank">`, '</a>' ) }
</RawHTML>
</Notice>
) }
<ToggleControl
label={ __( 'Reviewer name', 'woo-gutenberg-products-block' ) }
checked={ attributes.showReviewerName }
onChange={ () => setAttributes( { showReviewerName: ! attributes.showReviewerName } ) }
/>
<ToggleControl
label={ __( 'Image', 'woo-gutenberg-products-block' ) }
checked={ attributes.showReviewImage }
onChange={ () => setAttributes( { showReviewImage: ! attributes.showReviewImage } ) }
/>
<ToggleControl
label={ __( 'Review date', 'woo-gutenberg-products-block' ) }
checked={ attributes.showReviewDate }
onChange={ () => setAttributes( { showReviewDate: ! attributes.showReviewDate } ) }
/>
{ attributes.showReviewImage && (
<Fragment>
<ToggleButtonControl
label={ __( 'Review image', 'woo-gutenberg-products-block' ) }
value={ attributes.imageType }
options={ [
{ label: __( 'Reviewer photo', 'woo-gutenberg-products-block' ), value: 'reviewer' },
{ label: __( 'Product', 'woo-gutenberg-products-block' ), value: 'product' },
] }
onChange={ ( value ) => setAttributes( { imageType: value } ) }
/>
{ ( attributes.imageType === 'reviewer' && ! showAvatars ) && (
<Notice className="wc-block-reviews-by-product__notice" isDismissible={ false }>
<RawHTML>
{ sprintf( __( 'Reviewer photo is disabled in your %ssite settings%s.', 'woo-gutenberg-products-block' ), `<a href="${ getAdminLink( 'options-discussion.php' ) }" target="_blank">`, '</a>' ) }
</RawHTML>
</Notice>
) }
</Fragment>
) }
</PanelBody>
<PanelBody title={ __( 'List Settings', 'woo-gutenberg-products-block' ) }>
<ToggleControl
label={ __( 'Order by', 'woo-gutenberg-products-block' ) }
checked={ attributes.showOrderby }
onChange={ () => setAttributes( { showOrderby: ! attributes.showOrderby } ) }
/>
<SelectControl
label={ __( 'Order Product Reviews by', 'woo-gutenberg-products-block' ) }
value={ attributes.orderby }
options={ [
{ label: 'Most recent', value: 'most-recent' },
{ label: 'Highest Rating', value: 'highest-rating' },
{ label: 'Lowest Rating', value: 'lowest-rating' },
] }
onChange={ ( orderby ) => setAttributes( { orderby } ) }
/>
<RangeControl
label={ __( 'Starting Number of Reviews', 'woo-gutenberg-products-block' ) }
value={ attributes.reviewsOnPageLoad }
onChange={ ( reviewsOnPageLoad ) => setAttributes( { reviewsOnPageLoad } ) }
max={ maxPerPage }
min={ minPerPage }
/>
<ToggleControl
label={ __( 'Load more', 'woo-gutenberg-products-block' ) }
checked={ attributes.showLoadMore }
onChange={ () => setAttributes( { showLoadMore: ! attributes.showLoadMore } ) }
/>
{ attributes.showLoadMore && (
<RangeControl
label={ __( 'Load More Reviews', 'woo-gutenberg-products-block' ) }
value={ attributes.reviewsOnLoadMore }
onChange={ ( reviewsOnLoadMore ) => setAttributes( { reviewsOnLoadMore } ) }
max={ maxPerPage }
min={ minPerPage }
/>
) }
</PanelBody>
</InspectorControls>
);
};
const renderApiError = () => (
<ApiErrorPlaceholder
className="wc-block-featured-product-error"
error={ error }
isLoading={ isLoading }
onRetry={ getProduct }
/>
);
const renderLoadingScreen = () => {
return (
<Placeholder
icon={ <IconReviewsByProduct className="block-editor-block-icon" /> }
label={ __( 'Reviews by Product', 'woo-gutenberg-products-block' ) }
className="wc-block-reviews-by-product"
>
<Spinner />
</Placeholder>
);
};
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Reviews by Product block preview.',
'woo-gutenberg-products-block'
)
);
};
return (
<Placeholder
icon={ <IconReviewsByProduct className="block-editor-block-icon" /> }
label={ __( 'Reviews by Product', 'woo-gutenberg-products-block' ) }
className="wc-block-reviews-by-product"
>
{ __(
'Show reviews of your product to build trust',
'woo-gutenberg-products-block'
) }
<div className="wc-block-reviews-by-product__selection">
<ProductControl
selected={ attributes.productId || 0 }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( { productId: id } );
} }
queryArgs={ {
orderby: 'comment_count',
order: 'desc',
} }
renderItem={ renderProductControlItem }
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</div>
</Placeholder>
);
};
const renderViewMode = () => {
const showReviewImage = ( showAvatars || attributes.imageType === 'product' ) && attributes.showReviewImage;
const showReviewRating = enableReviewRating && attributes.showReviewRating;
const classes = classNames( 'wc-block-reviews-by-product', className, {
'has-image': showReviewImage,
'has-name': showReviewerName,
'has-date': showReviewDate,
'has-rating': showReviewRating,
} );
return (
<Fragment>
{ product.review_count === 0 ? (
<Placeholder
className="wc-block-reviews-by-product"
icon={ <IconReviewsByProduct className="block-editor-block-icon" /> }
label={ __( 'Reviews by Product', 'woo-gutenberg-products-block' ) }
>
<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.",
'woo-gutenberg-products-block'
),
'<strong>' + escapeHTML( product.name ) + '</strong>'
),
} } />
</Placeholder>
) : (
<Disabled>
<div className={ classes }>
<EditorBlock attributes={ attributes } />
</div>
</Disabled>
) }
</Fragment>
);
};
if ( error ) {
return renderApiError();
}
if ( ! productId || editMode ) {
return renderEditMode();
}
if ( ! product || isLoading ) {
return renderLoadingScreen();
}
return (
<Fragment>
{ getBlockControls() }
{ getInspectorControls() }
{ renderViewMode() }
</Fragment>
);
};
ReviewsByProductEditor.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withProduct
error: PropTypes.object,
getProduct: PropTypes.func,
isLoading: PropTypes.bool,
product: PropTypes.shape( {
name: PropTypes.node,
review_count: PropTypes.number,
} ),
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default compose( [
withProduct,
withSpokenMessages,
] )( ReviewsByProductEditor );

View File

@ -0,0 +1,107 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
/**
* Internal dependencies
*/
import { getOrderArgs, getReviews } from './utils';
import LoadMoreButton from '../../base/components/load-more-button';
import ReviewList from '../../base/components/review-list';
import ReviewOrderSelect from '../../base/components/review-order-select';
import withComponentId from '../../base/hocs/with-component-id';
const enableReviewRating = !! ( typeof wc_product_block_data !== 'undefined' && wc_product_block_data.enableReviewRating );
/**
* Block rendered in the editor.
*/
class EditorBlock extends Component {
constructor() {
super( ...arguments );
this.state = {
reviews: [],
totalReviews: 0,
};
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 { order, orderby } = getOrderArgs( attributes.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: [] } );
} );
}
render() {
const { attributes, componentId } = this.props;
const { reviews, totalReviews } = this.state;
return (
<Fragment>
{ ( attributes.showOrderby && enableReviewRating ) && (
<ReviewOrderSelect
componentId={ componentId }
readOnly
value={ attributes.orderby }
/>
) }
<ReviewList
attributes={ attributes }
componentId={ componentId }
reviews={ reviews }
/>
{ ( attributes.showLoadMore && totalReviews > reviews.length ) && (
<LoadMoreButton
screenReaderLabel={ __( 'Load more reviews', 'woo-gutenberg-products-block' ) }
/>
) }
</Fragment>
);
}
}
EditorBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
// from withComponentId
componentId: PropTypes.number,
};
export default withComponentId( EditorBlock );

View File

@ -0,0 +1,13 @@
.wc-block-reviews-by-product__selection {
width: 100%;
}
.components-base-control {
+ .wc-block-reviews-by-product__notice {
margin: -$gap 0 $gap;
}
&:nth-last-child(2) + .wc-block-reviews-by-product__notice {
margin: -$gap 0 $gap-small;
}
}

View File

@ -0,0 +1,169 @@
/**
* 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';
const enableReviewRating = !! ( typeof wc_product_block_data !== 'undefined' && wc_product_block_data.enableReviewRating );
/**
* 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;
return (
<Fragment>
{ ( attributes.showOrderby && enableReviewRating ) && (
<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

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { render } from 'react-dom';
/**
* Internal dependencies
*/
import FrontendBlock from './frontend-block.js';
const containers = document.querySelectorAll(
'.wp-block-woocommerce-reviews-by-product'
);
if ( containers.length ) {
// Use Array.forEach for IE11 compatibility
Array.prototype.forEach.call( containers, ( el ) => {
const attributes = {
...el.dataset,
showReviewDate: el.classList.contains( 'has-date' ),
showReviewerName: el.classList.contains( 'has-name' ),
showReviewImage: el.classList.contains( 'has-image' ),
showReviewRating: el.classList.contains( 'has-rating' ),
};
render( <FrontendBlock attributes={ attributes } />, el );
} );
}

View File

@ -0,0 +1,159 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import './editor.scss';
import Editor from './edit';
import { IconReviewsByProduct } from '../../components/icons';
/**
* Register and run the "Reviews by Product" block.
*/
registerBlockType( 'woocommerce/reviews-by-product', {
title: __( 'Reviews by Product', 'woo-gutenberg-products-block' ),
icon: (
<IconReviewsByProduct fillColor="#96588a" />
),
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
description: __(
'Show reviews of your product to build trust.',
'woo-gutenberg-products-block'
),
attributes: {
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* Whether to display the reviewer or product image.
*/
imageType: {
type: 'string',
default: 'reviewer',
},
/**
* Order to use for the reviews listing.
*/
orderby: {
type: 'string',
default: 'most-recent',
},
/**
* The id of the product to load reviews for.
*/
productId: {
type: 'number',
},
/**
* Number of reviews to add when clicking on load more.
*/
reviewsOnLoadMore: {
type: 'number',
default: 10,
},
/**
* Number of reviews to display on page load.
*/
reviewsOnPageLoad: {
type: 'number',
default: 10,
},
/**
* Show the load more button.
*/
showLoadMore: {
type: 'boolean',
default: true,
},
/**
* Show the order by selector.
*/
showOrderby: {
type: 'boolean',
default: true,
},
/**
* Show the review date.
*/
showReviewDate: {
type: 'boolean',
default: true,
},
/**
* Show the reviewer name.
*/
showReviewerName: {
type: 'boolean',
default: true,
},
/**
* Show the review image..
*/
showReviewImage: {
type: 'boolean',
default: true,
},
/**
* Show the product rating.
*/
showReviewRating: {
type: 'boolean',
default: true,
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Editor { ...props } />;
},
/**
* Save the props to post content.
*/
save( { attributes } ) {
const { className, imageType, orderby, productId, reviewsOnPageLoad, reviewsOnLoadMore, showLoadMore, showOrderby, showReviewDate, showReviewerName, showReviewImage, showReviewRating } = attributes;
const classes = classNames( 'wc-block-reviews-by-product', className, {
'has-date': showReviewDate,
'has-name': showReviewerName,
'has-image': showReviewImage,
'has-rating': showReviewRating,
} );
const data = {
'data-image-type': imageType,
'data-product-id': productId,
'data-orderby': orderby,
'data-reviews-on-page-load': reviewsOnPageLoad,
'data-reviews-on-load-more': reviewsOnLoadMore,
'data-show-load-more': showLoadMore,
'data-show-orderby': showOrderby,
};
return (
<div className={ classes } { ...data } />
);
},
} );

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
const enableReviewRating = !! ( typeof wc_product_block_data !== 'undefined' && wc_product_block_data.enableReviewRating );
export const getOrderArgs = ( orderValue ) => {
if ( enableReviewRating ) {
if ( orderValue === 'lowest-rating' ) {
return {
order: 'asc',
orderby: 'rating',
};
}
if ( orderValue === 'highest-rating' ) {
return {
order: 'desc',
orderby: 'rating',
};
}
}
return {
order: 'desc',
orderby: 'date_gmt',
};
};
export const getReviews = ( args ) => {
return apiFetch( {
path: '/wc/blocks/products/reviews?' + Object.entries( args ).map( ( arg ) => arg.join( '=' ) ).join( '&' ),
parse: false,
} ).then( ( response ) => {
return response.json().then( ( reviews ) => {
const totalReviews = parseInt( response.headers.get( 'x-wp-total' ), 10 );
return { reviews, totalReviews };
} );
} );
};

View File

@ -6,5 +6,6 @@ export { default as IconFolderStar } from './folder-star';
export { default as IconNewReleases } from './new-releases';
export { default as IconRadioSelected } from './radio-selected';
export { default as IconRadioUnselected } from './radio-unselected';
export { default as IconReviewsByProduct } from './reviews-by-product';
export { default as IconWidgets } from './widgets';
export { default as IconWoo } from './woo';

View File

@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/components';
export default ( { className, fillColor } ) => (
<Icon
className={ className }
icon={
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill={ fillColor } d="M2.3,17.3h9.3c0.1,0,0.3,0,0.4,0.1l5.9,4.2c0.3,0.2,0.7,0,0.7-0.3v-3.7c0-0.2,0.2-0.4,0.4-0.4H22 c1.1,0,2-0.9,2-2V2.5c0-1.2-0.7-2.2-2-2.2H2.3C0.7,0.2,0,0.9,0,2.5v12.3C0,16.3,0.7,17.3,2.3,17.3z" />
<polygon fill="#ffffff" points="8.8,12.1 6.5,10.9 4.1,12.1 4.5,9.5 2.6,7.6 5.3,7.2 6.5,4.8 7.6,7.2 10.3,7.6 8.4,9.5" />
<path fill="#ffffff" d="M20.7,7.9h-7c-0.5,0-0.9-0.4-0.9-0.9S13.2,6,13.7,6h7c0.5,0,0.9,0.4,0.9,0.9S21.2,7.9,20.7,7.9z" />
<path fill="#ffffff" d="M20.7,11.5h-7c-0.5,0-0.9-0.4-0.9-0.9s0.4-0.9,0.9-0.9h7c0.5,0,0.9,0.4,0.9,0.9S21.2,11.5,20.7,11.5z" />
</svg>
}
/>
);

View File

@ -54,10 +54,15 @@ class ProductControl extends Component {
this.onProductSelect = this.onProductSelect.bind( this );
}
componentDidMount() {
const { selected } = this.props;
componentWillUnmount() {
this.debouncedOnSearch.cancel();
this.debouncedGetVariations.cancel();
}
getProducts( { selected } )
componentDidMount() {
const { selected, queryArgs } = this.props;
getProducts( { selected, queryArgs } )
.then( ( products ) => {
products = products.map( ( product ) => {
const count = product.variations ? product.variations.length : 0;
@ -81,7 +86,7 @@ class ProductControl extends Component {
}
getVariations() {
const { product, variationsList } = this.state;
const { product, products, variationsList } = this.state;
if ( ! product ) {
this.setState( {
@ -91,7 +96,7 @@ class ProductControl extends Component {
return;
}
const productDetails = this.state.products.find( ( findProduct ) => findProduct.id === product );
const productDetails = products.find( ( findProduct ) => findProduct.id === product );
if ( ! productDetails.variations || productDetails.variations.length === 0 ) {
return;
@ -119,8 +124,8 @@ class ProductControl extends Component {
}
onSearch( search ) {
const { selected } = this.props;
getProducts( { selected, search } )
const { selected, queryArgs } = this.props;
getProducts( { selected, search, queryArgs } )
.then( ( products ) => {
this.setState( { products, loading: false } );
} )
@ -238,7 +243,7 @@ class ProductControl extends Component {
render() {
const { products, loading, product, variationsList } = this.state;
const { onChange, selected } = this.props;
const { onChange, renderItem, selected } = this.props;
const currentVariations = variationsList[ product ] || [];
const currentList = [ ...products, ...currentVariations ];
const messages = {
@ -267,9 +272,9 @@ class ProductControl extends Component {
isSingle
selected={ selectedListItems }
onChange={ onChange }
renderItem={ renderItem }
onSearch={ isLargeCatalog ? this.debouncedOnSearch : null }
messages={ messages }
renderItem={ this.renderItem }
isHierarchical
/>
</Fragment>
@ -282,10 +287,18 @@ ProductControl.propTypes = {
* Callback to update the selected products.
*/
onChange: PropTypes.func.isRequired,
/**
* Callback to render each item in the selection list, allows any custom object-type rendering.
*/
renderItem: PropTypes.func.isRequired,
/**
* The ID of the currently selected product.
*/
selected: PropTypes.number.isRequired,
/**
* Query args to pass to getProducts.
*/
queryArgs: PropTypes.object,
};
export default ProductControl;

View File

@ -14,14 +14,20 @@ 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 getProductsRequests = ( { selected = [], search } ) => {
const requests = [
addQueryArgs( ENDPOINTS.products, {
const getProductsRequests = ( { selected = [], search = '', queryArgs = [] } ) => {
const defaultArgs = {
per_page: isLargeCatalog ? 100 : -1,
catalog_visibility: 'any',
status: 'publish',
search,
} ),
orderby: 'title',
order: 'asc',
};
const requests = [
addQueryArgs(
ENDPOINTS.products,
{ ...defaultArgs, ...queryArgs }
),
];
// If we have a large catalog, we might not get all selected products in the first page.
@ -43,8 +49,8 @@ const getProductsRequests = ( { selected = [], search } ) => {
*
* @param {Object} - A query object with the list of selected products and search term.
*/
export const getProducts = ( { selected = [], search } ) => {
const requests = getProductsRequests( { selected, search } );
export const getProducts = ( { selected = [], search = '', queryArgs = [] } ) => {
const requests = getProductsRequests( { selected, search, queryArgs } );
return Promise.all( requests.map( ( path ) => apiFetch( { path } ) ) ).then( ( data ) => {
return uniqBy( flatten( data ), 'id' );

View File

@ -1,4 +1,3 @@
export { default as withComponentId } from './with-component-id';
export { default as withProduct } from './with-product';
export { default as withCategory } from './with-category';
export { default as withSearchedProducts } from './with-searched-products';

View File

@ -5,7 +5,7 @@
"maxSize": "300 kB"
},
{
"path": "./build/frontend*.js",
"path": "./build/*frontend*.js",
"maxSize": "130 kB"
},
{

View File

@ -1394,9 +1394,9 @@
}
},
"@babel/runtime": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.0.tgz",
"integrity": "sha512-2xsuyZ0R0RBFwjgae5NpXk8FcfH4qovj5cEM5VEeB7KXnKqzaisIu2HSV/mCEISolJJuR4wkViUGYujA8MH9tw==",
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.2.tgz",
"integrity": "sha512-9M29wrrP7//JBGX70+IrDuD1w4iOYhUGpJNMQJVNAXue+cFeFlMTqBECouIziXPUphlgrfjcfiEpGX4t0WGK4g==",
"requires": {
"regenerator-runtime": "^0.13.2"
}
@ -2365,6 +2365,28 @@
"react-transition-group": "2.9.0"
},
"dependencies": {
"@woocommerce/navigation": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@woocommerce/navigation/-/navigation-2.1.0.tgz",
"integrity": "sha512-lxw6OQkP4mOiOIButbyOPIFLIgABkrgJEbdGi1N6+VhyWKbGsB1elKukRi7bo57FK+p/+bRFiqeDU+FeusFkcA==",
"requires": {
"@babel/runtime-corejs2": "7.4.3",
"history": "4.9.0",
"lodash": "4.17.11",
"qs": "6.7.0"
},
"dependencies": {
"@babel/runtime-corejs2": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.4.3.tgz",
"integrity": "sha512-anTLTF7IK8Hd5f73zpPzt875I27UaaTWARJlfMGgnmQhvEe1uNHQRKBUbXL0Gc0VEYiVzsHsTPso5XdK8NGvFg==",
"requires": {
"core-js": "^2.6.5",
"regenerator-runtime": "^0.13.2"
}
}
}
},
"@wordpress/components": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@wordpress/components/-/components-7.4.0.tgz",
@ -2605,34 +2627,22 @@
}
},
"@woocommerce/navigation": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@woocommerce/navigation/-/navigation-2.1.0.tgz",
"integrity": "sha512-lxw6OQkP4mOiOIButbyOPIFLIgABkrgJEbdGi1N6+VhyWKbGsB1elKukRi7bo57FK+p/+bRFiqeDU+FeusFkcA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@woocommerce/navigation/-/navigation-2.1.1.tgz",
"integrity": "sha512-ZmYIpmjfIiDJoAUIHiXLtXi+/2e2h9TA9gCqoEJdCa11NX8gzrIkRIcXsc7E+EV/eQT0Ud5PzMccGIpOiCzS5w==",
"dev": true,
"requires": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.5",
"history": "4.9.0",
"lodash": "4.17.11",
"qs": "6.7.0"
},
"dependencies": {
"@babel/runtime-corejs2": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.4.3.tgz",
"integrity": "sha512-anTLTF7IK8Hd5f73zpPzt875I27UaaTWARJlfMGgnmQhvEe1uNHQRKBUbXL0Gc0VEYiVzsHsTPso5XdK8NGvFg==",
"requires": {
"core-js": "^2.6.5",
"regenerator-runtime": "^0.13.2"
}
},
"core-js": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz",
"integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A=="
},
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
"dev": true
}
}
},
@ -7552,8 +7562,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"aproba": {
"version": "1.2.0",
@ -7574,14 +7583,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -7596,20 +7603,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -7726,8 +7730,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -7739,7 +7742,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -7754,7 +7756,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -7762,14 +7763,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -7788,7 +7787,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -7869,8 +7867,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@ -7882,7 +7879,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -7968,8 +7964,7 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
@ -8005,7 +8000,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -8025,7 +8019,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -8069,14 +8062,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
}
}
},
@ -11391,8 +11382,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"aproba": {
"version": "1.2.0",
@ -11413,14 +11403,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -11435,20 +11423,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -11565,8 +11550,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -11578,7 +11562,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -11593,7 +11576,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -11601,14 +11583,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -11627,7 +11607,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -11715,8 +11694,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@ -11728,7 +11706,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -11814,8 +11791,7 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
@ -11851,7 +11827,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -11871,7 +11846,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -11915,14 +11889,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
}
}
},
@ -21349,9 +21321,9 @@
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
},
"tiny-invariant": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.4.tgz",
"integrity": "sha512-lMhRd/djQJ3MoaHEBrw8e2/uM4rs9YMNk0iOr8rHQ0QdbM7D4l0gFl3szKdeixrlyfm9Zqi4dxHCM2qVG8ND5g=="
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.5.tgz",
"integrity": "sha512-BziszNEQNwtyMS9OVJia2LK9N9b6VJ35kBrvhDDDpr4hreLYqhCie15dB35uZMdqv9ZTQ55GHQtkz2FnleTHIA=="
},
"tiny-lr": {
"version": "1.1.1",
@ -21368,9 +21340,9 @@
}
},
"tiny-warning": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.2.tgz",
"integrity": "sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q=="
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"tinycolor2": {
"version": "1.4.1",
@ -21523,6 +21495,11 @@
"integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=",
"dev": true
},
"trim-html": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/trim-html/-/trim-html-0.1.9.tgz",
"integrity": "sha1-puYK1yFeItozcOR7bDRTp8ydSz4="
},
"trim-newlines": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",

View File

@ -37,6 +37,7 @@
},
"devDependencies": {
"@babel/core": "7.5.5",
"@woocommerce/navigation": "^2.1.1",
"@wordpress/babel-preset-default": "4.4.0",
"@wordpress/blocks": "6.5.0",
"@wordpress/browserslist-config": "2.6.0",
@ -71,6 +72,7 @@
"eslint-plugin-jest": "22.15.1",
"har-validator": "5.1.3",
"husky": "2.4.1",
"ignore-loader": "^0.1.2",
"interpolate-components": "1.1.1",
"js-md5": "0.7.3",
"lint-staged": "9.2.1",
@ -95,7 +97,8 @@
},
"dependencies": {
"@woocommerce/components": "3.1.0",
"gridicons": "3.3.1"
"gridicons": "3.3.1",
"trim-html": "^0.1.9"
},
"husky": {
"hooks": {

View File

@ -1,6 +1,6 @@
module.exports = ( { env } ) => ( {
plugins: {
autoprefixer: {},
autoprefixer: { grid: true },
cssnano: env === 'production',
},
} );

View File

@ -49,6 +49,7 @@ class Assets {
self::register_script( 'wc-featured-category', plugins_url( 'build/featured-category.js', __DIR__ ), array( 'wc-vendors', 'wc-blocks' ) );
self::register_script( 'wc-product-categories', plugins_url( 'build/product-categories.js', __DIR__ ), array( 'wc-vendors', 'wc-blocks' ) );
self::register_script( 'wc-product-tag', plugins_url( 'build/product-tag.js', __DIR__ ), array( 'wc-vendors', 'wc-blocks' ) );
self::register_script( 'wc-reviews-by-product', plugins_url( 'build/reviews-by-product.js', __DIR__ ), array( 'wc-vendors', 'wc-blocks' ) );
self::register_script( 'wc-product-search', plugins_url( 'build/product-search.js', __DIR__ ), array( 'wc-vendors', 'wc-blocks' ) );
}
@ -139,6 +140,8 @@ class Assets {
'hasTags' => $tag_count > 0,
'productCategories' => $product_categories,
'homeUrl' => esc_js( home_url( '/' ) ),
'showAvatars' => '1' === get_option( 'show_avatars' ),
'enableReviewRating' => 'yes' === get_option( 'woocommerce_enable_review_rating' ),
);
?>
<script type="text/javascript">

View File

@ -32,7 +32,7 @@ class ProductCategories extends AbstractBlock {
'editor_script' => 'wc-' . $this->block_name,
'editor_style' => 'wc-block-editor',
'style' => 'wc-block-style',
'script' => 'wc-frontend',
'script' => 'wc-' . $this->block_name . '-frontend',
)
);
}
@ -45,7 +45,7 @@ class ProductCategories extends AbstractBlock {
* @return string Rendered block type output.
*/
public function render( $attributes = array(), $content = '' ) {
\Automattic\WooCommerce\Blocks\Assets::register_block_script( 'frontend' );
\Automattic\WooCommerce\Blocks\Assets::register_block_script( $this->block_name . '-frontend' );
return $content;
}

View File

@ -0,0 +1,51 @@
<?php
/**
* Reviews by Product block.
*
* @package WooCommerce\Blocks
*/
namespace Automattic\WooCommerce\Blocks\BlockTypes;
defined( 'ABSPATH' ) || exit;
/**
* ReviewsByProduct class.
*/
class ReviewsByProduct extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'reviews-by-product';
/**
* Registers the block type with WordPress.
*/
public function register_block_type() {
register_block_type(
$this->namespace . '/' . $this->block_name,
array(
'render_callback' => array( $this, 'render' ),
'editor_script' => 'wc-' . $this->block_name,
'editor_style' => 'wc-block-editor',
'style' => 'wc-block-style',
'script' => 'wc-' . $this->block_name . '-frontend',
)
);
}
/**
* Append frontend scripts when rendering the Product Categories List block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @return string Rendered block type output.
*/
public function render( $attributes = array(), $content = '' ) {
\Automattic\WooCommerce\Blocks\Assets::register_block_script( $this->block_name . '-frontend' );
return $content;
}
}

View File

@ -36,6 +36,7 @@ class Library {
'ProductOnSale',
'ProductsByAttribute',
'ProductTopRated',
'ReviewsByProduct',
'ProductSearch',
'ProductTag',
];

View File

@ -46,6 +46,7 @@ class RestApi {
'product-tags' => __NAMESPACE__ . '\RestApi\Controllers\ProductTags',
'products' => __NAMESPACE__ . '\RestApi\Controllers\Products',
'variations' => __NAMESPACE__ . '\RestApi\Controllers\Variations',
'product-reviews' => __NAMESPACE__ . '\RestApi\Controllers\ProductReviews',
];
}
}

View File

@ -0,0 +1,415 @@
<?php
/**
* REST API Reviews controller customized for Blocks.
*
* Handles requests to the /products/reviews endpoint.
*
* @package WooCommerce/Blocks
* @internal This API is used internally by the block post editor--it is still in flux. It should not be used outside of wc-blocks.
* @internal This endpoint is open to anyone and is used on the frontend. Personal data such as reviewer email address is purposely left out. Only approved reviews are returned.
*/
namespace Automattic\WooCommerce\Blocks\RestApi\Controllers;
defined( 'ABSPATH' ) || exit;
use \WC_REST_Controller;
/**
* REST API Product Reviews controller class.
*/
class ProductReviews extends WC_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/blocks';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'products/reviews';
/**
* Register the routes for product reviews.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => 'GET',
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check if a given request has access to read the attributes.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( 'edit' === $request['context'] ) {
return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit resources.', 'woo-gutenberg-products-block' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Get all reviews.
*
* @param WP_REST_Request $request Full details about the request.
* @return array|WP_Error
*/
public function get_items( $request ) {
// Retrieve the list of registered collection query parameters.
$registered = $this->get_collection_params();
/*
* This array defines mappings between public API query parameters whose
* values are accepted as-passed, and their internal WP_Query parameter
* name equivalents (some are the same). Only values which are also
* present in $registered will be set.
*/
$parameter_mappings = array(
'offset' => 'offset',
'order' => 'order',
'per_page' => 'number',
'product_id' => 'post__in',
);
$prepared_args = array(
'type' => 'review',
'status' => 'approve',
'no_found_rows' => false,
);
/*
* For each known parameter which is both registered and present in the request,
* set the parameter's value on the query $prepared_args.
*/
foreach ( $parameter_mappings as $api_param => $wp_param ) {
if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
$prepared_args[ $wp_param ] = $request[ $api_param ];
}
}
/**
* Map category id to list of product ids.
*/
if ( isset( $registered['category_id'] ) && ! empty( $request['category_id'] ) ) {
$category_ids = wp_parse_id_list( $request['category_id'] );
$child_ids = [];
foreach ( $category_ids as $category_id ) {
$child_ids = array_merge( $child_ids, get_term_children( $category_id, 'product_cat' ) );
}
$category_ids = array_unique( array_merge( $category_ids, $child_ids ) );
$product_ids = get_objects_in_term( $category_ids, 'product_cat' );
$prepared_args['post__in'] = isset( $prepared_args['post__in'] ) ? array_merge( $prepared_args['post__in'], $product_ids ) : $product_ids;
}
if ( isset( $registered['orderby'] ) ) {
if ( 'rating' === $request['orderby'] ) {
$prepared_args['meta_query'] = array( // phpcs:ignore
'relation' => 'OR',
array(
'key' => 'rating',
'compare' => 'EXISTS',
),
array(
'key' => 'rating',
'compare' => 'NOT EXISTS',
),
);
}
$prepared_args['orderby'] = $this->normalize_query_param( $request['orderby'] );
}
if ( isset( $registered['page'] ) && empty( $request['offset'] ) ) {
$prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 );
}
$query = new \WP_Comment_Query();
$query_result = $query->query( $prepared_args );
$reviews = array();
foreach ( $query_result as $review ) {
$data = $this->prepare_item_for_response( $review, $request );
$reviews[] = $this->prepare_response_for_collection( $data );
}
$total_reviews = (int) $query->found_comments;
$max_pages = (int) $query->max_num_pages;
if ( $total_reviews < 1 ) {
// Out-of-bounds, run the query again without LIMIT for total count.
unset( $prepared_args['number'], $prepared_args['offset'] );
$query = new \WP_Comment_Query();
$prepared_args['count'] = true;
$total_reviews = $query->query( $prepared_args );
$max_pages = ceil( $total_reviews / $request['per_page'] );
}
$response = rest_ensure_response( $reviews );
$response->header( 'X-WP-Total', $total_reviews );
$response->header( 'X-WP-TotalPages', $max_pages );
$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
if ( $request['page'] > 1 ) {
$prev_page = $request['page'] - 1;
if ( $prev_page > $max_pages ) {
$prev_page = $max_pages;
}
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $max_pages > $request['page'] ) {
$next_page = $request['page'] + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
/**
* Prepends internal property prefix to query parameters to match our response fields.
*
* @param string $query_param Query parameter.
* @return string
*/
protected function normalize_query_param( $query_param ) {
$prefix = 'comment_';
switch ( $query_param ) {
case 'id':
$normalized = $prefix . 'ID';
break;
case 'product':
$normalized = $prefix . 'post_ID';
break;
case 'rating':
$normalized = 'meta_value_num';
break;
default:
$normalized = $prefix . $query_param;
break;
}
return $normalized;
}
/**
* Prepare a single product review output for response.
*
* @param \WP_Comment $review Product review object.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $review, $request ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$rating = get_comment_meta( $review->comment_ID, 'rating', true ) === '' ? null : (int) get_comment_meta( $review->comment_ID, 'rating', true );
$data = array(
'id' => (int) $review->comment_ID,
'date_created' => wc_rest_prepare_date_response( $review->comment_date ),
'formatted_date_created' => get_comment_date( 'F j, Y', $review->comment_ID ),
'date_created_gmt' => wc_rest_prepare_date_response( $review->comment_date_gmt ),
'product_id' => (int) $review->comment_post_ID,
'product_picture' => get_the_post_thumbnail_url( (int) $review->comment_post_ID, 96 ),
'reviewer' => $review->comment_author,
'review' => $review->comment_content,
'rating' => $rating,
'verified' => wc_review_is_from_verified_owner( $review->comment_ID ),
'reviewer_avatar_urls' => rest_get_avatar_urls( $review->comment_author_email ),
);
if ( 'view' === $context ) {
$data['review'] = wpautop( $data['review'] );
}
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Get the Product Review's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'product_block_review',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_created' => array(
'description' => __( "The date the review was created, in the site's timezone.", 'woo-gutenberg-products-block' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'formatted_date_created' => array(
'description' => __( "The date the review was created, in the site's timezone in human-readable format.", 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_created_gmt' => array(
'description' => __( 'The date the review was created, as GMT.', 'woo-gutenberg-products-block' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'product_id' => array(
'description' => __( 'Unique identifier for the product that the review belongs to.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'product_picture' => array(
'description' => __( 'Image of the product that the review belongs to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'reviewer' => array(
'description' => __( 'Reviewer name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'review' => array(
'description' => __( 'The content of the review.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'wp_filter_post_kses',
),
'readonly' => true,
),
'rating' => array(
'description' => __( 'Review rating (0 to 5).', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'verified' => array(
'description' => __( 'Shows if the reviewer bought the product or not.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
if ( get_option( 'show_avatars' ) ) {
$avatar_properties = array();
$avatar_sizes = rest_get_avatar_sizes();
foreach ( $avatar_sizes as $size ) {
$avatar_properties[ $size ] = array(
/* translators: %d: avatar image size in pixels */
'description' => sprintf( __( 'Avatar URL with image size of %d pixels.', 'woo-gutenberg-products-block' ), $size ),
'type' => 'string',
'format' => 'uri',
'context' => array( 'embed', 'view', 'edit' ),
);
}
$schema['properties']['reviewer_avatar_urls'] = array(
'description' => __( 'Avatar URLs for the object reviewer.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $avatar_properties,
);
}
return $schema;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['context']['default'] = 'view';
$params['offset'] = array(
'description' => __( 'Offset the result set by a specific number of items.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => 'desc',
'enum' => array(
'asc',
'desc',
),
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => 'date_gmt',
'enum' => array(
'date',
'date_gmt',
'id',
'rating',
'product',
),
);
$params['product_id'] = array(
'default' => array(),
'description' => __( 'Limit result set to reviews assigned to specific product IDs.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
);
$params['category_id'] = array(
'default' => array(),
'description' => __( 'Limit result set to reviews from products within specific category IDs.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
);
return $params;
}
}

View File

@ -207,6 +207,7 @@ class Products extends WC_REST_Products_Controller {
'price_html' => $product->get_price_html(),
'images' => $this->get_images( $product ),
'average_rating' => $product->get_average_rating(),
'review_count' => $product->get_review_count(),
);
}
@ -260,7 +261,7 @@ class Products extends WC_REST_Products_Controller {
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array_merge( $params['orderby']['enum'], array( 'price', 'popularity', 'rating', 'menu_order' ) );
$params['orderby']['enum'] = array_merge( $params['orderby']['enum'], array( 'menu_order', 'comment_count' ) );
$params['category_operator'] = array(
'description' => __( 'Operator to compare product category terms.', 'woo-gutenberg-products-block' ),
'type' => 'string',
@ -358,6 +359,12 @@ class Products extends WC_REST_Products_Controller {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'review_count' => array(
'description' => __( 'Amount of reviews that the product has.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'images' => array(
'description' => __( 'List of images.', 'woo-gutenberg-products-block' ),
'type' => 'object',

View File

@ -58,6 +58,7 @@ const GutenbergBlocksConfig = {
'product-top-rated': './assets/js/blocks/product-top-rated/index.js',
'products-by-attribute': './assets/js/blocks/products-by-attribute/index.js',
'featured-product': './assets/js/blocks/featured-product/index.js',
'reviews-by-product': './assets/js/blocks/reviews-by-product/index.js',
'product-search': './assets/js/blocks/product-search/index.js',
'product-tag': './assets/js/blocks/product-tag/index.js',
'featured-category': './assets/js/blocks/featured-category/index.js',
@ -86,17 +87,15 @@ const GutenbergBlocksConfig = {
test: ( module = {} ) =>
module.constructor.name === 'CssModule' &&
( findModuleMatch( module, /editor\.scss$/ ) ||
findModuleMatch( module, /[\\/]components[\\/]/ ) ),
findModuleMatch( module, /[\\/]assets[\\/]components[\\/]/ ) ),
name: 'editor',
chunks: 'all',
enforce: true,
priority: 10,
},
style: {
test: /style\.scss$/,
name: 'style',
chunks: 'all',
enforce: true,
priority: 5,
},
},
@ -156,10 +155,17 @@ const GutenbergBlocksConfig = {
const BlocksFrontendConfig = {
...baseConfig,
entry: './assets/js/blocks/product-categories/frontend.js',
entry: {
'product-categories': './assets/js/blocks/product-categories/frontend.js',
'reviews-by-product': './assets/js/blocks/reviews-by-product/frontend.js',
},
output: {
path: path.resolve( __dirname, './build/' ),
filename: 'frontend.js',
filename: '[name]-frontend.js',
// This fixes an issue with multiple webpack projects using chunking
// overwriting each other's chunk loader function.
// See https://webpack.js.org/configuration/output/#outputjsonpfunction
jsonpFunction: 'webpackWcBlocksJsonp',
},
module: {
rules: [
@ -187,6 +193,12 @@ const BlocksFrontendConfig = {
},
},
},
{
test: /\.s[c|a]ss$/,
use: {
loader: 'ignore-loader',
},
},
],
},
plugins: [