Skip to content

Commit

Permalink
Store: Add the ability to reply to product reviews. (#18377)
Browse files Browse the repository at this point in the history
* Add the ability to reply to product reviews.

* Handle PR Feedback: Single quotes for deleted string, more accurate comparison on text area height, and make the submit box padding a bit wider to make it easier to hit.
  • Loading branch information
justinshreve authored Sep 29, 2017
1 parent bccc3d3 commit 7dd54ad
Show file tree
Hide file tree
Showing 10 changed files with 444 additions and 22 deletions.
18 changes: 6 additions & 12 deletions client/extensions/woocommerce/app/reviews/review-replies.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { fetchReviewReplies } from 'woocommerce/state/sites/review-replies/actio
import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors';
import { getReviewReplies } from 'woocommerce/state/sites/review-replies/selectors';
import ReviewReply from './review-reply';
import ReviewReplyCreate from './review-reply-create';

class ReviewReplies extends Component {
static propTypes = {
Expand Down Expand Up @@ -52,23 +53,16 @@ class ReviewReplies extends Component {
}

render() {
const { replyIds, review, translate } = this.props;
const { siteId, replyIds, review } = this.props;
const repliesOutput = replyIds.length && replyIds.map( this.renderReply ) || null;

const textAreaPlaceholder = 'approved' === review.status
? translate( 'Reply to %(reviewAuthor)s…', { args: { reviewAuthor: review.name } } )
: translate( 'Approve and reply to %(reviewAuthor)s…', { args: { reviewAuthor: review.name } } );

return (
<div className="reviews__replies">
{ repliesOutput }

<form className="reviews__reply-textarea">
<textarea placeholder={ textAreaPlaceholder } />
<button className="reviews__reply-submit">
{ translate( 'Send' ) }
</button>
</form>
<ReviewReplyCreate
siteId={ siteId }
review={ review }
/>
</div>
);
}
Expand Down
175 changes: 175 additions & 0 deletions client/extensions/woocommerce/app/reviews/review-reply-create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* External depedencies
*/
import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { localize } from 'i18n-calypso';
import PropTypes from 'prop-types';

/**
* Internal dependencies
*/
import { createReviewReply } from 'woocommerce/state/sites/review-replies/actions';
import { editReviewReply, clearReviewReplyEdits } from 'woocommerce/state/ui/review-replies/actions';
import { getCurrentUser } from 'state/current-user/selectors';
import { getReviewReplyEdits } from 'woocommerce/state/ui/review-replies/selectors';
import Gravatar from 'components/gravatar';
import { successNotice } from 'state/notices/actions';

// Matches comments reply box heights
const TEXTAREA_HEIGHT_COLLAPSED = 47; // 1 line
const TEXTAREA_HEIGHT_FOCUSED = 68; // 2 lines
const TEXTAREA_MAX_HEIGHT = 236; // 10 lines
const TEXTAREA_VERTICAL_BORDER = 2;

class ReviewReplyCreate extends Component {
static propTypes = {
siteId: PropTypes.number.isRequired,
review: PropTypes.shape( {
status: PropTypes.string,
} ).isRequired,
};

state = {
hasFocus: false,
textareaHeight: TEXTAREA_HEIGHT_COLLAPSED,
};

bindTextareaRef = ( textarea ) => {
this.textarea = textarea;
}

calculateTextareaHeight = () => {
const textareaScrollHeight = this.textarea.scrollHeight;
const textareaHeight = Math.min( TEXTAREA_MAX_HEIGHT, textareaScrollHeight + TEXTAREA_VERTICAL_BORDER );
return Math.max( TEXTAREA_HEIGHT_FOCUSED, textareaHeight );
}

getTextareaPlaceholder = () => {
const { review, translate } = this.props;
if ( 'approved' === review.status ) {
return translate( 'Reply to %(reviewAuthor)s…', { args: { reviewAuthor: review.name } } );
}
return translate( 'Approve and reply to %(reviewAuthor)s…', { args: { reviewAuthor: review.name } } );
}

onTextChange = ( event ) => {
const { siteId, review } = this.props;
const { value } = event.target;

this.props.editReviewReply( siteId, review.id, { content: value } );

const textareaHeight = this.calculateTextareaHeight();
this.setState( {
textareaHeight,
} );
}

setFocus = () => this.setState( {
hasFocus: true,
textareaHeight: this.calculateTextareaHeight(),
} );

unsetFocus = () => this.setState( {
hasFocus: false,
textareaHeight: TEXTAREA_HEIGHT_COLLAPSED,
} );

onSubmit = ( event ) => {
event.preventDefault();
const { siteId, review, commentText, translate } = this.props;
const { product } = review;

const shouldApprove = 'pending' === review.status ? true : false;

this.props.createReviewReply( siteId, product.id, review.id, commentText, shouldApprove );
this.props.clearReviewReplyEdits( siteId );

this.props.successNotice(
translate( 'Reply submitted.' ),
{ duration: 5000 }
);
}

render() {
const { translate, currentUser, commentText } = this.props;
const { hasFocus, textareaHeight } = this.state;

const hasCommentText = commentText.trim().length > 0;

// Only show the scrollbar if the textarea content exceeds the max height
const hasScrollbar = textareaHeight >= TEXTAREA_MAX_HEIGHT;

const buttonClasses = classNames( 'reviews__reply-submit', {
'has-scrollbar': hasScrollbar,
'is-active': hasCommentText,
'is-visible': hasFocus || hasCommentText,
} );
const gravatarClasses = classNames( { 'is-visible': ! hasFocus } );
const textareaClasses = classNames( {
'has-content': hasCommentText,
'has-focus': hasFocus,
'has-scrollbar': hasScrollbar,
} );

// Without focus, force the textarea to collapse even if it was manually resized
const textareaStyle = {
height: hasFocus ? textareaHeight : TEXTAREA_HEIGHT_COLLAPSED,
};

return (
<form className="reviews__reply-textarea">
<textarea
className={ textareaClasses }
onBlur={ this.unsetFocus }
onChange={ this.onTextChange }
onFocus={ this.setFocus }
placeholder={ this.getTextareaPlaceholder() }
ref={ this.bindTextareaRef }
style={ textareaStyle }
value={ commentText }
/>

<Gravatar
className={ gravatarClasses }
size={ 24 }
user={ currentUser }
/>

<button
className={ buttonClasses }
disabled={ ! hasCommentText }
onClick={ this.onSubmit }
>
{ translate( 'Send' ) }
</button>
</form>
);
}

}

function mapStateToProps( state ) {
const replyEdits = getReviewReplyEdits( state );
const commentText = replyEdits.content || '';
return {
currentUser: getCurrentUser( state ),
commentText,
};
}

function mapDispatchToProps( dispatch ) {
return bindActionCreators(
{
editReviewReply,
clearReviewReplyEdits,
createReviewReply,
successNotice,
},
dispatch
);
}

export default connect( mapStateToProps, mapDispatchToProps )( localize( ReviewReplyCreate ) );
58 changes: 51 additions & 7 deletions client/extensions/woocommerce/app/reviews/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,10 @@

.reviews__reply-textarea {
line-height: 0;
overflow: hidden;
padding: 2px;
position: relative;
border-top: 1px solid lighten( $gray, 30% );

textarea {
font-size: 14px;
Expand All @@ -239,12 +242,29 @@
padding: 12px 70px 12px 16px;
position: relative;
resize: vertical;
transition: min-height .15s linear;
transition: min-height .15s linear, padding-left 0.2s ease-in-out;
white-space: pre-wrap;
word-wrap: break-word;
border-left: 0;
border-right: 0;
border-bottom: 0;

&:not( :focus ) {
border-color: $white;
padding-left: 48px;
padding-right: 16px;
resize: none;

&.has-content {
padding-right: 70px;
}
}

&:focus,
&.has-focus {
min-height: 68px;
}

&.has-scrollbar {
overflow-y: auto;
}

&::placeholder {
color: $gray-text-min;
Expand All @@ -254,16 +274,40 @@
}
}

.gravatar {
position: absolute;
left: 16px;
top: 12px;
transition: transform 0.2s ease-in-out;

&:not( .is-visible ) {
transform: translate3d( -40px, 0, 0 );
}
}

.reviews__reply-submit {
color: $gray;
font-size: 12px;
font-weight: 600;
padding: 4px;
padding: 16px;
padding-top: 4px;
position: absolute;
right: 18px;
right: -70px;
top: 11px;
text-transform: uppercase;
transition: opacity 0.2s ease-in-out;
transition: transform 0.2s ease-in-out;

&.is-active {
color: $blue-medium;
cursor: pointer;
}

&.is-visible {
transform: translate3d( -88px, 0, 0 );
}
&.is-visible.has-scrollbar {
transform: translate3d( -94px, 0, 0 );
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions client/extensions/woocommerce/state/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const WOOCOMMERCE_REVIEW_DELETE = 'WOOCOMMERCE_REVIEW_DELETE';
export const WOOCOMMERCE_REVIEW_STATUS_CHANGE = 'WOOCOMMERCE_REVIEW_STATUS_CHANGE';
export const WOOCOMMERCE_REVIEW_REPLIES_REQUEST = 'WOOCOMMERCE_REVIEW_REPLIES_REQUEST';
export const WOOCOMMERCE_REVIEW_REPLIES_UPDATED = 'WOOCOMMERCE_REVIEW_REPLIES_UPDATED';
export const WOOCOMMERCE_REVIEW_REPLY_CREATE_REQUEST = 'WOOCOMMERCE_REVIEW_REPLY_CREATE_REQUEST';
export const WOOCOMMERCE_REVIEW_REPLY_CREATED = 'WOOCOMMERCE_REVIEW_REPLY_CREATED';
export const WOOCOMMERCE_REVIEW_REPLY_DELETE_REQUEST = 'WOOCOMMERCE_REVIEW_REPLY_DELETE_REQUEST';
export const WOOCOMMERCE_REVIEW_REPLY_DELETED = 'WOOCOMMERCE_REVIEW_REPLY_DELETED';
export const WOOCOMMERCE_REVIEW_REPLY_UPDATE_REQUEST = 'WOOCOMMERCE_REVIEW_REPLY_UPDATE_REQUEST';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import {
WOOCOMMERCE_REVIEW_REPLIES_REQUEST,
WOOCOMMERCE_REVIEW_REPLY_CREATE_REQUEST,
WOOCOMMERCE_REVIEW_REPLY_DELETE_REQUEST,
WOOCOMMERCE_REVIEW_REPLY_UPDATE_REQUEST
} from 'woocommerce/state/action-types';
Expand All @@ -15,6 +16,17 @@ export function fetchReviewReplies( siteId, reviewId ) {
};
}

export function createReviewReply( siteId, productId, reviewId, replyText, shouldApprove ) {
return {
type: WOOCOMMERCE_REVIEW_REPLY_CREATE_REQUEST,
siteId,
productId,
reviewId,
replyText,
shouldApprove,
};
}

export function deleteReviewReply( siteId, reviewId, replyId ) {
return {
type: WOOCOMMERCE_REVIEW_REPLY_DELETE_REQUEST,
Expand Down
Loading

0 comments on commit 7dd54ad

Please sign in to comment.