diff --git a/plugins/woocommerce-admin/client/header/activity-panel/README.md b/plugins/woocommerce-admin/client/header/activity-panel/README.md index 51516a7faea..20506819ab3 100644 --- a/plugins/woocommerce-admin/client/header/activity-panel/README.md +++ b/plugins/woocommerce-admin/client/header/activity-panel/README.md @@ -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. -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 diff --git a/plugins/woocommerce-admin/client/header/activity-panel/activity-card/style.scss b/plugins/woocommerce-admin/client/header/activity-panel/activity-card/style.scss index a0b59e4fb2a..31208815b57 100644 --- a/plugins/woocommerce-admin/client/header/activity-panel/activity-card/style.scss +++ b/plugins/woocommerce-admin/client/header/activity-panel/activity-card/style.scss @@ -142,6 +142,11 @@ height: 24px; padding: 4px 10px; @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 { height: 33px; position: relative; diff --git a/plugins/woocommerce-admin/client/header/activity-panel/index.js b/plugins/woocommerce-admin/client/header/activity-panel/index.js index 523f284d48e..890b8036327 100644 --- a/plugins/woocommerce-admin/client/header/activity-panel/index.js +++ b/plugins/woocommerce-admin/client/header/activity-panel/index.js @@ -11,7 +11,7 @@ import { uniqueId, find } from 'lodash'; import CrossIcon from 'gridicons/dist/cross-small'; import classnames from 'classnames'; 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 { OPTIONS_STORE_NAME } from '@woocommerce/data'; import { getHistory, getNewPath } from '@woocommerce/navigation'; @@ -21,7 +21,7 @@ import { getHistory, getNewPath } from '@woocommerce/navigation'; */ import './style.scss'; import ActivityPanelToggleBubble from './toggle-bubble'; -import { getUnreadNotes, getUnapprovedReviews } from './unread-indicators'; +import { getUnreadNotes } from './unread-indicators'; import { isWCAdmin } from '../../dashboard/utils'; import { Tabs } from './tabs'; 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 { constructor( props ) { super( props ); @@ -132,7 +127,6 @@ export class ActivityPanel extends Component { getTabs() { const { hasUnreadNotes, - hasUnapprovedReviews, isEmbedded, taskListComplete, taskListHidden, @@ -150,9 +144,6 @@ export class ActivityPanel extends Component { const showDisplayOptions = ! isEmbedded && this.isHomescreen() && ! isPerformingSetupTask; - const showReviews = - ( taskListComplete || taskListHidden ) && ! isPerformingSetupTask; - const showStoreSetup = ! taskListComplete && ! taskListHidden && ! isPerformingSetupTask; @@ -173,20 +164,6 @@ export class ActivityPanel extends Component { } : null; - const reviews = - showReviews && reviewsEnabled === 'yes' - ? { - name: 'reviews', - title: __( 'Reviews', 'woocommerce-admin' ), - icon: ( - - star_border - - ), - unread: hasUnapprovedReviews, - } - : null; - const help = showHelp ? { name: 'help', @@ -201,24 +178,16 @@ export class ActivityPanel extends Component { } : null; - return [ inbox, reviews, setup, displayOptions, help ].filter( - Boolean - ); + return [ inbox, setup, displayOptions, help ].filter( Boolean ); } getPanelContent( tab ) { - const { query, hasUnapprovedReviews } = this.props; + const { query } = this.props; const { task } = query; switch ( tab ) { case 'inbox': return ; - case 'reviews': - return ( - - ); case 'help': return ; default: @@ -378,7 +347,6 @@ ActivityPanel.defaultProps = { export default compose( withSelect( ( select ) => { const hasUnreadNotes = getUnreadNotes( select ); - const hasUnapprovedReviews = getUnapprovedReviews( select ); const { getOption, isResolving } = select( OPTIONS_STORE_NAME ); const taskListComplete = @@ -391,7 +359,6 @@ export default compose( return { hasUnreadNotes, - hasUnapprovedReviews, requestingTaskListOptions, taskListComplete, taskListHidden, diff --git a/plugins/woocommerce-admin/client/header/activity-panel/panels/reviews.js b/plugins/woocommerce-admin/client/header/activity-panel/panels/reviews.js deleted file mode 100644 index 9dfeb23d686..00000000000 --- a/plugins/woocommerce-admin/client/header/activity-panel/panels/reviews.js +++ /dev/null @@ -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: ( - this.recordReviewEvent( 'product' ) } - type="external" - /> - ), - authorLink: ( - this.recordReviewEvent( 'customer' ) } - type="external" - /> - ), - }, - } ); - - const subtitle = ( - - - { review.verified && ( - - - { __( 'Verified customer', 'woocommerce-admin' ) } - - ) } - - ); - - 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 = ( -
- -
- -
-
- ); - - const manageReviewEvent = { - date: review.date_created_gmt, - status: review.status, - }; - - const cardActions = ( - - ); - - return ( - - lastRead - } - > - - - ); - } - - 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 = ( - -

- { __( - "We noticed that it's been a while since your products had any reviews.", - 'woocommerce-admin' - ) } -

-

- { __( - 'Take some time to learn about best practices for collecting and using your reviews.', - 'woocommerce-admin' - ) } -

-
- ); - } else { - buttonUrl = getAdminLink( - 'edit-comments.php?comment_type=review' - ); - buttonText = __( 'View all Reviews', 'woocommerce-admin' ); - content = ( -

- { __( - /* 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 */ - ) } -

- ); - eventName = 'view_reviews'; - } - } else { - buttonUrl = - 'https://woocommerce.com/posts/reviews-woocommerce-best-practices/'; - buttonTarget = '_blank'; - buttonText = __( 'Learn more', 'woocommerce-admin' ); - content = ( - -

- { __( - "Your customers haven't started reviewing your products.", - 'woocommerce-admin' - ) } -

-

- { __( - 'Take some time to learn about best practices for collecting and using your reviews.', - 'woocommerce-admin' - ) } -

-
- ); - } - - return ( - } - actions={ - - } - > - { content } - - ); - } - - 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 ( - - - - ); - } - - const title = - isRequesting || reviews.length - ? __( 'Reviews', 'woocommerce-admin' ) - : __( 'No reviews to moderate', 'woocommerce-admin' ); - - return ( - - -
- { isRequesting ? ( - - ) : ( - - { reviews.length - ? reviews.map( ( review ) => - this.renderReview( review, this.props ) - ) - : this.renderEmptyMessage() } - - ) } -
-
- ); - } -} - -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 ); diff --git a/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js b/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js index 4d2e5235f5e..067c5187be2 100644 --- a/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js +++ b/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js @@ -3,7 +3,6 @@ */ import { NOTES_STORE_NAME, - REVIEWS_STORE_NAME, USER_STORE_NAME, QUERY_DEFAULTS, } from '@woocommerce/data'; @@ -57,40 +56,6 @@ export function getUnreadNotes( select ) { 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() { return getSetting( 'lowStockCount', 0 ); } diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/index.js b/plugins/woocommerce-admin/client/homescreen/activity-panel/index.js index c191467a72f..28e6b4c4f20 100644 --- a/plugins/woocommerce-admin/client/homescreen/activity-panel/index.js +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/index.js @@ -16,14 +16,17 @@ import { getUnreadOrders, } from './orders/utils'; import { getAllPanels } from './panels'; +import { getUnapprovedReviews } from './reviews/utils'; export const ActivityPanel = () => { const panelsData = useSelect( ( select ) => { const totalOrderCount = getSetting( 'orderCount', 0 ); const orderStatuses = getOrderStatuses( select ); + const reviewsEnabled = getSetting( 'reviewsEnabled', 'no' ); const countUnreadOrders = getUnreadOrders( select, orderStatuses ); const manageStock = getSetting( 'manageStock', 'no' ); const countLowStockProducts = getLowStockCount( select ); + const countUnapprovedReviews = getUnapprovedReviews( select ); return { countLowStockProducts, @@ -31,6 +34,8 @@ export const ActivityPanel = () => { manageStock, orderStatuses, totalOrderCount, + reviewsEnabled, + countUnapprovedReviews, }; } ); @@ -55,6 +60,7 @@ export const ActivityPanel = () => { count={ count } initialOpen={ initialOpen } title={ title } + collapsible={ count !== 0 } > { panel } diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/panels.js b/plugins/woocommerce-admin/client/homescreen/activity-panel/panels.js index 15727c8f620..038967ec476 100644 --- a/plugins/woocommerce-admin/client/homescreen/activity-panel/panels.js +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/panels.js @@ -8,6 +8,7 @@ import { __ } from '@wordpress/i18n'; */ import OrdersPanel from './orders'; import StockPanel from './stock'; +import ReviewsPanel from './reviews'; export function getAllPanels( { countLowStockProducts, @@ -15,6 +16,8 @@ export function getAllPanels( { manageStock, orderStatuses, totalOrderCount, + reviewsEnabled, + countUnapprovedReviews, } ) { return [ totalOrderCount > 0 && { @@ -40,6 +43,18 @@ export function getAllPanels( { ), title: __( 'Stock', 'woocommerce-admin' ), }, + reviewsEnabled === 'yes' && { + className: 'woocommerce-homescreen-card', + id: 'reviews-panel', + count: countUnapprovedReviews, + initialOpen: false, + panel: ( + 0 } + /> + ), + title: __( 'Reviews', 'woocommerce-admin' ), + }, // Add another panel row here ].filter( Boolean ); } diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/index.js b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/index.js new file mode 100644 index 00000000000..7e8104a0613 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/index.js @@ -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 ( + + ); + } + 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: ( + this.recordReviewEvent( 'product' ) } + type="external" + /> + ), + authorLink: ( + this.recordReviewEvent( 'customer' ) } + type="external" + /> + ), + }, + } ); + + const subtitle = ( + + + { review.verified && ( + + + { __( 'Verified customer', 'woocommerce-admin' ) } + + ) } + + ); + + 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 = ( +
+
+ +
+
+ ); + + const manageReviewEvent = { + date: review.date_created_gmt, + status: review.status, + }; + + const cardActions = [ + , + , + , + ]; + + return ( + + + + ); + } + + renderReviews( reviews ) { + if ( reviews.length === 0 ) { + return <>; + } + return ( + <> + { reviews.map( ( review ) => + this.renderReview( review, this.props ) + ) } + this.recordReviewEvent( 'reviews_manage' ) } + className="woocommerce-layout__activity-panel-outbound-link woocommerce-layout__activity-panel-empty" + type="wp-admin" + > + { __( 'Manage all reviews', 'woocommerce-admin' ) } + + + ); + } + + 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 ( + + + + ); + } + + return ( + +
+ { isRequesting && ! reviews.length ? ( + + ) : ( + <>{ this.renderReviews( reviews, this.props ) } + ) } +
+
+ ); + } +} + +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 ); diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/style.scss b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/style.scss new file mode 100644 index 00000000000..dad6e9e4655 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/style.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js new file mode 100644 index 00000000000..2f1ce24d31f --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js @@ -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: '

It is an average hat

\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( + + ); + expect( screen.queryByRole( 'section' ) ).toBeNull(); + } ); + + it( 'should render a review card with title reviewed ', () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + fireEvent.click( screen.getByText( 'Delete' ) ); + expect( clickHandler ).toHaveBeenCalledWith( REVIEW.id ); + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/utils.js b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/utils.js new file mode 100644 index 00000000000..1dda58d9c98 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/utils.js @@ -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; +} diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/style.scss b/plugins/woocommerce-admin/client/homescreen/activity-panel/style.scss index 967f6ce556e..1638dee1a2f 100644 --- a/plugins/woocommerce-admin/client/homescreen/activity-panel/style.scss +++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/style.scss @@ -1,30 +1,47 @@ +@mixin accordion-header { + .woocommerce-accordion-header { + order: 1; + text-align: left; + .woocommerce-accordion-title { + margin-right: $gap; + } + } +} .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 { margin: 0; - padding: 1em 0; + padding: $gap-small 0; + height: 60px; .components-button { - display: flex; - flex-direction: row; width: 100%; padding-left: 24px; font-style: normal; font-weight: 400; - font-size: 20px; - line-height: 28px; - color: $gray-900; - &:focus { - box-shadow: unset; - outline: unset; - } + display: flex; + flex-direction: row; + + @include accordion-header(); + .woocommerce-accordion-header { - order: 1; - text-align: left; width: 50%; - .woocommerce-accordion-title { - vertical-align: bottom; - margin-right: $gap; - } } + & > span { order: 2; width: 50%; diff --git a/plugins/woocommerce-admin/packages/components/src/accordion/panel.js b/plugins/woocommerce-admin/packages/components/src/accordion/panel.js index 6cd65d8ec7a..6308382e82e 100644 --- a/plugins/woocommerce-admin/packages/components/src/accordion/panel.js +++ b/plugins/woocommerce-admin/packages/components/src/accordion/panel.js @@ -1,10 +1,10 @@ /** * External dependencies */ -import { Card, PanelBody, PanelRow } from '@wordpress/components'; +import { Card, CardHeader, PanelBody, PanelRow } from '@wordpress/components'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { useState } from '@wordpress/element'; +import { useState, useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -20,6 +20,7 @@ import { Badge } from '../badge'; * @param {string} props.children * @param {string} props.title * @param {string} props.initialOpen + * @param {boolean} props.collapsible * @return {Object} - */ const AccordionPanel = ( { @@ -27,9 +28,17 @@ const AccordionPanel = ( { count, title, initialOpen, + collapsible, children, } ) => { const [ isPanelOpen, setIsPanelOpen ] = useState( null ); + + useEffect( () => { + if ( ! collapsible && isPanelOpen ) { + setIsPanelOpen( ! isPanelOpen ); + } + }, [ collapsible ] ); + const getTitleAndCount = ( titleText, countUnread ) => { return ( @@ -54,13 +63,19 @@ const AccordionPanel = ( { 'is-panel-opened': opened, } ) } > - - { children } - + { collapsible ? ( + + { children } + + ) : ( + + { getTitleAndCount( title, count ) } + + ) } ); }; @@ -82,10 +97,15 @@ AccordionPanel.propTypes = { * Whether or not the panel will start open. */ initialOpen: PropTypes.bool, + /** + * Whether or not the panel can be collapsed or not. + */ + collapsible: PropTypes.bool, }; AccordionPanel.defaultProps = { initialOpen: true, + collapsible: true, }; export default AccordionPanel; diff --git a/plugins/woocommerce-admin/packages/components/src/accordion/test/index.js b/plugins/woocommerce-admin/packages/components/src/accordion/test/index.js index 81e577cea4c..8a5900a453f 100644 --- a/plugins/woocommerce-admin/packages/components/src/accordion/test/index.js +++ b/plugins/woocommerce-admin/packages/components/src/accordion/test/index.js @@ -50,4 +50,22 @@ describe( 'Accordion', () => { expect( screen.queryByText( '10000' ) ).toBeInTheDocument(); expect( screen.queryByText( '20000' ) ).toBeInTheDocument(); } ); + + it( 'should only render title if collapsible is false', () => { + render( + + { ' ' } + + Custom panel 1 + + + ); + expect( screen.queryByText( 'empty title' ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'button' ) ).toBeNull(); + expect( screen.queryByText( 'Custom panel 1' ) ).toBeNull(); + } ); } ); diff --git a/plugins/woocommerce-admin/packages/components/src/rating/index.js b/plugins/woocommerce-admin/packages/components/src/rating/index.js index 7914114c463..cf28c19937b 100644 --- a/plugins/woocommerce-admin/packages/components/src/rating/index.js +++ b/plugins/woocommerce-admin/packages/components/src/rating/index.js @@ -12,7 +12,7 @@ import PropTypes from 'prop-types'; * rating in a scale between 0 and the prop `totalStars` (default 5). */ class Rating extends Component { - stars() { + stars( icon ) { const { size, totalStars } = this.props; const starStyles = { @@ -22,13 +22,14 @@ class Rating extends Component { const stars = []; for ( let i = 0; i < totalStars; i++ ) { - stars.push( ); + const Icon = icon || StarIcon; + stars.push( ); } return stars; } render() { - const { rating, totalStars, className } = this.props; + const { rating, totalStars, className, icon, outlineIcon } = this.props; const classes = classnames( 'woocommerce-rating', className ); const perStar = 100 / totalStars; @@ -43,12 +44,12 @@ class Rating extends Component { ); return (
- { this.stars() } + { this.stars( icon ) }
- { this.stars() } + { this.stars( outlineIcon || icon ) }
); @@ -72,6 +73,14 @@ Rating.propTypes = { * Additional CSS classes. */ 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 = { diff --git a/plugins/woocommerce-admin/packages/components/src/rating/test/__snapshots__/index.js.snap b/plugins/woocommerce-admin/packages/components/src/rating/test/__snapshots__/index.js.snap index 84cfc05b51d..78982911ed1 100644 --- a/plugins/woocommerce-admin/packages/components/src/rating/test/__snapshots__/index.js.snap +++ b/plugins/woocommerce-admin/packages/components/src/rating/test/__snapshots__/index.js.snap @@ -155,6 +155,161 @@ exports[`ProductRating should render rating based on product object 1`] = ` `; +exports[`Rating should render different icons if specified 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+`; + exports[`Rating should render stars at a different size 1`] = `
{ const { container } = render( ); expect( container ).toMatchSnapshot(); } ); + + test( 'should render different icons if specified', () => { + const { container } = render( + + ); + expect( container ).toMatchSnapshot(); + } ); } ); describe( 'ReviewRating', () => { diff --git a/plugins/woocommerce-admin/packages/data/src/reviews/action-types.js b/plugins/woocommerce-admin/packages/data/src/reviews/action-types.js index 0f17553e1a2..7e2269fe981 100644 --- a/plugins/woocommerce-admin/packages/data/src/reviews/action-types.js +++ b/plugins/woocommerce-admin/packages/data/src/reviews/action-types.js @@ -1,6 +1,8 @@ const TYPES = { UPDATE_REVIEWS: 'UPDATE_REVIEWS', + SET_REVIEW: 'SET_REVIEW', SET_ERROR: 'SET_ERROR', + SET_REVIEW_IS_UPDATING: 'SET_REVIEW_IS_UPDATING', }; export default TYPES; diff --git a/plugins/woocommerce-admin/packages/data/src/reviews/actions.js b/plugins/woocommerce-admin/packages/data/src/reviews/actions.js index fdfd1094ddd..ef17604e531 100644 --- a/plugins/woocommerce-admin/packages/data/src/reviews/actions.js +++ b/plugins/woocommerce-admin/packages/data/src/reviews/actions.js @@ -1,7 +1,14 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { addQueryArgs } from '@wordpress/url'; + /** * Internal dependencies */ import TYPES from './action-types'; +import { NAMESPACE } from '../constants'; export function updateReviews( query, reviews, totalCount ) { 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 ) { return { type: TYPES.SET_ERROR, diff --git a/plugins/woocommerce-admin/packages/data/src/reviews/reducer.js b/plugins/woocommerce-admin/packages/data/src/reviews/reducer.js index a6b37cab0f3..a3620c96721 100644 --- a/plugins/woocommerce-admin/packages/data/src/reviews/reducer.js +++ b/plugins/woocommerce-admin/packages/data/src/reviews/reducer.js @@ -9,7 +9,16 @@ const reducer = ( errors: {}, data: {}, }, - { type, query, reviews, totalCount, error } + { + type, + query, + reviews, + reviewId, + reviewData, + totalCount, + error, + isUpdating, + } ) => { switch ( type ) { case TYPES.UPDATE_REVIEWS: @@ -30,6 +39,14 @@ const reducer = ( ...nextReviews, }, }; + case TYPES.SET_REVIEW: + return { + ...state, + data: { + ...state.data, + [ reviewId ]: reviewData, + }, + }; case TYPES.SET_ERROR: return { ...state, @@ -38,6 +55,17 @@ const reducer = ( [ JSON.stringify( query ) ]: error, }, }; + case TYPES.SET_REVIEW_IS_UPDATING: + return { + ...state, + data: { + ...state.data, + [ reviewId ]: { + ...state.data[ reviewId ], + isUpdating, + }, + }, + }; default: return state; } diff --git a/plugins/woocommerce-admin/packages/data/src/reviews/selectors.js b/plugins/woocommerce-admin/packages/data/src/reviews/selectors.js index 43962d50771..f969579e99e 100644 --- a/plugins/woocommerce-admin/packages/data/src/reviews/selectors.js +++ b/plugins/woocommerce-admin/packages/data/src/reviews/selectors.js @@ -10,9 +10,8 @@ export const getReviews = ( state, query ) => { export const getReviewsTotalCount = ( state, query ) => { const stringifiedQuery = JSON.stringify( query ); return ( - ( state.reviews[ stringifiedQuery ] && - state.reviews[ stringifiedQuery ].totalCount ) || - 0 + state.reviews[ stringifiedQuery ] && + state.reviews[ stringifiedQuery ].totalCount ); }; diff --git a/plugins/woocommerce-admin/packages/data/src/reviews/test/reducer.js b/plugins/woocommerce-admin/packages/data/src/reviews/test/reducer.js index 0751f7d4031..18a1fc7abb7 100644 --- a/plugins/woocommerce-admin/packages/data/src/reviews/test/reducer.js +++ b/plugins/woocommerce-admin/packages/data/src/reviews/test/reducer.js @@ -62,4 +62,50 @@ describe( 'reviews reducer', () => { const stringifiedQuery = JSON.stringify( query ); 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 ); + } ); } );