From 9bccd52144699165eb33ee10a1199934302acae6 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Wed, 22 Nov 2017 17:59:26 +0200 Subject: [PATCH 1/7] Add review shape to propTypes --- src/util/propTypes.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/util/propTypes.js b/src/util/propTypes.js index 104994dd..233161db 100644 --- a/src/util/propTypes.js +++ b/src/util/propTypes.js @@ -163,6 +163,16 @@ export const TX_TRANSITION_CANCEL = 'transition/cancel'; // with the mark-delivered transition. export const TX_TRANSITION_MARK_DELIVERED = 'transition/mark-delivered'; +// Review transitions +export const TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST = 'transition/review-by-provider-first'; +export const TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND = 'transition/review-by-provider-second'; +export const TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST = 'transition/review-by-customer-first'; +export const TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND = 'transition/review-by-customer-second'; +export const TX_TRANSITION_MARK_REVIEWED_BY_CUSTOMER = 'transition/mark-reviewed-by-customer'; +export const TX_TRANSITION_MARK_REVIEWED_BY_PROVIDER = 'transition/mark-reviewed-by-provider'; +export const TX_TRANSITION_AUTO_COMPLETE_WITHOUT_REVIEWS = + 'transition/auto-complete-without-reviews'; + export const TX_TRANSITIONS = [ TX_TRANSITION_PREAUTHORIZE, TX_TRANSITION_ACCEPT, @@ -170,6 +180,13 @@ export const TX_TRANSITIONS = [ TX_TRANSITION_AUTO_DECLINE, TX_TRANSITION_CANCEL, TX_TRANSITION_MARK_DELIVERED, + TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST, + TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST, + TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, + TX_TRANSITION_MARK_REVIEWED_BY_CUSTOMER, + TX_TRANSITION_MARK_REVIEWED_BY_PROVIDER, + TX_TRANSITION_AUTO_COMPLETE_WITHOUT_REVIEWS, ]; // Roles of actors that perform transaction transitions @@ -207,6 +224,20 @@ export const txTransition = shape({ transition: oneOf(TX_TRANSITIONS).isRequired, }); +// A review on a user +export const review = shape({ + id: uuid.isRequired, + attributes: shape({ + at: instanceOf(Date).isRequired, + content: string, + rating: number, + state: string.isRequired, + type: string.isRequired, + }), + author: user, + subject: user, +}); + // Denormalised transaction object export const transaction = shape({ id: uuid.isRequired, From efc861646604ffbc770cbcba8372f45e0bf60502 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Tue, 28 Nov 2017 11:10:14 +0200 Subject: [PATCH 2/7] Add ReviewRating component --- .../ReviewRating/ReviewRating.example.js | 19 +++++++++ src/components/ReviewRating/ReviewRating.js | 40 +++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + 4 files changed, 62 insertions(+) create mode 100644 src/components/ReviewRating/ReviewRating.example.js create mode 100644 src/components/ReviewRating/ReviewRating.js diff --git a/src/components/ReviewRating/ReviewRating.example.js b/src/components/ReviewRating/ReviewRating.example.js new file mode 100644 index 00000000..8302d1b7 --- /dev/null +++ b/src/components/ReviewRating/ReviewRating.example.js @@ -0,0 +1,19 @@ +import ReviewRating from './ReviewRating'; + +export const ReviewRatingZero = { + component: ReviewRating, + props: { rating: 0 }, + group: 'misc', +}; + +export const ReviewRatingThree = { + component: ReviewRating, + props: { rating: 3 }, + group: 'misc', +}; + +export const ReviewRatingFive = { + component: ReviewRating, + props: { rating: 5 }, + group: 'misc', +}; diff --git a/src/components/ReviewRating/ReviewRating.js b/src/components/ReviewRating/ReviewRating.js new file mode 100644 index 00000000..f08665e2 --- /dev/null +++ b/src/components/ReviewRating/ReviewRating.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { range } from 'lodash'; +import { IconReviewStar } from '../../components'; + +const MAX_RATING = 5; + +const ReviewRating = props => { + const { className, rootClassName, reviewStarClassName, rating } = props; + const classes = rootClassName || className; + + const stars = range(MAX_RATING); + return ( +
+ {stars.map(star => ( + + ))} +
+ ); +}; + +ReviewRating.defaultProps = { + className: null, + reviewStarClassName: null, +}; + +const { number, string } = PropTypes; + +ReviewRating.propTypes = { + rating: number.isRequired, + reviewStartClassName: string, + rootClassName: string, + className: string, +}; + +export default ReviewRating; diff --git a/src/components/index.js b/src/components/index.js index 54535659..22c61959 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -80,6 +80,7 @@ export { default as PaginationLinks } from './PaginationLinks/PaginationLinks'; export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy'; export { default as Promised } from './Promised/Promised'; export { default as ResponsiveImage } from './ResponsiveImage/ResponsiveImage'; +export { default as ReviewRating } from './ReviewRating/ReviewRating'; export { default as SaleDetailsPanel } from './SaleDetailsPanel/SaleDetailsPanel'; export { default as SearchMap } from './SearchMap/SearchMap'; export { default as SearchMapGroupLabel } from './SearchMapGroupLabel/SearchMapGroupLabel'; diff --git a/src/examples.js b/src/examples.js index 34c359da..3e5a37d3 100644 --- a/src/examples.js +++ b/src/examples.js @@ -36,6 +36,7 @@ import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example import * as NamedLink from './components/NamedLink/NamedLink.example'; import * as PaginationLinks from './components/PaginationLinks/PaginationLinks.example'; import * as ResponsiveImage from './components/ResponsiveImage/ResponsiveImage.example'; +import * as ReviewRating from './components/ReviewRating/ReviewRating.example'; import * as SelectField from './components/SelectField/SelectField.example'; import * as StripeBankAccountTokenInputField from './components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.example'; import * as TabNav from './components/TabNav/TabNav.example'; @@ -110,6 +111,7 @@ export { PasswordResetForm, PayoutDetailsForm, ResponsiveImage, + ReviewRating, SelectField, SendMessageForm, SignupForm, From dccf787f13ade970a833954dd2a8dfbe756ac4fd Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 23 Nov 2017 15:21:45 +0200 Subject: [PATCH 3/7] Add reviews to the ActivityFeed component --- src/components/ActivityFeed/ActivityFeed.css | 26 ++ .../ActivityFeed/ActivityFeed.example.js | 68 ++++- src/components/ActivityFeed/ActivityFeed.js | 232 ++++++++++++++---- .../OrderDetailsPanel.test.js.snap | 18 ++ .../SaleDetailsPanel.test.js.snap | 18 ++ .../__snapshots__/InboxPage.test.js.snap | 4 + .../__snapshots__/OrderPage.test.js.snap | 1 + .../__snapshots__/SalePage.test.js.snap | 1 + src/translations/en.json | 6 +- src/util/propTypes.js | 1 + src/util/test-data.js | 18 ++ 11 files changed, 346 insertions(+), 47 deletions(-) diff --git a/src/components/ActivityFeed/ActivityFeed.css b/src/components/ActivityFeed/ActivityFeed.css index 6b8a9405..f0ddc930 100644 --- a/src/components/ActivityFeed/ActivityFeed.css +++ b/src/components/ActivityFeed/ActivityFeed.css @@ -137,6 +137,32 @@ margin-right: 6px; } +.reviewContent { + @apply --marketplaceH4FontStyles; + font-style: italic; + white-space: pre-line; + margin: 8px 0 0 0; + + @media (--viewportMedium) { + max-width: 500px; + margin: 7px 0 0 0; + } +} + +.reviewStars { + margin-top: 4px; + + @media (--viewportMedium) { + margin-top: 2px; + } +} + +.reviewStar { + width: 12px; + height: 12px; + font-styles: initial; +} + .showOlderWrapper { text-align: center; margin-bottom: 1px; diff --git a/src/components/ActivityFeed/ActivityFeed.example.js b/src/components/ActivityFeed/ActivityFeed.example.js index bd99f626..8740b28d 100644 --- a/src/components/ActivityFeed/ActivityFeed.example.js +++ b/src/components/ActivityFeed/ActivityFeed.example.js @@ -6,6 +6,7 @@ import { createTransaction, createListing, createTxTransition, + createReview, } from '../../util/test-data'; import * as propTypes from '../../util/propTypes'; import ActivityFeed from './ActivityFeed'; @@ -84,7 +85,7 @@ export const WithTransitions = { group: 'messages', }; -export const WithMessagesAndTransitions = { +export const WithMessagesTransitionsAndReviews = { component: ActivityFeed, props: { currentUser: createCurrentUser('user2'), @@ -92,6 +93,7 @@ export const WithMessagesAndTransitions = { customer: createUser('user1'), provider: createUser('user2'), listing: createListing('Listing'), + lastTransition: propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, transitions: [ createTxTransition({ at: new Date(Date.UTC(2017, 10, 9, 8, 10)), @@ -113,6 +115,28 @@ export const WithMessagesAndTransitions = { by: propTypes.TX_TRANSITION_ACTOR_PROVIDER, transition: propTypes.TX_TRANSITION_MARK_DELIVERED, }), + createTxTransition({ + at: new Date(Date.UTC(2017, 10, 9, 11, 34)), + by: propTypes.TX_TRANSITION_ACTOR_PROVIDER, + transition: propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST, + }), + createTxTransition({ + at: new Date(Date.UTC(2017, 10, 9, 12, 34)), + by: propTypes.TX_TRANSITION_ACTOR_CUSTOMER, + transition: propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, + }), + ], + reviews: [ + createReview( + 'review1', + { at: new Date(Date.UTC(2017, 10, 9, 11, 34)), rating: 3, type: 'ofCustomer' }, + { author: createUser('user2'), subject: createUser('user1') } + ), + createReview( + 'review2', + { at: new Date(Date.UTC(2017, 10, 9, 12, 34)), rating: 5, type: 'ofProvider' }, + { author: createUser('user1'), subject: createUser('user2') } + ), ], }), messages: [ @@ -144,6 +168,48 @@ export const WithMessagesAndTransitions = { group: 'messages', }; +export const WithAReviewFromBothUsers = { + component: ActivityFeed, + props: { + currentUser: createCurrentUser('user1'), + transaction: createTransaction({ + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + reviews: [ + createReview( + 'review1', + { at: new Date(Date.UTC(2017, 10, 9, 8, 10)), rating: 3, type: 'ofProvider' }, + { author: createUser('user1'), subject: createUser('user2') } + ), + createReview( + 'review2', + { at: new Date(Date.UTC(2017, 10, 10, 8, 10)), rating: 5, type: 'ofCustomer' }, + { author: createUser('user2'), subject: createUser('user1') } + ), + ], + lastTransition: propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + transitions: [ + createTxTransition({ + at: new Date(Date.UTC(2017, 10, 9, 8, 10)), + by: propTypes.TX_TRANSITION_ACTOR_CUSTOMER, + transition: propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST, + }), + createTxTransition({ + at: new Date(Date.UTC(2017, 10, 10, 8, 10)), + by: propTypes.TX_TRANSITION_ACTOR_PROVIDER, + transition: propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + }), + ], + }), + messages: [], + hasOlderMessages: false, + onShowOlderMessages: noop, + fetchMessagesInProgress: false, + }, + group: 'messages', +}; + class PagedFeed extends Component { constructor(props) { super(props); diff --git a/src/components/ActivityFeed/ActivityFeed.js b/src/components/ActivityFeed/ActivityFeed.js index 80780078..f45e3fb5 100644 --- a/src/components/ActivityFeed/ActivityFeed.js +++ b/src/components/ActivityFeed/ActivityFeed.js @@ -1,9 +1,9 @@ import React from 'react'; -import { string, arrayOf, bool, func } from 'prop-types'; +import { string, arrayOf, bool, func, number } from 'prop-types'; import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; import { dropWhile } from 'lodash'; import classNames from 'classnames'; -import { Avatar, InlineTextButton } from '../../components'; +import { Avatar, InlineTextButton, NamedLink, ReviewRating } from '../../components'; import { formatDate } from '../../util/dates'; import { ensureTransaction, ensureUser, ensureListing } from '../../util/data'; import * as propTypes from '../../util/propTypes'; @@ -47,43 +47,156 @@ OwnMessage.propTypes = { intl: intlShape.isRequired, }; +const Review = props => { + const { content, rating } = props; + return ( +
+

{content}

+ +
+ ); +}; + +Review.propTypes = { + content: string.isRequired, + rating: number.isRequired, +}; + +const areReviewsCompleted = transition => { + return [ + propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, + propTypes.TX_TRANSITION_MARK_REVIEWED_BY_CUSTOMER, + propTypes.TX_TRANSITION_MARK_REVIEWED_BY_PROVIDER, + propTypes.TX_TRANSITION_AUTO_COMPLETE_WITHOUT_REVIEWS, + ].includes(transition); +}; + +const isReviewTransition = transition => { + return [ + propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST, + propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST, + propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, + ].includes(transition); +}; + +const hasUserLeftAReviewFirst = (userRole, lastTransition) => { + return ( + (lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST && + userRole === propTypes.TX_TRANSITION_ACTOR_CUSTOMER) || + (lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST && + userRole === propTypes.TX_TRANSITION_ACTOR_PROVIDER) || + areReviewsCompleted(lastTransition) + ); +}; + +const resolveTransitionMessage = ( + transition, + lastTransition, + listingTitle, + ownRole, + otherUsersName, + intl +) => { + const isOwnTransition = transition.by === ownRole; + const currentTransition = transition.transition; + const displayName = otherUsersName; + const deliveredState = lastTransition === propTypes.TX_TRANSITION_MARK_DELIVERED; + + switch (currentTransition) { + case propTypes.TX_TRANSITION_PREAUTHORIZE: + return isOwnTransition ? ( + + ) : ( + + ); + case propTypes.TX_TRANSITION_ACCEPT: + return isOwnTransition ? ( + + ) : ( + + ); + case propTypes.TX_TRANSITION_DECLINE: + return isOwnTransition ? ( + + ) : ( + + ); + case propTypes.TX_TRANSITION_AUTO_DECLINE: + return ownRole === propTypes.TX_TRANSITION_ACTOR_PROVIDER ? ( + + ) : ( + + ); + case propTypes.TX_TRANSITION_CANCEL: + return ; + case propTypes.TX_TRANSITION_MARK_DELIVERED: + // Show the leave a review link if the state is delivered or + // if current user is not the first to leave a review + const reviewLink = + deliveredState || !hasUserLeftAReviewFirst(ownRole, lastTransition) ? ( + + + + ) : null; + + return ; + case propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST: + case propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST: + if (isOwnTransition) { + return ; + } else { + // show the leave a review link if current user is not the first + // one to leave a review + const reviewLink = !hasUserLeftAReviewFirst(ownRole, lastTransition) ? ( + + + + ) : null; + return ( + + ); + } + case propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND: + case propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND: + if (isOwnTransition) { + return ; + } else { + return ( + + ); + } + + default: + return ''; + } +}; + +const reviewByAuthorId = (transaction, userId) => { + return transaction.reviews.filter(r => r.author.id.uuid === userId.uuid)[0]; +}; + const Transition = props => { - const { transition, currentUser, customer, provider, listingTitle, intl } = props; + const { transition, transaction, currentUser, intl } = props; - const resolveTransitionMessage = (transition, ownRole, otherUsersName, intl) => { - const isOwnTransition = transition.by === ownRole; - const transitionType = transition.transition; - const displayName = otherUsersName; - - switch (transitionType) { - case propTypes.TX_TRANSITION_PREAUTHORIZE: - return isOwnTransition - ? intl.formatMessage({ id: 'ActivityFeed.ownTransitionRequest' }, { listingTitle }) - : intl.formatMessage( - { id: 'ActivityFeed.transitionRequest' }, - { displayName, listingTitle } - ); - case propTypes.TX_TRANSITION_ACCEPT: - return isOwnTransition - ? intl.formatMessage({ id: 'ActivityFeed.ownTransitionAccept' }) - : intl.formatMessage({ id: 'ActivityFeed.transitionAccept' }, { displayName }); - case propTypes.TX_TRANSITION_DECLINE: - return isOwnTransition - ? intl.formatMessage({ id: 'ActivityFeed.ownTransitionDecline' }) - : intl.formatMessage({ id: 'ActivityFeed.transitionDecline' }, { displayName }); - case propTypes.TX_TRANSITION_AUTO_DECLINE: - return ownRole === propTypes.TX_TRANSITION_ACTOR_PROVIDER - ? intl.formatMessage({ id: 'ActivityFeed.ownTransitionAutoDecline' }) - : intl.formatMessage({ id: 'ActivityFeed.transitionAutoDecline' }, { displayName }); - case propTypes.TX_TRANSITION_MARK_DELIVERED: - return intl.formatMessage({ id: 'ActivityFeed.transitionComplete' }); - case propTypes.TX_TRANSITION_CANCEL: - return intl.formatMessage({ id: 'ActivityFeed.transitionCancel' }); - - default: - return ''; - } - }; + const customer = transaction.customer; + const provider = transaction.provider; + const listingTitle = transaction.listing.attributes.title; + const lastTransition = transaction.attributes.lastTransition; const ownRole = currentUser.id.uuid === customer.id.uuid @@ -95,7 +208,38 @@ const Transition = props => { ? provider.attributes.profile.displayName : customer.attributes.profile.displayName; - const transitionMessage = resolveTransitionMessage(transition, ownRole, otherUsersName, intl); + const transitionMessage = resolveTransitionMessage( + transition, + lastTransition, + listingTitle, + ownRole, + otherUsersName, + intl + ); + const currentTransition = transition.transition; + + let reviewComponent = null; + + if (isReviewTransition(currentTransition) && areReviewsCompleted(lastTransition)) { + const customerReview = + currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST || + currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND; + const providerReview = + currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST || + currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND; + if (customerReview) { + const review = reviewByAuthorId(transaction, customer.id); + reviewComponent = ( + + ); + } else if (providerReview) { + const review = reviewByAuthorId(transaction, provider.id); + reviewComponent = ( + + ); + } + } + const todayString = intl.formatMessage({ id: 'ActivityFeed.today' }); return ( @@ -106,6 +250,7 @@ const Transition = props => {

{transitionMessage}

{formatDate(intl, todayString, transition.at)}

+ {reviewComponent}
); @@ -113,10 +258,8 @@ const Transition = props => { Transition.propTypes = { transition: propTypes.txTransition.isRequired, + transaction: propTypes.transaction.isRequired, currentUser: propTypes.currentUser.isRequired, - customer: propTypes.user.isRequired, - provider: propTypes.user.isRequired, - listingTitle: string.isRequired, intl: intlShape.isRequired, }; @@ -180,7 +323,8 @@ export const ActivityFeedComponent = props => { currentUser && currentUser.id && currentCustomer.id && - currentProvider.id + currentProvider.id && + currentListing.id ); // combine messages and transaction transitions @@ -191,10 +335,8 @@ export const ActivityFeedComponent = props => { return ( ); diff --git a/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap b/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap index 03cf25a8..71cf60de 100644 --- a/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap +++ b/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap @@ -217,6 +217,7 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -424,6 +425,7 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -633,6 +635,7 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -849,6 +852,7 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -1056,6 +1060,7 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -1265,6 +1270,7 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -1493,6 +1499,7 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -1700,6 +1707,7 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -1909,6 +1917,7 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -2125,6 +2134,7 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -2332,6 +2342,7 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -2541,6 +2552,7 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -2757,6 +2769,7 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -2964,6 +2977,7 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -3173,6 +3187,7 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -3412,6 +3427,7 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -3619,6 +3635,7 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -3828,6 +3845,7 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } diff --git a/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap b/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap index 260d3344..020ddbac 100644 --- a/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap +++ b/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap @@ -211,6 +211,7 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -406,6 +407,7 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -571,6 +573,7 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -795,6 +798,7 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -990,6 +994,7 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -1155,6 +1160,7 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -1379,6 +1385,7 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -1574,6 +1581,7 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -1739,6 +1747,7 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -1963,6 +1972,7 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -2158,6 +2168,7 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -2323,6 +2334,7 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -2547,6 +2559,7 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -2742,6 +2755,7 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -2907,6 +2921,7 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -3141,6 +3156,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -3336,6 +3352,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } @@ -3529,6 +3546,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = ` "type": "listing", }, "provider": null, + "reviews": Array [], "type": "transaction", } } diff --git a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap index 18ebeb1c..7131cc9e 100644 --- a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap +++ b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap @@ -183,6 +183,7 @@ exports[`InboxPage matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -297,6 +298,7 @@ exports[`InboxPage matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -569,6 +571,7 @@ exports[`InboxPage matches snapshot 3`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } @@ -683,6 +686,7 @@ exports[`InboxPage matches snapshot 3`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } diff --git a/src/containers/OrderPage/__snapshots__/OrderPage.test.js.snap b/src/containers/OrderPage/__snapshots__/OrderPage.test.js.snap index fc91023a..981a359a 100644 --- a/src/containers/OrderPage/__snapshots__/OrderPage.test.js.snap +++ b/src/containers/OrderPage/__snapshots__/OrderPage.test.js.snap @@ -165,6 +165,7 @@ exports[`OrderPage matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } diff --git a/src/containers/SalePage/__snapshots__/SalePage.test.js.snap b/src/containers/SalePage/__snapshots__/SalePage.test.js.snap index b2183007..b9280c89 100644 --- a/src/containers/SalePage/__snapshots__/SalePage.test.js.snap +++ b/src/containers/SalePage/__snapshots__/SalePage.test.js.snap @@ -170,6 +170,7 @@ exports[`SalePage matches snapshot 1`] = ` }, "type": "user", }, + "reviews": Array [], "type": "transaction", } } diff --git a/src/translations/en.json b/src/translations/en.json index 35ef6050..6d166f6e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1,16 +1,20 @@ { + "ActivityFeed.leaveAReview": "Leave a review for { displayName }.", + "ActivityFeed.leaveAReviewSecond": "Leave a review for { displayName } to see their review.", "ActivityFeed.ownTransitionAccept": "You accepted the booking request.", "ActivityFeed.ownTransitionAutoDecline": "Booking request expired. You did not reply on time.", "ActivityFeed.ownTransitionDecline": "You declined the booking request.", "ActivityFeed.ownTransitionRequest": "You requested to book { listingTitle }.", + "ActivityFeed.ownTransitionReview": "You left a review for { displayName }.", "ActivityFeed.showOlderMessages": "Show older", "ActivityFeed.today": "Today", "ActivityFeed.transitionAccept": "{ displayName } accepted the booking request.", "ActivityFeed.transitionAutoDecline": "Booking request expired. { displayName } did not reply on time.", "ActivityFeed.transitionCancel": "The booking was cancelled.", - "ActivityFeed.transitionComplete": "The booking was completed.", + "ActivityFeed.transitionComplete": "The booking was completed. { reviewLink }", "ActivityFeed.transitionDecline": "{ displayName } declined the booking request.", "ActivityFeed.transitionRequest": "{ displayName } requested to book { listingTitle }.", + "ActivityFeed.transitionReview": "{ displayName } left a review for you. { reviewLink }", "AuthenticationPage.emailAlreadyInUse": "An account with this email address already exists. Try logging in instead.", "AuthenticationPage.fixEmail": "Whoops, typo in your email? {fixEmailLink}.", "AuthenticationPage.fixEmailLinkText": "Fix it", diff --git a/src/util/propTypes.js b/src/util/propTypes.js index 233161db..bad686e0 100644 --- a/src/util/propTypes.js +++ b/src/util/propTypes.js @@ -263,6 +263,7 @@ export const transaction = shape({ listing, customer: user, provider: user, + reviews: arrayOf(review), }); // Denormalised transaction message diff --git a/src/util/test-data.js b/src/util/test-data.js index d6767095..d191f3e3 100644 --- a/src/util/test-data.js +++ b/src/util/test-data.js @@ -105,6 +105,7 @@ export const createTransaction = options => { listing = null, customer = null, provider = null, + reviews = [], lastTransitionedAt = new Date(Date.UTC(2017, 5, 1)), transitions = [ createTxTransition({ @@ -150,6 +151,7 @@ export const createTransaction = options => { listing, customer, provider, + reviews, }; }; @@ -166,6 +168,22 @@ export const createMessage = (id, attributes = {}, includes = {}) => { }; }; +export const createReview = (id, attributes = {}, includes = {}) => { + return { + id: new UUID(id), + attributes: { + at: new Date(), + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + rating: 3, + state: 'public', + type: 'ofProvider', + ...attributes, + }, + ...includes, + }; +}; + // Default config for currency formatting in tests and examples. export const currencyConfig = { style: 'currency', From 1f3e0e9fce1b1f775a36b8c224dedee15c1abf8f Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Mon, 27 Nov 2017 15:47:39 +0200 Subject: [PATCH 4/7] Add reviews to OrderPage and SalePage --- src/containers/OrderPage/OrderPage.duck.js | 16 +++++++++++++++- src/containers/SalePage/SalePage.duck.js | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/containers/OrderPage/OrderPage.duck.js b/src/containers/OrderPage/OrderPage.duck.js index dc921aeb..651091f1 100644 --- a/src/containers/OrderPage/OrderPage.duck.js +++ b/src/containers/OrderPage/OrderPage.duck.js @@ -117,7 +117,21 @@ export const fetchOrder = id => (dispatch, getState, sdk) => { let txResponse = null; return sdk.transactions - .show({ id, include: ['customer', 'provider', 'listing', 'booking'] }, { expand: true }) + .show( + { + id, + include: [ + 'customer', + 'provider', + 'listing', + 'booking', + 'reviews', + 'reviews.author', + 'reviews.subject', + ], + }, + { expand: true } + ) .then(response => { txResponse = response; const listingId = listingRelationship(response).id; diff --git a/src/containers/SalePage/SalePage.duck.js b/src/containers/SalePage/SalePage.duck.js index f3b5573f..1a74484e 100644 --- a/src/containers/SalePage/SalePage.duck.js +++ b/src/containers/SalePage/SalePage.duck.js @@ -159,6 +159,9 @@ export const fetchSale = id => (dispatch, getState, sdk) => { 'provider.profileImage', 'listing', 'booking', + 'reviews', + 'reviews.author', + 'reviews.subject', ], }, { expand: true } From 0735760cf57fbb491c7c15dd3b28ccf78eb6c4ff Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Wed, 29 Nov 2017 08:57:08 +0200 Subject: [PATCH 5/7] Change leave a review link to a button --- src/components/ActivityFeed/ActivityFeed.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/ActivityFeed/ActivityFeed.js b/src/components/ActivityFeed/ActivityFeed.js index f45e3fb5..c1bce6b9 100644 --- a/src/components/ActivityFeed/ActivityFeed.js +++ b/src/components/ActivityFeed/ActivityFeed.js @@ -3,7 +3,7 @@ import { string, arrayOf, bool, func, number } from 'prop-types'; import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; import { dropWhile } from 'lodash'; import classNames from 'classnames'; -import { Avatar, InlineTextButton, NamedLink, ReviewRating } from '../../components'; +import { Avatar, InlineTextButton, ReviewRating } from '../../components'; import { formatDate } from '../../util/dates'; import { ensureTransaction, ensureUser, ensureListing } from '../../util/data'; import * as propTypes from '../../util/propTypes'; @@ -95,6 +95,12 @@ const hasUserLeftAReviewFirst = (userRole, lastTransition) => { ); }; +const onLeaveAReview = () => { + // TODO: Open up a review modal + + console.log('Leave a review button clicked'); +}; + const resolveTransitionMessage = ( transition, lastTransition, @@ -143,9 +149,9 @@ const resolveTransitionMessage = ( // if current user is not the first to leave a review const reviewLink = deliveredState || !hasUserLeftAReviewFirst(ownRole, lastTransition) ? ( - + - + ) : null; return ; @@ -157,9 +163,9 @@ const resolveTransitionMessage = ( // show the leave a review link if current user is not the first // one to leave a review const reviewLink = !hasUserLeftAReviewFirst(ownRole, lastTransition) ? ( - + - + ) : null; return ( Date: Wed, 29 Nov 2017 10:17:45 +0200 Subject: [PATCH 6/7] Fix baseline for the ActivityFeed --- src/components/ActivityFeed/ActivityFeed.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ActivityFeed/ActivityFeed.css b/src/components/ActivityFeed/ActivityFeed.css index f0ddc930..fd793555 100644 --- a/src/components/ActivityFeed/ActivityFeed.css +++ b/src/components/ActivityFeed/ActivityFeed.css @@ -62,7 +62,7 @@ box-shadow: var(--boxShadow); @media (--viewportMedium) { - padding: 8px 14px 9px 14px; + padding: 6px 14px 11px 14px; margin: 0; } } From 919cb89499a7a5369cceb9371bba25d094b06c68 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Wed, 29 Nov 2017 13:52:40 +0200 Subject: [PATCH 7/7] Improve transition handling and type checking - move review transition utility functions to util/propTypes - pass a non-ensured transaction to Transition component - stronger validation for review rating - review rating css style resolving revisited --- src/components/ActivityFeed/ActivityFeed.js | 41 ++++++------------- .../ReviewRating/ReviewRating.example.js | 8 ---- src/components/ReviewRating/ReviewRating.js | 16 ++++---- src/util/propTypes.js | 30 +++++++++++++- 4 files changed, 49 insertions(+), 46 deletions(-) diff --git a/src/components/ActivityFeed/ActivityFeed.js b/src/components/ActivityFeed/ActivityFeed.js index c1bce6b9..415f63cb 100644 --- a/src/components/ActivityFeed/ActivityFeed.js +++ b/src/components/ActivityFeed/ActivityFeed.js @@ -66,32 +66,13 @@ Review.propTypes = { rating: number.isRequired, }; -const areReviewsCompleted = transition => { - return [ - propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, - propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, - propTypes.TX_TRANSITION_MARK_REVIEWED_BY_CUSTOMER, - propTypes.TX_TRANSITION_MARK_REVIEWED_BY_PROVIDER, - propTypes.TX_TRANSITION_AUTO_COMPLETE_WITHOUT_REVIEWS, - ].includes(transition); -}; - -const isReviewTransition = transition => { - return [ - propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST, - propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST, - propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, - propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, - ].includes(transition); -}; - const hasUserLeftAReviewFirst = (userRole, lastTransition) => { return ( (lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST && userRole === propTypes.TX_TRANSITION_ACTOR_CUSTOMER) || (lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST && userRole === propTypes.TX_TRANSITION_ACTOR_PROVIDER) || - areReviewsCompleted(lastTransition) + propTypes.areReviewsCompleted(lastTransition) ); }; @@ -199,10 +180,11 @@ const reviewByAuthorId = (transaction, userId) => { const Transition = props => { const { transition, transaction, currentUser, intl } = props; - const customer = transaction.customer; - const provider = transaction.provider; - const listingTitle = transaction.listing.attributes.title; - const lastTransition = transaction.attributes.lastTransition; + const currentTransaction = ensureTransaction(transaction); + const customer = currentTransaction.customer; + const provider = currentTransaction.provider; + const listingTitle = currentTransaction.listing.attributes.title; + const lastTransition = currentTransaction.attributes.lastTransition; const ownRole = currentUser.id.uuid === customer.id.uuid @@ -226,7 +208,10 @@ const Transition = props => { let reviewComponent = null; - if (isReviewTransition(currentTransition) && areReviewsCompleted(lastTransition)) { + if ( + propTypes.isReviewTransition(currentTransition) && + propTypes.areReviewsCompleted(lastTransition) + ) { const customerReview = currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST || currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND; @@ -234,12 +219,12 @@ const Transition = props => { currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST || currentTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND; if (customerReview) { - const review = reviewByAuthorId(transaction, customer.id); + const review = reviewByAuthorId(currentTransaction, customer.id); reviewComponent = ( ); } else if (providerReview) { - const review = reviewByAuthorId(transaction, provider.id); + const review = reviewByAuthorId(currentTransaction, provider.id); reviewComponent = ( ); @@ -341,7 +326,7 @@ export const ActivityFeedComponent = props => { return ( diff --git a/src/components/ReviewRating/ReviewRating.example.js b/src/components/ReviewRating/ReviewRating.example.js index 8302d1b7..902b1e97 100644 --- a/src/components/ReviewRating/ReviewRating.example.js +++ b/src/components/ReviewRating/ReviewRating.example.js @@ -1,19 +1,11 @@ import ReviewRating from './ReviewRating'; -export const ReviewRatingZero = { - component: ReviewRating, - props: { rating: 0 }, - group: 'misc', -}; - export const ReviewRatingThree = { component: ReviewRating, props: { rating: 3 }, - group: 'misc', }; export const ReviewRatingFive = { component: ReviewRating, props: { rating: 5 }, - group: 'misc', }; diff --git a/src/components/ReviewRating/ReviewRating.js b/src/components/ReviewRating/ReviewRating.js index f08665e2..7f61b1ed 100644 --- a/src/components/ReviewRating/ReviewRating.js +++ b/src/components/ReviewRating/ReviewRating.js @@ -1,22 +1,21 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { range } from 'lodash'; +import classNames from 'classnames'; import { IconReviewStar } from '../../components'; - -const MAX_RATING = 5; +import * as propTypes from '../../util/propTypes'; const ReviewRating = props => { const { className, rootClassName, reviewStarClassName, rating } = props; - const classes = rootClassName || className; + const classes = classNames(rootClassName, className); - const stars = range(MAX_RATING); + const stars = propTypes.REVIEW_RATINGS; return (
{stars.map(star => ( ))}
@@ -24,14 +23,15 @@ const ReviewRating = props => { }; ReviewRating.defaultProps = { + rootClassName: null, className: null, reviewStarClassName: null, }; -const { number, string } = PropTypes; +const { string, oneOf } = PropTypes; ReviewRating.propTypes = { - rating: number.isRequired, + rating: oneOf(propTypes.REVIEW_RATINGS).isRequired, reviewStartClassName: string, rootClassName: string, className: string, diff --git a/src/util/propTypes.js b/src/util/propTypes.js index bad686e0..12e3cb4c 100644 --- a/src/util/propTypes.js +++ b/src/util/propTypes.js @@ -224,16 +224,42 @@ export const txTransition = shape({ transition: oneOf(TX_TRANSITIONS).isRequired, }); +// Check if tx transition is followed by a state where +// reviews are completed +export const areReviewsCompleted = transition => { + return [ + TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, + TX_TRANSITION_MARK_REVIEWED_BY_CUSTOMER, + TX_TRANSITION_MARK_REVIEWED_BY_PROVIDER, + TX_TRANSITION_AUTO_COMPLETE_WITHOUT_REVIEWS, + ].includes(transition); +}; + +// Check if a user giving a review is related to +// given tx transition. +export const isReviewTransition = transition => { + return [ + TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST, + TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST, + TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND, + TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND, + ].includes(transition); +}; + +// Possible amount of stars in a review +export const REVIEW_RATINGS = [1, 2, 3, 4, 5]; + // A review on a user export const review = shape({ id: uuid.isRequired, attributes: shape({ at: instanceOf(Date).isRequired, content: string, - rating: number, + rating: oneOf(REVIEW_RATINGS), state: string.isRequired, type: string.isRequired, - }), + }).isRequired, author: user, subject: user, });