* Create initial reviews panel and displaying it on the home screen

* Update reviews package to support updating and deleting reviews

* Allow custom icons to be defined for rating component

* Add approve, spam, and delete actions to home screen review panel

* Show entire list as updating when items are still in the store

* Update rating to only import the required icons, and allow icons to be passed in instead of strings

* Prune out reviews header panel, as we are not using it anymore

* Showing just a header if collapsible is false for activity panel

* Add tests for reviews panel and accordion changes

* Fix undoing a deleted item by using status - untrash

* Several styling changes to match wireframe as mentioned in PR review

* Moved review rating into the subtitle in relation to new design

* Update clear cache logic for last item

* Remove activity panel unused css

* Use invalideResolution instead of invalidateResolutionForStoreSelector
This commit is contained in:
louwie17 2020-12-02 09:30:39 -04:00 committed by GitHub
parent e778a3c0fc
commit 98a55aaeb9
22 changed files with 1084 additions and 534 deletions

View File

@ -1,8 +1,7 @@
Activity Panel # Activity Panel
======
This component contains the Activity Panel. This is shown on every page and is rendered as part of the header. This component contains the Activity Panel. This is shown on every page and is rendered as part of the header.
It provides access to the notices system and actionable items like reviews and stock. It provides access to the notices system and actionable items like stock.
## Components ## Components

View File

@ -142,6 +142,11 @@
height: 24px; height: 24px;
padding: 4px 10px; padding: 4px 10px;
@include font-size( 11 ); @include font-size( 11 );
&.is-destructive {
&:not(:hover) {
box-shadow: none;
}
}
} }
} }
@ -256,61 +261,6 @@
} }
} }
.woocommerce-review-activity-card {
.woocommerce-activity-card__body > span > p {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.woocommerce-review-activity-card__verified {
margin-left: $gap-small;
display: inline-flex;
position: relative;
top: $gap-smallest;
color: $valid-green;
@include font-size( 12 );
.gridicon {
margin-right: $gap-smallest;
fill: $valid-green;
}
}
.woocommerce-review-activity-card__image-overlay {
position: relative;
img.woocommerce-gravatar {
border: 2px solid $studio-white;
left: 0;
position: absolute;
top: -6px;
z-index: 2;
}
}
@include breakpoint( '<782px' ) {
.woocommerce-review-activity-card__image-overlay {
margin-top: $gap-smallest;
}
.woocommerce-review-activity-card__image-overlay__product
.woocommerce-gravatar {
margin-left: 0;
width: 18px;
height: 18px;
left: 32px;
top: -28px;
z-index: 1;
}
}
}
.woocommerce-review-activity-card__image-overlay__product,
.woocommerce-stock-activity-card__image-overlay__product { .woocommerce-stock-activity-card__image-overlay__product {
height: 33px; height: 33px;
position: relative; position: relative;

View File

@ -11,7 +11,7 @@ import { uniqueId, find } from 'lodash';
import CrossIcon from 'gridicons/dist/cross-small'; import CrossIcon from 'gridicons/dist/cross-small';
import classnames from 'classnames'; import classnames from 'classnames';
import { Icon, help as helpIcon } from '@wordpress/icons'; import { Icon, help as helpIcon } from '@wordpress/icons';
import { getSetting, getAdminLink } from '@woocommerce/wc-admin-settings'; import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { H, Section, Spinner } from '@woocommerce/components'; import { H, Section, Spinner } from '@woocommerce/components';
import { OPTIONS_STORE_NAME } from '@woocommerce/data'; import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { getHistory, getNewPath } from '@woocommerce/navigation'; import { getHistory, getNewPath } from '@woocommerce/navigation';
@ -21,7 +21,7 @@ import { getHistory, getNewPath } from '@woocommerce/navigation';
*/ */
import './style.scss'; import './style.scss';
import ActivityPanelToggleBubble from './toggle-bubble'; import ActivityPanelToggleBubble from './toggle-bubble';
import { getUnreadNotes, getUnapprovedReviews } from './unread-indicators'; import { getUnreadNotes } from './unread-indicators';
import { isWCAdmin } from '../../dashboard/utils'; import { isWCAdmin } from '../../dashboard/utils';
import { Tabs } from './tabs'; import { Tabs } from './tabs';
import { SetupProgress } from './setup-progress'; import { SetupProgress } from './setup-progress';
@ -37,11 +37,6 @@ const InboxPanel = lazy( () =>
) )
); );
const ReviewsPanel = lazy( () =>
import( /* webpackChunkName: "activity-panels-inbox" */ './panels/reviews' )
);
const reviewsEnabled = getSetting( 'reviewsEnabled', 'no' );
export class ActivityPanel extends Component { export class ActivityPanel extends Component {
constructor( props ) { constructor( props ) {
super( props ); super( props );
@ -132,7 +127,6 @@ export class ActivityPanel extends Component {
getTabs() { getTabs() {
const { const {
hasUnreadNotes, hasUnreadNotes,
hasUnapprovedReviews,
isEmbedded, isEmbedded,
taskListComplete, taskListComplete,
taskListHidden, taskListHidden,
@ -150,9 +144,6 @@ export class ActivityPanel extends Component {
const showDisplayOptions = const showDisplayOptions =
! isEmbedded && this.isHomescreen() && ! isPerformingSetupTask; ! isEmbedded && this.isHomescreen() && ! isPerformingSetupTask;
const showReviews =
( taskListComplete || taskListHidden ) && ! isPerformingSetupTask;
const showStoreSetup = const showStoreSetup =
! taskListComplete && ! taskListHidden && ! isPerformingSetupTask; ! taskListComplete && ! taskListHidden && ! isPerformingSetupTask;
@ -173,20 +164,6 @@ export class ActivityPanel extends Component {
} }
: null; : null;
const reviews =
showReviews && reviewsEnabled === 'yes'
? {
name: 'reviews',
title: __( 'Reviews', 'woocommerce-admin' ),
icon: (
<i className="material-icons-outlined">
star_border
</i>
),
unread: hasUnapprovedReviews,
}
: null;
const help = showHelp const help = showHelp
? { ? {
name: 'help', name: 'help',
@ -201,24 +178,16 @@ export class ActivityPanel extends Component {
} }
: null; : null;
return [ inbox, reviews, setup, displayOptions, help ].filter( return [ inbox, setup, displayOptions, help ].filter( Boolean );
Boolean
);
} }
getPanelContent( tab ) { getPanelContent( tab ) {
const { query, hasUnapprovedReviews } = this.props; const { query } = this.props;
const { task } = query; const { task } = query;
switch ( tab ) { switch ( tab ) {
case 'inbox': case 'inbox':
return <InboxPanel />; return <InboxPanel />;
case 'reviews':
return (
<ReviewsPanel
hasUnapprovedReviews={ hasUnapprovedReviews }
/>
);
case 'help': case 'help':
return <HelpPanel taskName={ task } />; return <HelpPanel taskName={ task } />;
default: default:
@ -378,7 +347,6 @@ ActivityPanel.defaultProps = {
export default compose( export default compose(
withSelect( ( select ) => { withSelect( ( select ) => {
const hasUnreadNotes = getUnreadNotes( select ); const hasUnreadNotes = getUnreadNotes( select );
const hasUnapprovedReviews = getUnapprovedReviews( select );
const { getOption, isResolving } = select( OPTIONS_STORE_NAME ); const { getOption, isResolving } = select( OPTIONS_STORE_NAME );
const taskListComplete = const taskListComplete =
@ -391,7 +359,6 @@ export default compose(
return { return {
hasUnreadNotes, hasUnreadNotes,
hasUnapprovedReviews,
requestingTaskListOptions, requestingTaskListOptions,
taskListComplete, taskListComplete,
taskListHidden, taskListHidden,

View File

@ -1,370 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { Component, Fragment } from '@wordpress/element';
import { withSelect } from '@wordpress/data';
import { Button } from '@wordpress/components';
import CheckmarkIcon from 'gridicons/dist/checkmark';
import TimeIcon from 'gridicons/dist/time';
import interpolateComponents from 'interpolate-components';
import { get, isNull } from 'lodash';
import PropTypes from 'prop-types';
import {
EmptyContent,
Gravatar,
Link,
ProductImage,
ReviewRating,
Section,
} from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { REVIEWS_STORE_NAME, QUERY_DEFAULTS } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { ActivityCard, ActivityCardPlaceholder } from '../activity-card';
import ActivityHeader from '../activity-header';
import sanitizeHTML from '../../../lib/sanitize-html';
class ReviewsPanel extends Component {
constructor() {
super();
this.mountTime = new Date().getTime();
}
recordReviewEvent( eventName ) {
recordEvent( `activity_panel_reviews_${ eventName }`, {} );
}
renderReview( review, props ) {
const { lastRead } = props;
const product =
( review &&
review._embedded &&
review._embedded.up &&
review._embedded.up[ 0 ] ) ||
null;
if ( isNull( product ) ) {
return null;
}
const title = interpolateComponents( {
mixedString: sprintf(
__(
'{{productLink}}%s{{/productLink}} reviewed by {{authorLink}}%s{{/authorLink}}',
'woocommerce-admin'
),
product.name,
review.reviewer
),
components: {
productLink: (
<Link
href={ product.permalink }
onClick={ () => this.recordReviewEvent( 'product' ) }
type="external"
/>
),
authorLink: (
<Link
href={ 'mailto:' + review.reviewer_email }
onClick={ () => this.recordReviewEvent( 'customer' ) }
type="external"
/>
),
},
} );
const subtitle = (
<Fragment>
<ReviewRating review={ review } />
{ review.verified && (
<span className="woocommerce-review-activity-card__verified">
<CheckmarkIcon size={ 18 } />
{ __( 'Verified customer', 'woocommerce-admin' ) }
</span>
) }
</Fragment>
);
const productImage =
get( product, [ 'images', 0 ] ) || get( product, [ 'image' ] );
const productImageClasses = classnames(
'woocommerce-review-activity-card__image-overlay__product',
{
'is-placeholder': ! productImage || ! productImage.src,
}
);
const icon = (
<div className="woocommerce-review-activity-card__image-overlay">
<Gravatar user={ review.reviewer_email } size={ 24 } />
<div className={ productImageClasses }>
<ProductImage product={ product } />
</div>
</div>
);
const manageReviewEvent = {
date: review.date_created_gmt,
status: review.status,
};
const cardActions = (
<Button
isSecondary
onClick={ () =>
recordEvent( 'review_manage_click', manageReviewEvent )
}
href={ getAdminLink(
'comment.php?action=editcomment&c=' + review.id
) }
>
{ __( 'Manage', 'woocommerce-admin' ) }
</Button>
);
return (
<ActivityCard
className="woocommerce-review-activity-card"
key={ review.id }
title={ title }
subtitle={ subtitle }
date={ review.date_created_gmt }
icon={ icon }
actions={ cardActions }
unread={
review.status === 'hold' ||
! lastRead ||
! review.date_created_gmt ||
new Date( review.date_created_gmt + 'Z' ).getTime() >
lastRead
}
>
<span
dangerouslySetInnerHTML={ sanitizeHTML( review.review ) }
/>
</ActivityCard>
);
}
renderEmptyMessage() {
const { lastApprovedReviewTime } = this.props;
const title = __(
'You have no reviews to moderate',
'woocommerce-admin'
);
let buttonUrl = '';
let buttonTarget = '';
let buttonText = '';
let content = '';
let eventName = 'learn_more';
if ( lastApprovedReviewTime ) {
const now = new Date();
const DAY = 24 * 60 * 60 * 1000;
if ( ( now.getTime() - lastApprovedReviewTime ) / DAY > 30 ) {
buttonUrl =
'https://woocommerce.com/posts/reviews-woocommerce-best-practices/';
buttonTarget = '_blank';
buttonText = __( 'Learn more', 'woocommerce-admin' );
content = (
<Fragment>
<p>
{ __(
"We noticed that it's been a while since your products had any reviews.",
'woocommerce-admin'
) }
</p>
<p>
{ __(
'Take some time to learn about best practices for collecting and using your reviews.',
'woocommerce-admin'
) }
</p>
</Fragment>
);
} else {
buttonUrl = getAdminLink(
'edit-comments.php?comment_type=review'
);
buttonText = __( 'View all Reviews', 'woocommerce-admin' );
content = (
<p>
{ __(
/* eslint-disable max-len */
"Awesome, you've moderated all of your product reviews. How about responding to some of those negative reviews?",
'woocommerce-admin'
/* eslint-enable */
) }
</p>
);
eventName = 'view_reviews';
}
} else {
buttonUrl =
'https://woocommerce.com/posts/reviews-woocommerce-best-practices/';
buttonTarget = '_blank';
buttonText = __( 'Learn more', 'woocommerce-admin' );
content = (
<Fragment>
<p>
{ __(
"Your customers haven't started reviewing your products.",
'woocommerce-admin'
) }
</p>
<p>
{ __(
'Take some time to learn about best practices for collecting and using your reviews.',
'woocommerce-admin'
) }
</p>
</Fragment>
);
}
return (
<ActivityCard
className="woocommerce-empty-activity-card"
title={ title }
icon={ <TimeIcon size={ 48 } /> }
actions={
<Button
href={ buttonUrl }
target={ buttonTarget }
isSecondary
onClick={ () => this.recordReviewEvent( eventName ) }
>
{ buttonText }
</Button>
}
>
{ content }
</ActivityCard>
);
}
render() {
const { isError, isRequesting, reviews } = this.props;
if ( isError ) {
const title = __(
'There was an error getting your reviews. Please try again.',
'woocommerce-admin'
);
const actionLabel = __( 'Reload', 'woocommerce-admin' );
const actionCallback = () => {
window.location.reload();
};
return (
<Fragment>
<EmptyContent
title={ title }
actionLabel={ actionLabel }
actionURL={ null }
actionCallback={ actionCallback }
/>
</Fragment>
);
}
const title =
isRequesting || reviews.length
? __( 'Reviews', 'woocommerce-admin' )
: __( 'No reviews to moderate', 'woocommerce-admin' );
return (
<Fragment>
<ActivityHeader title={ title } />
<Section>
{ isRequesting ? (
<ActivityCardPlaceholder
className="woocommerce-review-activity-card"
hasAction
hasDate
lines={ 2 }
/>
) : (
<Fragment>
{ reviews.length
? reviews.map( ( review ) =>
this.renderReview( review, this.props )
)
: this.renderEmptyMessage() }
</Fragment>
) }
</Section>
</Fragment>
);
}
}
ReviewsPanel.propTypes = {
reviews: PropTypes.array.isRequired,
isError: PropTypes.bool,
isRequesting: PropTypes.bool,
};
ReviewsPanel.defaultProps = {
reviews: [],
isError: false,
isRequesting: false,
};
export default withSelect( ( select, props ) => {
const { hasUnapprovedReviews } = props;
const { getReviews, getReviewsError, isResolving } = select(
REVIEWS_STORE_NAME
);
let reviews = [];
let isError = false;
let isRequesting = false;
let lastApprovedReviewTime = null;
if ( hasUnapprovedReviews ) {
const reviewsQuery = {
page: 1,
per_page: QUERY_DEFAULTS.pageSize,
status: 'hold',
_embed: 1,
};
reviews = getReviews( reviewsQuery );
isError = Boolean( getReviewsError( reviewsQuery ) );
isRequesting = isResolving( 'getReviews', [ reviewsQuery ] );
} else {
const approvedReviewsQuery = {
page: 1,
per_page: 1,
status: 'approved',
_embed: 1,
};
const approvedReviews = getReviews( approvedReviewsQuery );
if ( approvedReviews.length ) {
const lastApprovedReview = approvedReviews[ 0 ];
if ( lastApprovedReview.date_created_gmt ) {
const creationDate = new Date(
lastApprovedReview.date_created_gmt
);
lastApprovedReviewTime = creationDate.getTime();
}
}
isError = Boolean( getReviewsError( approvedReviewsQuery ) );
isRequesting = isResolving( 'getReviews', [ approvedReviewsQuery ] );
}
return {
reviews,
isError,
isRequesting,
lastApprovedReviewTime,
};
} )( ReviewsPanel );

View File

@ -3,7 +3,6 @@
*/ */
import { import {
NOTES_STORE_NAME, NOTES_STORE_NAME,
REVIEWS_STORE_NAME,
USER_STORE_NAME, USER_STORE_NAME,
QUERY_DEFAULTS, QUERY_DEFAULTS,
} from '@woocommerce/data'; } from '@woocommerce/data';
@ -57,40 +56,6 @@ export function getUnreadNotes( select ) {
return unreadNotesCount > 0; return unreadNotesCount > 0;
} }
export function getUnapprovedReviews( select ) {
const { getReviewsTotalCount, getReviewsError, isResolving } = select(
REVIEWS_STORE_NAME
);
const reviewsEnabled = getSetting( 'reviewsEnabled' );
if ( reviewsEnabled === 'yes' ) {
const actionableReviewsQuery = {
page: 1,
// @todo we are not using this review, so when the endpoint supports it,
// it could be replaced with `per_page: 0`
per_page: 1,
status: 'hold',
};
const totalActionableReviews = getReviewsTotalCount(
actionableReviewsQuery
);
const isActionableReviewsError = Boolean(
getReviewsError( actionableReviewsQuery )
);
const isActionableReviewsRequesting = isResolving(
'getReviewsTotalCount',
[ actionableReviewsQuery ]
);
if ( ! isActionableReviewsError && ! isActionableReviewsRequesting ) {
return totalActionableReviews > 0;
}
}
return false;
}
export function getLowStockCount() { export function getLowStockCount() {
return getSetting( 'lowStockCount', 0 ); return getSetting( 'lowStockCount', 0 );
} }

View File

@ -16,14 +16,17 @@ import {
getUnreadOrders, getUnreadOrders,
} from './orders/utils'; } from './orders/utils';
import { getAllPanels } from './panels'; import { getAllPanels } from './panels';
import { getUnapprovedReviews } from './reviews/utils';
export const ActivityPanel = () => { export const ActivityPanel = () => {
const panelsData = useSelect( ( select ) => { const panelsData = useSelect( ( select ) => {
const totalOrderCount = getSetting( 'orderCount', 0 ); const totalOrderCount = getSetting( 'orderCount', 0 );
const orderStatuses = getOrderStatuses( select ); const orderStatuses = getOrderStatuses( select );
const reviewsEnabled = getSetting( 'reviewsEnabled', 'no' );
const countUnreadOrders = getUnreadOrders( select, orderStatuses ); const countUnreadOrders = getUnreadOrders( select, orderStatuses );
const manageStock = getSetting( 'manageStock', 'no' ); const manageStock = getSetting( 'manageStock', 'no' );
const countLowStockProducts = getLowStockCount( select ); const countLowStockProducts = getLowStockCount( select );
const countUnapprovedReviews = getUnapprovedReviews( select );
return { return {
countLowStockProducts, countLowStockProducts,
@ -31,6 +34,8 @@ export const ActivityPanel = () => {
manageStock, manageStock,
orderStatuses, orderStatuses,
totalOrderCount, totalOrderCount,
reviewsEnabled,
countUnapprovedReviews,
}; };
} ); } );
@ -55,6 +60,7 @@ export const ActivityPanel = () => {
count={ count } count={ count }
initialOpen={ initialOpen } initialOpen={ initialOpen }
title={ title } title={ title }
collapsible={ count !== 0 }
> >
{ panel } { panel }
</AccordionPanel> </AccordionPanel>

View File

@ -8,6 +8,7 @@ import { __ } from '@wordpress/i18n';
*/ */
import OrdersPanel from './orders'; import OrdersPanel from './orders';
import StockPanel from './stock'; import StockPanel from './stock';
import ReviewsPanel from './reviews';
export function getAllPanels( { export function getAllPanels( {
countLowStockProducts, countLowStockProducts,
@ -15,6 +16,8 @@ export function getAllPanels( {
manageStock, manageStock,
orderStatuses, orderStatuses,
totalOrderCount, totalOrderCount,
reviewsEnabled,
countUnapprovedReviews,
} ) { } ) {
return [ return [
totalOrderCount > 0 && { totalOrderCount > 0 && {
@ -40,6 +43,18 @@ export function getAllPanels( {
), ),
title: __( 'Stock', 'woocommerce-admin' ), title: __( 'Stock', 'woocommerce-admin' ),
}, },
reviewsEnabled === 'yes' && {
className: 'woocommerce-homescreen-card',
id: 'reviews-panel',
count: countUnapprovedReviews,
initialOpen: false,
panel: (
<ReviewsPanel
hasUnapprovedReviews={ countUnapprovedReviews > 0 }
/>
),
title: __( 'Reviews', 'woocommerce-admin' ),
},
// Add another panel row here // Add another panel row here
].filter( Boolean ); ].filter( Boolean );
} }

View File

@ -0,0 +1,421 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { Component, Fragment } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import { withSelect, withDispatch } from '@wordpress/data';
import PropTypes from 'prop-types';
import CheckmarkIcon from 'gridicons/dist/checkmark';
import StarIcon from 'gridicons/dist/star';
import StarOutlineIcon from 'gridicons/dist/star-outline';
import interpolateComponents from 'interpolate-components';
import {
EmptyContent,
Link,
ReviewRating,
ProductImage,
Section,
} from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { get, isNull } from 'lodash';
import { REVIEWS_STORE_NAME } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './style.scss';
import {
ActivityCard,
ActivityCardPlaceholder,
} from '../../../header/activity-panel/activity-card';
import { CurrencyContext } from '../../../lib/currency-context';
import sanitizeHTML from '../../../lib/sanitize-html';
import { REVIEW_PAGE_LIMIT, unapprovedReviewsQuery } from './utils';
const reviewsQuery = {
page: 1,
per_page: REVIEW_PAGE_LIMIT,
status: 'hold',
_embed: 1,
};
class ReviewsPanel extends Component {
recordReviewEvent( eventName, eventData ) {
recordEvent( `reviews_${ eventName }`, eventData || {} );
}
deleteReview( reviewId ) {
const {
deleteReview,
createNotice,
updateReview,
clearReviewsCache,
} = this.props;
if ( reviewId ) {
deleteReview( reviewId )
.then( () => {
clearReviewsCache();
createNotice(
'success',
__(
'Review successfully deleted.',
'woocommerce-admin'
),
{
actions: [
{
label: __( 'Undo', 'woocommerce-admin' ),
onClick: () => {
updateReview(
reviewId,
{
status: 'untrash',
},
{
_embed: 1,
}
).then( () => clearReviewsCache() );
},
},
],
}
);
} )
.catch( () => {
createNotice(
'error',
__(
'Review could not be deleted.',
'woocommerce-admin'
)
);
} );
}
}
updateReviewStatus( reviewId, newStatus, oldStatus ) {
const { createNotice, updateReview, clearReviewsCache } = this.props;
if ( reviewId ) {
updateReview( reviewId, { status: newStatus } )
.then( () => {
clearReviewsCache();
createNotice(
'success',
__(
'Review successfully updated.',
'woocommerce-admin'
),
{
actions: [
{
label: __( 'Undo', 'woocommerce-admin' ),
onClick: () => {
updateReview(
reviewId,
{
status: oldStatus,
},
{
_embed: 1,
}
).then( () => clearReviewsCache() );
},
},
],
}
);
} )
.catch( () => {
createNotice(
'error',
__(
'Review could not be updated.',
'woocommerce-admin'
)
);
} );
}
}
renderReview( review ) {
const product =
( review &&
review._embedded &&
review._embedded.up &&
review._embedded.up[ 0 ] ) ||
null;
if ( review.isUpdating || this.props.isRequesting ) {
return (
<ActivityCardPlaceholder
key={ review.id }
className="woocommerce-review-activity-card"
hasAction
hasDate
lines={ 1 }
/>
);
}
if ( isNull( product ) ) {
return null;
}
const title = interpolateComponents( {
mixedString: sprintf(
__(
'{{authorLink}}%s{{/authorLink}} reviewed {{productLink}}%s{{/productLink}}',
'woocommerce-admin'
),
review.reviewer,
product.name
),
components: {
productLink: (
<Link
href={ product.permalink }
onClick={ () => this.recordReviewEvent( 'product' ) }
type="external"
/>
),
authorLink: (
<Link
href={ getAdminLink(
'admin.php?page=wc-admin&path=%2Fcustomers&search=' +
review.reviewer
) }
onClick={ () => this.recordReviewEvent( 'customer' ) }
type="external"
/>
),
},
} );
const subtitle = (
<Fragment>
<ReviewRating
review={ review }
icon={ StarOutlineIcon }
outlineIcon={ StarIcon }
size={ 13 }
/>
{ review.verified && (
<span className="woocommerce-review-activity-card__verified">
<CheckmarkIcon size={ 18 } />
{ __( 'Verified customer', 'woocommerce-admin' ) }
</span>
) }
</Fragment>
);
const productImage =
get( product, [ 'images', 0 ] ) || get( product, [ 'image' ] );
const productImageClasses = classnames(
'woocommerce-review-activity-card__image-overlay__product',
{
'is-placeholder': ! productImage || ! productImage.src,
}
);
const icon = (
<div className="woocommerce-review-activity-card__image-overlay">
<div className={ productImageClasses }>
<ProductImage
product={ product }
width={ 33 }
height={ 33 }
/>
</div>
</div>
);
const manageReviewEvent = {
date: review.date_created_gmt,
status: review.status,
};
const cardActions = [
<Button
key="approve-action"
isSecondary
onClick={ () => {
this.recordReviewEvent( 'approve', manageReviewEvent );
this.updateReviewStatus(
review.id,
'approved',
review.status
);
} }
>
{ __( 'Approve', 'woocommerce-admin' ) }
</Button>,
<Button
key="spam-action"
isTertiary
onClick={ () => {
this.recordReviewEvent( 'mark_as_spam', manageReviewEvent );
this.updateReviewStatus( review.id, 'spam', review.status );
} }
>
{ __( 'Mark as spam', 'woocommerce-admin' ) }
</Button>,
<Button
key="delete-action"
isDestructive
isTertiary
onClick={ () => {
this.recordReviewEvent( 'delete', manageReviewEvent );
this.deleteReview( review.id );
} }
>
{ __( 'Delete', 'woocommerce-admin' ) }
</Button>,
];
return (
<ActivityCard
className="woocommerce-review-activity-card"
key={ review.id }
title={ title }
subtitle={ subtitle }
date={ review.date_created_gmt }
icon={ icon }
actions={ cardActions }
>
<span
dangerouslySetInnerHTML={ sanitizeHTML( review.review ) }
/>
</ActivityCard>
);
}
renderReviews( reviews ) {
if ( reviews.length === 0 ) {
return <></>;
}
return (
<>
{ reviews.map( ( review ) =>
this.renderReview( review, this.props )
) }
<Link
href={ getAdminLink(
'edit-comments.php?comment_type=review'
) }
onClick={ () => this.recordReviewEvent( 'reviews_manage' ) }
className="woocommerce-layout__activity-panel-outbound-link woocommerce-layout__activity-panel-empty"
type="wp-admin"
>
{ __( 'Manage all reviews', 'woocommerce-admin' ) }
</Link>
</>
);
}
render() {
const { isRequesting, isError, reviews } = this.props;
if ( isError ) {
const title = __(
'There was an error getting your reviews. Please try again.',
'woocommerce-admin'
);
const actionLabel = __( 'Reload', 'woocommerce-admin' );
const actionCallback = () => {
// @todo Add tracking for how often an error is displayed, and the reload action is clicked.
window.location.reload();
};
return (
<Fragment>
<EmptyContent
title={ title }
actionLabel={ actionLabel }
actionURL={ null }
actionCallback={ actionCallback }
/>
</Fragment>
);
}
return (
<Fragment>
<Section>
{ isRequesting && ! reviews.length ? (
<ActivityCardPlaceholder
className="woocommerce-review-activity-card"
hasAction
hasDate
lines={ 1 }
/>
) : (
<>{ this.renderReviews( reviews, this.props ) }</>
) }
</Section>
</Fragment>
);
}
}
ReviewsPanel.propTypes = {
reviews: PropTypes.array.isRequired,
isError: PropTypes.bool,
isRequesting: PropTypes.bool,
};
ReviewsPanel.defaultProps = {
reviews: [],
isError: false,
isRequesting: false,
};
ReviewsPanel.contextType = CurrencyContext;
export { ReviewsPanel };
export default compose( [
withSelect( ( select, props ) => {
const { hasUnapprovedReviews } = props;
const { getReviews, getReviewsError, isResolving } = select(
REVIEWS_STORE_NAME
);
let reviews = [];
let isError = false;
let isRequesting = false;
if ( hasUnapprovedReviews ) {
reviews = getReviews( reviewsQuery );
isError = Boolean( getReviewsError( reviewsQuery ) );
isRequesting = isResolving( 'getReviews', [ reviewsQuery ] );
}
return {
reviews,
isError,
isRequesting,
};
} ),
withDispatch( ( dispatch, props ) => {
const { deleteReview, updateReview, invalidateResolution } = dispatch(
REVIEWS_STORE_NAME
);
const { createNotice } = dispatch( 'core/notices' );
const clearReviewsCache = () => {
invalidateResolution( 'getReviews', [ reviewsQuery ] );
if ( props.reviews && props.reviews.length < 2 ) {
invalidateResolution( 'getReviewsTotalCount', [
unapprovedReviewsQuery,
] );
}
};
return {
deleteReview,
createNotice,
updateReview,
clearReviewsCache,
};
} ),
] )( ReviewsPanel );

View File

@ -0,0 +1,45 @@
.woocommerce-review-activity-card {
.woocommerce-rating {
margin-top: $gap-smallest;
.gridicon {
fill: $notice-yellow;
}
}
.woocommerce-activity-card__body > span > p {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.woocommerce-review-activity-card__verified {
margin-left: $gap-small;
display: inline-flex;
position: relative;
top: $gap-smallest;
color: $valid-green;
@include font-size( 12 );
.gridicon {
margin-right: $gap-smallest;
fill: $valid-green;
}
}
@include breakpoint( '<782px' ) {
.woocommerce-review-activity-card__image-overlay {
margin-top: $gap-smallest;
}
}
&.woocommerce-activity-card {
padding: $gap $gap-large;
}
.woocommerce-activity-card__header .woocommerce-activity-card__title {
line-height: 20px;
}
}

View File

@ -0,0 +1,152 @@
/**
* External dependencies
*/
import { render, screen, fireEvent } from '@testing-library/react';
/**
* Internal dependencies
*/
import { ReviewsPanel } from '../';
const REVIEW = {
id: 10,
date_created: '2020-11-20T18:24:41',
date_created_gmt: '2020-11-20T18:24:41',
product_id: 45,
status: 'hold',
reviewer: 'Reviewer',
reviewer_email: 'test@test.ca',
review: '<p>It is an average hat</p>\n',
rating: 3,
verified: false,
_embedded: {
up: [
{
id: 45,
name: 'Cap',
slug: 'cap',
permalink: 'https://one.wordpress.test/product/cap/',
description:
'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.',
short_description: 'This is a simple product.',
images: [
{
id: 74,
date_created: '2020-11-20T17:28:47',
date_created_gmt: '2020-11-20T17:28:47',
date_modified: '2020-11-20T17:28:47',
date_modified_gmt: '2020-11-20T17:28:47',
src:
'https://one.wordpress.test/wp-content/uploads/2020/11/cap-2-1.jpg',
name: 'cap-2-1.jpg',
alt: '',
},
],
},
],
},
};
jest.mock( '@woocommerce/components', () => ( {
...require.requireActual( '@woocommerce/components' ),
Link: ( { children } ) => {
return <>{ children }</>;
},
} ) );
describe( 'ReviewsPanel', () => {
it( 'should render an empty review card', () => {
render(
<ReviewsPanel
hasUnapprovedReviews={ false }
isError={ false }
isRequesting={ false }
reviews={ [] }
/>
);
expect( screen.queryByRole( 'section' ) ).toBeNull();
} );
it( 'should render a review card with title <name> reviewed <product name>', () => {
render(
<ReviewsPanel
hasUnapprovedReviews={ true }
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
/>
);
expect( screen.getByText( 'Reviewer reviewed Cap' ) ).not.toBeNull();
} );
describe( 'review actions', () => {
it( 'should render a review card with approve, mark as spam, and delete buttons', () => {
render(
<ReviewsPanel
hasUnapprovedReviews={ true }
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
/>
);
expect( screen.queryByText( 'Approve' ) ).toBeInTheDocument();
expect( screen.queryByText( 'Mark as spam' ) ).toBeInTheDocument();
expect( screen.queryByText( 'Delete' ) ).toBeInTheDocument();
} );
it( 'should trigger updateReview with status approved when Approve is clicked', () => {
const clickHandler = jest.fn( () => {
return Promise.resolve();
} );
render(
<ReviewsPanel
hasUnapprovedReviews={ true }
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
updateReview={ clickHandler }
/>
);
fireEvent.click( screen.getByText( 'Approve' ) );
expect( clickHandler ).toHaveBeenCalledWith( REVIEW.id, {
status: 'approved',
} );
} );
it( 'should trigger updateReview with status spam when Mark as spam is clicked', () => {
const clickHandler = jest.fn( () => {
return Promise.resolve();
} );
render(
<ReviewsPanel
hasUnapprovedReviews={ true }
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
updateReview={ clickHandler }
/>
);
fireEvent.click( screen.getByText( 'Mark as spam' ) );
expect( clickHandler ).toHaveBeenCalledWith( REVIEW.id, {
status: 'spam',
} );
} );
it( 'should trigger deleteReview with review id when delete is clicked', () => {
const clickHandler = jest.fn( () => {
return Promise.resolve();
} );
render(
<ReviewsPanel
hasUnapprovedReviews={ true }
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
deleteReview={ clickHandler }
/>
);
fireEvent.click( screen.getByText( 'Delete' ) );
expect( clickHandler ).toHaveBeenCalledWith( REVIEW.id );
} );
} );
} );

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { REVIEWS_STORE_NAME } from '@woocommerce/data';
export const REVIEW_PAGE_LIMIT = 5;
export const unapprovedReviewsQuery = {
page: 1,
per_page: 1,
status: 'hold',
_embed: 1,
_fields: [ 'id' ],
};
export function getUnapprovedReviews( select ) {
const { getReviewsTotalCount, getReviewsError, isResolving } = select(
REVIEWS_STORE_NAME
);
// eslint-disable-next-line @wordpress/no-unused-vars-before-return
const totalReviews = getReviewsTotalCount( unapprovedReviewsQuery );
const isError = Boolean( getReviewsError( unapprovedReviewsQuery ) );
const isRequesting = isResolving( 'getReviewsTotalCount', [
unapprovedReviewsQuery,
] );
if ( isError || ( isRequesting && totalReviews === undefined ) ) {
return null;
}
return totalReviews;
}

View File

@ -1,30 +1,47 @@
@mixin accordion-header {
.woocommerce-accordion-header {
order: 1;
text-align: left;
.woocommerce-accordion-title {
margin-right: $gap;
}
}
}
.woocommerce-accordion-card { .woocommerce-accordion-card {
.components-card__header,
.components-panel__body-title .components-button {
font-size: 20px;
line-height: 28px;
color: $gray-900;
&:focus {
box-shadow: unset;
outline: unset;
}
}
.components-card__header {
height: 60px;
@include accordion-header();
}
.components-panel__body-title { .components-panel__body-title {
margin: 0; margin: 0;
padding: 1em 0; padding: $gap-small 0;
height: 60px;
.components-button { .components-button {
display: flex;
flex-direction: row;
width: 100%; width: 100%;
padding-left: 24px; padding-left: 24px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-size: 20px; display: flex;
line-height: 28px; flex-direction: row;
color: $gray-900;
&:focus { @include accordion-header();
box-shadow: unset;
outline: unset;
}
.woocommerce-accordion-header { .woocommerce-accordion-header {
order: 1;
text-align: left;
width: 50%; width: 50%;
.woocommerce-accordion-title {
vertical-align: bottom;
margin-right: $gap;
}
} }
& > span { & > span {
order: 2; order: 2;
width: 50%; width: 50%;

View File

@ -1,10 +1,10 @@
/** /**
* External dependencies * External dependencies
*/ */
import { Card, PanelBody, PanelRow } from '@wordpress/components'; import { Card, CardHeader, PanelBody, PanelRow } from '@wordpress/components';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { useState } from '@wordpress/element'; import { useState, useEffect } from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
@ -20,6 +20,7 @@ import { Badge } from '../badge';
* @param {string} props.children * @param {string} props.children
* @param {string} props.title * @param {string} props.title
* @param {string} props.initialOpen * @param {string} props.initialOpen
* @param {boolean} props.collapsible
* @return {Object} - * @return {Object} -
*/ */
const AccordionPanel = ( { const AccordionPanel = ( {
@ -27,9 +28,17 @@ const AccordionPanel = ( {
count, count,
title, title,
initialOpen, initialOpen,
collapsible,
children, children,
} ) => { } ) => {
const [ isPanelOpen, setIsPanelOpen ] = useState( null ); const [ isPanelOpen, setIsPanelOpen ] = useState( null );
useEffect( () => {
if ( ! collapsible && isPanelOpen ) {
setIsPanelOpen( ! isPanelOpen );
}
}, [ collapsible ] );
const getTitleAndCount = ( titleText, countUnread ) => { const getTitleAndCount = ( titleText, countUnread ) => {
return ( return (
<span className="woocommerce-accordion-header"> <span className="woocommerce-accordion-header">
@ -54,13 +63,19 @@ const AccordionPanel = ( {
'is-panel-opened': opened, 'is-panel-opened': opened,
} ) } } ) }
> >
<PanelBody { collapsible ? (
title={ getTitleAndCount( title, count ) } <PanelBody
opened={ opened } title={ getTitleAndCount( title, count ) }
onToggle={ onToggle } initialOpen={ opened }
> onToggle={ onToggle }
<PanelRow> { children } </PanelRow> >
</PanelBody> <PanelRow> { children } </PanelRow>
</PanelBody>
) : (
<CardHeader size="medium">
{ getTitleAndCount( title, count ) }
</CardHeader>
) }
</Card> </Card>
); );
}; };
@ -82,10 +97,15 @@ AccordionPanel.propTypes = {
* Whether or not the panel will start open. * Whether or not the panel will start open.
*/ */
initialOpen: PropTypes.bool, initialOpen: PropTypes.bool,
/**
* Whether or not the panel can be collapsed or not.
*/
collapsible: PropTypes.bool,
}; };
AccordionPanel.defaultProps = { AccordionPanel.defaultProps = {
initialOpen: true, initialOpen: true,
collapsible: true,
}; };
export default AccordionPanel; export default AccordionPanel;

View File

@ -50,4 +50,22 @@ describe( 'Accordion', () => {
expect( screen.queryByText( '10000' ) ).toBeInTheDocument(); expect( screen.queryByText( '10000' ) ).toBeInTheDocument();
expect( screen.queryByText( '20000' ) ).toBeInTheDocument(); expect( screen.queryByText( '20000' ) ).toBeInTheDocument();
} ); } );
it( 'should only render title if collapsible is false', () => {
render(
<Accordion>
{ ' ' }
<AccordionPanel
title="empty title"
initialOpen={ false }
collapsible={ false }
>
<span>Custom panel 1</span>
</AccordionPanel>
</Accordion>
);
expect( screen.queryByText( 'empty title' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'button' ) ).toBeNull();
expect( screen.queryByText( 'Custom panel 1' ) ).toBeNull();
} );
} ); } );

View File

@ -12,7 +12,7 @@ import PropTypes from 'prop-types';
* rating in a scale between 0 and the prop `totalStars` (default 5). * rating in a scale between 0 and the prop `totalStars` (default 5).
*/ */
class Rating extends Component { class Rating extends Component {
stars() { stars( icon ) {
const { size, totalStars } = this.props; const { size, totalStars } = this.props;
const starStyles = { const starStyles = {
@ -22,13 +22,14 @@ class Rating extends Component {
const stars = []; const stars = [];
for ( let i = 0; i < totalStars; i++ ) { for ( let i = 0; i < totalStars; i++ ) {
stars.push( <StarIcon key={ 'star-' + i } style={ starStyles } /> ); const Icon = icon || StarIcon;
stars.push( <Icon key={ 'star-' + i } style={ starStyles } /> );
} }
return stars; return stars;
} }
render() { render() {
const { rating, totalStars, className } = this.props; const { rating, totalStars, className, icon, outlineIcon } = this.props;
const classes = classnames( 'woocommerce-rating', className ); const classes = classnames( 'woocommerce-rating', className );
const perStar = 100 / totalStars; const perStar = 100 / totalStars;
@ -43,12 +44,12 @@ class Rating extends Component {
); );
return ( return (
<div className={ classes } aria-label={ label }> <div className={ classes } aria-label={ label }>
{ this.stars() } { this.stars( icon ) }
<div <div
className="woocommerce-rating__star-outline" className="woocommerce-rating__star-outline"
style={ outlineStyles } style={ outlineStyles }
> >
{ this.stars() } { this.stars( outlineIcon || icon ) }
</div> </div>
</div> </div>
); );
@ -72,6 +73,14 @@ Rating.propTypes = {
* Additional CSS classes. * Additional CSS classes.
*/ */
className: PropTypes.string, className: PropTypes.string,
/**
* Icon used, defaults to StarIcon
*/
icon: PropTypes.elementType,
/**
* Outline icon used, the not selected rating. Defaults to props.icon or StarIcon
*/
outlineIcon: PropTypes.elementType,
}; };
Rating.defaultProps = { Rating.defaultProps = {

View File

@ -155,6 +155,161 @@ exports[`ProductRating should render rating based on product object 1`] = `
</div> </div>
`; `;
exports[`Rating should render different icons if specified 1`] = `
<div>
<div
aria-label="2 out of 5 stars."
class="woocommerce-rating"
>
<svg
class="gridicon gridicons-star-outline"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 6.308l1.176 3.167.347.936.997.042 3.374.14-2.647 2.09-.784.62.27.963.91 3.25-2.813-1.872-.83-.553-.83.552-2.814 1.87.91-3.248.27-.962-.783-.62-2.648-2.092 3.374-.14.996-.04.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.89 18.18 21l-2.002-7.14L22 9.256l-7.418-.305L12 2z"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star-outline"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 6.308l1.176 3.167.347.936.997.042 3.374.14-2.647 2.09-.784.62.27.963.91 3.25-2.813-1.872-.83-.553-.83.552-2.814 1.87.91-3.248.27-.962-.783-.62-2.648-2.092 3.374-.14.996-.04.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.89 18.18 21l-2.002-7.14L22 9.256l-7.418-.305L12 2z"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star-outline"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 6.308l1.176 3.167.347.936.997.042 3.374.14-2.647 2.09-.784.62.27.963.91 3.25-2.813-1.872-.83-.553-.83.552-2.814 1.87.91-3.248.27-.962-.783-.62-2.648-2.092 3.374-.14.996-.04.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.89 18.18 21l-2.002-7.14L22 9.256l-7.418-.305L12 2z"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star-outline"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 6.308l1.176 3.167.347.936.997.042 3.374.14-2.647 2.09-.784.62.27.963.91 3.25-2.813-1.872-.83-.553-.83.552-2.814 1.87.91-3.248.27-.962-.783-.62-2.648-2.092 3.374-.14.996-.04.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.89 18.18 21l-2.002-7.14L22 9.256l-7.418-.305L12 2z"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star-outline"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 6.308l1.176 3.167.347.936.997.042 3.374.14-2.647 2.09-.784.62.27.963.91 3.25-2.813-1.872-.83-.553-.83.552-2.814 1.87.91-3.248.27-.962-.783-.62-2.648-2.092 3.374-.14.996-.04.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.89 18.18 21l-2.002-7.14L22 9.256l-7.418-.305L12 2z"
/>
</g>
</svg>
<div
class="woocommerce-rating__star-outline"
style="width: 40%;"
>
<svg
class="gridicon gridicons-star"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.89 5.82 21l2.002-7.14L2 9.256l7.418-.304"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.89 5.82 21l2.002-7.14L2 9.256l7.418-.304"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.89 5.82 21l2.002-7.14L2 9.256l7.418-.304"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.89 5.82 21l2.002-7.14L2 9.256l7.418-.304"
/>
</g>
</svg>
<svg
class="gridicon gridicons-star"
height="24"
style="width: 18px; height: 18px;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.89 5.82 21l2.002-7.14L2 9.256l7.418-.304"
/>
</g>
</svg>
</div>
</div>
</div>
`;
exports[`Rating should render stars at a different size 1`] = ` exports[`Rating should render stars at a different size 1`] = `
<div> <div>
<div <div

View File

@ -2,6 +2,8 @@
* External dependencies * External dependencies
*/ */
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import StarIcon from 'gridicons/dist/star';
import StarOutlineIcon from 'gridicons/dist/star-outline';
/** /**
* Internal dependencies * Internal dependencies
@ -27,6 +29,17 @@ describe( 'Rating', () => {
const { container } = render( <Rating rating={ 1 } size={ 36 } /> ); const { container } = render( <Rating rating={ 1 } size={ 36 } /> );
expect( container ).toMatchSnapshot(); expect( container ).toMatchSnapshot();
} ); } );
test( 'should render different icons if specified', () => {
const { container } = render(
<Rating
rating={ 2 }
icon={ StarOutlineIcon }
outlineIcon={ StarIcon }
/>
);
expect( container ).toMatchSnapshot();
} );
} ); } );
describe( 'ReviewRating', () => { describe( 'ReviewRating', () => {

View File

@ -1,6 +1,8 @@
const TYPES = { const TYPES = {
UPDATE_REVIEWS: 'UPDATE_REVIEWS', UPDATE_REVIEWS: 'UPDATE_REVIEWS',
SET_REVIEW: 'SET_REVIEW',
SET_ERROR: 'SET_ERROR', SET_ERROR: 'SET_ERROR',
SET_REVIEW_IS_UPDATING: 'SET_REVIEW_IS_UPDATING',
}; };
export default TYPES; export default TYPES;

View File

@ -1,7 +1,14 @@
/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
import { addQueryArgs } from '@wordpress/url';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import TYPES from './action-types'; import TYPES from './action-types';
import { NAMESPACE } from '../constants';
export function updateReviews( query, reviews, totalCount ) { export function updateReviews( query, reviews, totalCount ) {
return { return {
@ -12,6 +19,60 @@ export function updateReviews( query, reviews, totalCount ) {
}; };
} }
export function* updateReview( reviewId, reviewFields, query ) {
yield setReviewIsUpdating( reviewId, true );
try {
const url = addQueryArgs(
`${ NAMESPACE }/products/reviews/${ reviewId }`,
query || {}
);
const review = yield apiFetch( {
path: url,
method: 'PUT',
data: reviewFields,
} );
yield setReview( reviewId, review );
yield setReviewIsUpdating( reviewId, false );
} catch ( error ) {
yield setError( 'updateReview', error );
yield setReviewIsUpdating( reviewId, false );
throw new Error();
}
}
export function* deleteReview( reviewId ) {
yield setReviewIsUpdating( reviewId, true );
try {
const url = `${ NAMESPACE }/products/reviews/${ reviewId }`;
const response = yield apiFetch( { path: url, method: 'DELETE' } );
yield setReview( reviewId, response );
yield setReviewIsUpdating( reviewId, false );
return response;
} catch ( error ) {
yield setError( 'deleteReview', error );
yield setReviewIsUpdating( reviewId, false );
throw new Error();
}
}
export function setReviewIsUpdating( reviewId, isUpdating ) {
return {
type: TYPES.SET_REVIEW_IS_UPDATING,
reviewId,
isUpdating,
};
}
export function setReview( reviewId, reviewData ) {
return {
type: TYPES.SET_REVIEW,
reviewId,
reviewData,
};
}
export function setError( query, error ) { export function setError( query, error ) {
return { return {
type: TYPES.SET_ERROR, type: TYPES.SET_ERROR,

View File

@ -9,7 +9,16 @@ const reducer = (
errors: {}, errors: {},
data: {}, data: {},
}, },
{ type, query, reviews, totalCount, error } {
type,
query,
reviews,
reviewId,
reviewData,
totalCount,
error,
isUpdating,
}
) => { ) => {
switch ( type ) { switch ( type ) {
case TYPES.UPDATE_REVIEWS: case TYPES.UPDATE_REVIEWS:
@ -30,6 +39,14 @@ const reducer = (
...nextReviews, ...nextReviews,
}, },
}; };
case TYPES.SET_REVIEW:
return {
...state,
data: {
...state.data,
[ reviewId ]: reviewData,
},
};
case TYPES.SET_ERROR: case TYPES.SET_ERROR:
return { return {
...state, ...state,
@ -38,6 +55,17 @@ const reducer = (
[ JSON.stringify( query ) ]: error, [ JSON.stringify( query ) ]: error,
}, },
}; };
case TYPES.SET_REVIEW_IS_UPDATING:
return {
...state,
data: {
...state.data,
[ reviewId ]: {
...state.data[ reviewId ],
isUpdating,
},
},
};
default: default:
return state; return state;
} }

View File

@ -10,9 +10,8 @@ export const getReviews = ( state, query ) => {
export const getReviewsTotalCount = ( state, query ) => { export const getReviewsTotalCount = ( state, query ) => {
const stringifiedQuery = JSON.stringify( query ); const stringifiedQuery = JSON.stringify( query );
return ( return (
( state.reviews[ stringifiedQuery ] && state.reviews[ stringifiedQuery ] &&
state.reviews[ stringifiedQuery ].totalCount ) || state.reviews[ stringifiedQuery ].totalCount
0
); );
}; };

View File

@ -62,4 +62,50 @@ describe( 'reviews reducer', () => {
const stringifiedQuery = JSON.stringify( query ); const stringifiedQuery = JSON.stringify( query );
expect( state.errors[ stringifiedQuery ] ).toBe( error ); expect( state.errors[ stringifiedQuery ] ).toBe( error );
} ); } );
it( 'should handle SET_REVIEW', () => {
const state = reducer(
{
...defaultState,
data: {
4: { title: 'test' },
},
},
{
type: TYPES.SET_REVIEW,
reviewId: 4,
reviewData: {
title: 'test updated',
},
}
);
expect( state.data[ 4 ].title ).toEqual( 'test updated' );
} );
it( 'should handle SET_REVIEW_IS_UPDATING', () => {
const state = reducer(
{
...defaultState,
data: {
4: { title: 'test' },
},
},
{
type: TYPES.SET_REVIEW_IS_UPDATING,
reviewId: 4,
isUpdating: true,
}
);
expect( state.data[ 4 ].isUpdating ).toEqual( true );
const newstate = reducer( state, {
type: TYPES.SET_REVIEW_IS_UPDATING,
reviewId: 4,
isUpdating: false,
} );
expect( newstate.data[ 4 ].isUpdating ).toEqual( false );
} );
} ); } );