From c8f22319f8040ca600b59a58fc3ff8406518dbb7 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 17:57:49 +0200 Subject: [PATCH 01/14] Icon for review modal (about user review) --- .../IconReviewUser/IconReviewUser.css | 5 +++ .../IconReviewUser/IconReviewUser.example.js | 7 +++ .../IconReviewUser/IconReviewUser.js | 44 +++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + 5 files changed, 59 insertions(+) create mode 100644 src/components/IconReviewUser/IconReviewUser.css create mode 100644 src/components/IconReviewUser/IconReviewUser.example.js create mode 100644 src/components/IconReviewUser/IconReviewUser.js diff --git a/src/components/IconReviewUser/IconReviewUser.css b/src/components/IconReviewUser/IconReviewUser.css new file mode 100644 index 00000000..7480319c --- /dev/null +++ b/src/components/IconReviewUser/IconReviewUser.css @@ -0,0 +1,5 @@ +@import '../../marketplace.css'; + +.root { + stroke: var(--marketplaceColor); +} diff --git a/src/components/IconReviewUser/IconReviewUser.example.js b/src/components/IconReviewUser/IconReviewUser.example.js new file mode 100644 index 00000000..5b47352e --- /dev/null +++ b/src/components/IconReviewUser/IconReviewUser.example.js @@ -0,0 +1,7 @@ +import IconReviewUser from './IconReviewUser'; + +export const Icon = { + component: IconReviewUser, + props: {}, + group: 'icons', +}; diff --git a/src/components/IconReviewUser/IconReviewUser.js b/src/components/IconReviewUser/IconReviewUser.js new file mode 100644 index 00000000..bc00c190 --- /dev/null +++ b/src/components/IconReviewUser/IconReviewUser.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import css from './IconReviewUser.css'; + +const IconReviewUser = props => { + const { className, rootClassName } = props; + const classes = classNames(rootClassName || css.root, className); + + return ( + + + + + + + + + ); +}; + +IconReviewUser.defaultProps = { className: null, rootClassName: null }; + +const { string } = PropTypes; + +IconReviewUser.propTypes = { + className: string, + rootClassName: string, +}; + +export default IconReviewUser; diff --git a/src/components/index.js b/src/components/index.js index 22c61959..7cd94ac0 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -38,6 +38,7 @@ export { default as IconEmailSuccess } from './IconEmailSuccess/IconEmailSuccess export { default as IconKeys } from './IconKeys/IconKeys'; export { default as IconKeysSuccess } from './IconKeysSuccess/IconKeysSuccess'; export { default as IconReviewStar } from './IconReviewStar/IconReviewStar'; +export { default as IconReviewUser } from './IconReviewUser/IconReviewUser'; export { default as IconSearch } from './IconSearch/IconSearch'; export { default as IconSocialMediaFacebook, diff --git a/src/examples.js b/src/examples.js index 3e5a37d3..8bd4ca94 100644 --- a/src/examples.js +++ b/src/examples.js @@ -21,6 +21,7 @@ import * as IconKeys from './components/IconKeys/IconKeys.example'; import * as IconKeysSuccess from './components/IconKeysSuccess/IconKeysSuccess.example'; import * as IconSearch from './components/IconSearch/IconSearch.example'; import * as IconReviewStar from './components/IconReviewStar/IconReviewStar.example'; +import * as IconReviewUser from './components/IconReviewUser/IconReviewUser.example'; import * as IconSocialMediaFacebook from './components/IconSocialMediaFacebook/IconSocialMediaFacebook.example'; import * as IconSocialMediaInstagram from './components/IconSocialMediaInstagram/IconSocialMediaInstagram.example'; import * as IconSocialMediaTwitter from './components/IconSocialMediaTwitter/IconSocialMediaTwitter.example'; @@ -91,6 +92,7 @@ export { IconKeys, IconKeysSuccess, IconReviewStar, + IconReviewUser, IconSearch, IconSocialMediaFacebook, IconSocialMediaInstagram, From 993c06f162c67103d9ef34e333248ceb7910bf63 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 18:27:34 +0200 Subject: [PATCH 02/14] New form field: star rating --- .../FieldStarRating/FieldStarRating.css | 51 +++++++ .../FieldStarRating.example.js | 44 ++++++ .../FieldStarRating/FieldStarRating.js | 140 ++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + src/marketplaceIndex.css | 1 + src/translations/en.json | 5 + 7 files changed, 244 insertions(+) create mode 100644 src/components/FieldStarRating/FieldStarRating.css create mode 100644 src/components/FieldStarRating/FieldStarRating.example.js create mode 100644 src/components/FieldStarRating/FieldStarRating.js diff --git a/src/components/FieldStarRating/FieldStarRating.css b/src/components/FieldStarRating/FieldStarRating.css new file mode 100644 index 00000000..08e000c8 --- /dev/null +++ b/src/components/FieldStarRating/FieldStarRating.css @@ -0,0 +1,51 @@ +@import '../../marketplace.css'; + +.ratingFieldSet { + border: none; + padding: 0; + margin: 0; +} + +/* fieldset can't use flexbox hence this wrapper exists */ +.rating { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + margin-top: 16px; +} + +.rateInput { + display: none; +} + +.label { + width: 30px; +} + +.star { + fill: var(--matterColorNegative); +} + +/***** CSS Magic to Highlight Stars on Hover *****/ + +/* Star order: reverse expected (5 -> 1) and also input before label */ + +/* show actived star when checked */ +/* and actived star when hovering over a star */ +/* and show previous stars also as activated */ +.rating > .rateInput:checked ~ .label > .star, +.rating > .label:hover > .star, +.rating > .label:hover ~ .label > .star { + fill: var(--marketplaceColor); +} + +/* Darken hovered star when changing rating i.e it already is active */ +/* and darken hovered star too when changing rating (hovering inside current selection) */ +/* and darken current selection inside hovered selection */ +/* and darken hovered selection inside current selection */ +.rating > .rateInput:checked + .label:hover > .star, +.rating > .rateInput:checked ~ .label:hover > .star, +.rating > .label:hover ~ .rateInput:checked ~ .label > .star, +.rating > .rateInput:checked ~ .label:hover ~ .label > .star { + fill: var(--marketplaceColorDark); +} diff --git a/src/components/FieldStarRating/FieldStarRating.example.js b/src/components/FieldStarRating/FieldStarRating.example.js new file mode 100644 index 00000000..b84ca856 --- /dev/null +++ b/src/components/FieldStarRating/FieldStarRating.example.js @@ -0,0 +1,44 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { reduxForm, propTypes as formPropTypes } from 'redux-form'; +import * as validators from '../../util/validators'; +import { Button } from '../../components'; +import FieldStarRating from './FieldStarRating'; + +const formName = 'Styleguide.FieldStarRating.Form'; + +const FormComponent = props => { + const { form, handleSubmit, invalid, pristine, submitting } = props; + const required = validators.required('This field is required'); + const submitDisabled = invalid || pristine || submitting; + + return ( +
+ + + + ); +}; + +FormComponent.propTypes = formPropTypes; + +const Form = reduxForm({ + form: formName, +})(FormComponent); + +export const StarRating = { + component: Form, + props: { + onSubmit: values => { + console.log('submit values:', values); + }, + }, + group: 'inputs', +}; diff --git a/src/components/FieldStarRating/FieldStarRating.js b/src/components/FieldStarRating/FieldStarRating.js new file mode 100644 index 00000000..d286da4f --- /dev/null +++ b/src/components/FieldStarRating/FieldStarRating.js @@ -0,0 +1,140 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape, injectIntl } from 'react-intl'; +import { Field } from 'redux-form'; +import classNames from 'classnames'; +import { IconReviewStar, ValidationError } from '../../components'; + +import css from './FieldStarRating.css'; + +class FieldStarRatingComponent extends Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + } + + componentWillUnmount() { + if (this.props.clearOnUnmount) { + this.props.input.onChange(''); + } + } + + handleChange(event) { + this.props.input.onChange(event.target.value); + } + + render() { + /* eslint-disable no-unused-vars */ + const { + rootClassName, + className, + inputRootClass, + clearOnUnmount, + customErrorText, + id, + intl, + label, + input, + meta, + ...rest + } = this.props; + /* eslint-enable no-unused-vars */ + + const { touched, error } = meta; + const errorText = customErrorText || error; + const fieldMeta = { touched, error: errorText }; + + const { value, ...restInputProps } = input; + const inputProps = { ...restInputProps, type: 'radio', name: 'rating', ...rest }; + + const classes = classNames(rootClassName || css.root, className); + + const createStarRating = starCount => { + let inputsAndLabels = []; + + // Star inpu order: reverse order expected (5 -> 1) and also input before label + // This is due to CSS selectors. + // Sibling combinator (~) selects following siblings, but we want to select previous siblings + for (let i = starCount; i > 0; i--) { + const inputValue = `${i}`; + const starId = `star${i}`; + const inputId = `${id}.${starId}`; + + inputsAndLabels.push( + + ); + + inputsAndLabels.push( + + ); + } + return inputsAndLabels; + }; + + return ( +
+
{ + this.ratingFieldSet = c; + }} + > + {label ? {label} : null} +
{createStarRating(5)}
+
+ +
+ ); + } +} + +FieldStarRatingComponent.defaultProps = { + rootClassName: null, + className: null, + clearOnUnmount: false, + customErrorText: null, + label: null, +}; + +const { string, bool, shape, func, object } = PropTypes; + +FieldStarRatingComponent.propTypes = { + rootClassName: string, + className: string, + clearOnUnmount: bool, + id: string.isRequired, + label: string, + + // Error message that can be manually passed to input field, + // overrides default validation message + customErrorText: string, + + // Generated by redux-form's Field component + input: shape({ + onChange: func.isRequired, + }).isRequired, + meta: object.isRequired, + + // from injectIntl + intl: intlShape.isRequired, +}; + +const FieldStarRating = props => { + return ; +}; + +export default injectIntl(FieldStarRating); diff --git a/src/components/index.js b/src/components/index.js index 7cd94ac0..0f73a6ad 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -27,6 +27,7 @@ export { default as EditListingWizard } from './EditListingWizard/EditListingWiz export { default as ExpandingTextarea } from './ExpandingTextarea/ExpandingTextarea'; export { default as ExternalLink } from './ExternalLink/ExternalLink'; export { default as FilterPanel } from './FilterPanel/FilterPanel'; +export { default as FieldStarRating } from './FieldStarRating/FieldStarRating'; export { default as Footer } from './Footer/Footer'; export { default as Form } from './Form/Form'; export { default as IconBannedUser } from './IconBannedUser/IconBannedUser'; diff --git a/src/examples.js b/src/examples.js index 8bd4ca94..a9a15c4e 100644 --- a/src/examples.js +++ b/src/examples.js @@ -10,6 +10,7 @@ import * as DateInputField from './components/DateInputField/DateInputField.exam import * as DateRangeInputField from './components/DateRangeInputField/DateRangeInputField.example'; import * as EditListingWizard from './components/EditListingWizard/EditListingWizard.example'; import * as ExpandingTextarea from './components/ExpandingTextarea/ExpandingTextarea.example'; +import * as FieldStarRating from './components/FieldStarRating/FieldStarRating.example'; import * as Footer from './components/Footer/Footer.example'; import * as IconBannedUser from './components/IconBannedUser/IconBannedUser.example'; import * as IconCheckmark from './components/IconCheckmark/IconCheckmark.example'; @@ -82,6 +83,7 @@ export { EditListingWizard, EmailVerificationForm, ExpandingTextarea, + FieldStarRating, Footer, IconBannedUser, IconCheckmark, diff --git a/src/marketplaceIndex.css b/src/marketplaceIndex.css index f4ea3fd1..3f0be487 100644 --- a/src/marketplaceIndex.css +++ b/src/marketplaceIndex.css @@ -71,6 +71,7 @@ ul { padding: 0; } +legend, label { @apply --marketplaceH4FontStyles; font-weight: var(--fontWeightSemiBold); diff --git a/src/translations/en.json b/src/translations/en.json index f3d08fa3..d152545d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -169,6 +169,11 @@ "EmailVerificationForm.verifying": "Verifying…", "EmailVerificationPage.loadingUserInformation": "Loading user information…", "EmailVerificationPage.title": "Verify your email address", + "FieldStarRating.star1": "Bad experience - 1 star", + "FieldStarRating.star2": "Not so nice - 2 stars", + "FieldStarRating.star3": "OK - 3 stars", + "FieldStarRating.star4": "Good - 4 stars", + "FieldStarRating.star5": "Awesome - 5 stars", "Footer.copyright": "© Sharetribe 2017", "Footer.goToFacebook": "Go to Facebook page", "Footer.goToInstagram": "Go to Instagram page", From e444bf02e72812e5970bb0a9b147d28e946b0db7 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 18:52:41 +0200 Subject: [PATCH 03/14] ReviewForm --- src/containers/ReviewForm/ReviewForm.css | 53 +++++++++ .../ReviewForm/ReviewForm.example.js | 14 +++ src/containers/ReviewForm/ReviewForm.js | 111 ++++++++++++++++++ src/containers/index.js | 1 + src/examples.js | 2 + src/translations/en.json | 11 ++ 6 files changed, 192 insertions(+) create mode 100644 src/containers/ReviewForm/ReviewForm.css create mode 100644 src/containers/ReviewForm/ReviewForm.example.js create mode 100644 src/containers/ReviewForm/ReviewForm.js diff --git a/src/containers/ReviewForm/ReviewForm.css b/src/containers/ReviewForm/ReviewForm.css new file mode 100644 index 00000000..c37173ca --- /dev/null +++ b/src/containers/ReviewForm/ReviewForm.css @@ -0,0 +1,53 @@ +@import '../../marketplace.css'; + +.root { + display: flex; + flex-direction: column; + + /* Layout: size and positioning */ + width: 100%; + height: auto; + margin-top: 24px; +} + +.error { + color: var(--failColor); +} + +.errorPlaceholder { + @media (--viewportMedium) { + width: 100%; + height: 32px; + } +} + +.reviewRating { + margin-bottom: 18px; + + @media (--viewportMedium) { + margin-bottom: 16px; + } +} + +.reviewContent { + flex-shrink: 0; + margin-top: 24px; + margin-bottom: 24px; + + @media (--viewportMedium) { + margin-top: 30px; + } +} + +.submitButton { + margin-top: auto; + margin-bottom: 24px; + flex-shrink: 0; + + @media (--viewportMedium) { + display: inline-block; + width: 100%; + margin-top: 36px; + margin-bottom: 0; + } +} diff --git a/src/containers/ReviewForm/ReviewForm.example.js b/src/containers/ReviewForm/ReviewForm.example.js new file mode 100644 index 00000000..d5f7660a --- /dev/null +++ b/src/containers/ReviewForm/ReviewForm.example.js @@ -0,0 +1,14 @@ +/* eslint-disable no-console */ +import ReviewForm from './ReviewForm'; + +export const Empty = { + component: ReviewForm, + props: { + onSubmit: values => { + console.log('Submit ReviewForm with (unformatted) values:', values); + }, + reviewSent: false, + sendReviewInProgress: false, + }, + group: 'forms', +}; diff --git a/src/containers/ReviewForm/ReviewForm.js b/src/containers/ReviewForm/ReviewForm.js new file mode 100644 index 00000000..ea33ed66 --- /dev/null +++ b/src/containers/ReviewForm/ReviewForm.js @@ -0,0 +1,111 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { reduxForm, propTypes as formPropTypes } from 'redux-form'; +import { intlShape, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { isTransactionsTransitionAlreadyReviewed } from '../../util/errors'; +import * as propTypes from '../../util/propTypes'; +import { required } from '../../util/validators'; +import { FieldStarRating, Form, PrimaryButton, TextInputField } from '../../components'; + +import css from './ReviewForm.css'; + +const ReviewFormComponent = props => { + const { + className, + disabled, + handleSubmit, + intl, + form, + invalid, + submitting, + reviewSent, + sendReviewError, + sendReviewInProgress, + } = props; + + const reviewRating = intl.formatMessage({ id: 'ReviewForm.reviewRatingLabel' }); + const reviewRatingRequiredMessage = intl.formatMessage({ + id: 'ReviewForm.reviewRatingRequired', + }); + + const reviewContent = intl.formatMessage({ id: 'ReviewForm.reviewContentLabel' }); + const reviewContentPlaceholderMessage = intl.formatMessage({ + id: 'ReviewForm.reviewContentPlaceholder', + }); + const reviewContentRequiredMessage = intl.formatMessage({ + id: 'ReviewForm.reviewContentRequired', + }); + + const errorMessage = + sendReviewError && isTransactionsTransitionAlreadyReviewed(sendReviewError) ? ( +

+ +

+ ) : ( +

+ +

+ ); + const errorArea = sendReviewError ? errorMessage :

; + + const reviewSubmitMessage = intl.formatMessage({ + id: 'ReviewForm.reviewSubmit', + }); + + const classes = classNames(css.root, className); + const submitInProgress = submitting || sendReviewInProgress; + const submitDisabled = invalid || disabled || submitInProgress; + + return ( +

+ + + + + {errorArea} + + {reviewSubmitMessage} + + + ); +}; + +ReviewFormComponent.defaultProps = { className: null, sendReviewError: null }; + +const { bool, func, string } = PropTypes; + +ReviewFormComponent.propTypes = { + ...formPropTypes, + className: string, + intl: intlShape.isRequired, + onSubmit: func.isRequired, + reviewSent: bool.isRequired, + sendReviewError: propTypes.error, + sendReviewInProgress: bool.isRequired, +}; + +const formName = 'ReviewForm'; + +export default compose(reduxForm({ form: formName }), injectIntl)(ReviewFormComponent); diff --git a/src/containers/index.js b/src/containers/index.js index 07d94cbf..b2707ce1 100644 --- a/src/containers/index.js +++ b/src/containers/index.js @@ -35,6 +35,7 @@ export { default as PrivacyPolicyPage } from './PrivacyPolicyPage/PrivacyPolicyP export { default as ProfilePage } from './ProfilePage/ProfilePage'; export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm'; export { default as ProfileSettingsPage } from './ProfileSettingsPage/ProfileSettingsPage'; +export { default as ReviewForm } from './ReviewForm/ReviewForm'; export { default as SalePage } from './SalePage/SalePage'; export { default as SearchPage } from './SearchPage/SearchPage'; export { default as SendMessageForm } from './SendMessageForm/SendMessageForm'; diff --git a/src/examples.js b/src/examples.js index a9a15c4e..2a3b3452 100644 --- a/src/examples.js +++ b/src/examples.js @@ -59,6 +59,7 @@ import * as LoginForm from './containers/LoginForm/LoginForm.example'; import * as PasswordRecoveryForm from './containers/PasswordRecoveryForm/PasswordRecoveryForm.example'; import * as PasswordResetForm from './containers/PasswordResetForm/PasswordResetForm.example'; import * as PayoutDetailsForm from './containers/PayoutDetailsForm/PayoutDetailsForm.example'; +import * as ReviewForm from './containers/ReviewForm/ReviewForm.example'; import * as SendMessageForm from './containers/SendMessageForm/SendMessageForm.example'; import * as SignupForm from './containers/SignupForm/SignupForm.example'; import * as StripePaymentForm from './containers/StripePaymentForm/StripePaymentForm.example'; @@ -116,6 +117,7 @@ export { PayoutDetailsForm, ResponsiveImage, ReviewRating, + ReviewForm, SelectField, SendMessageForm, SignupForm, diff --git a/src/translations/en.json b/src/translations/en.json index d152545d..e7eba216 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -442,6 +442,17 @@ "ProfileSettingsPage.title": "Profile settings", "ProfileSettingsPage.viewProfileLink": "View your profile", "ResponsiveImage.noImage": "No image", + "ReviewForm.reviewContentLabel": "Leave a review", + "ReviewForm.reviewContentPlaceholder": "Descripe your experience...", + "ReviewForm.reviewContentRequired": "Review message is required", + "ReviewForm.reviewRatingLabel": "Rate your experience", + "ReviewForm.reviewRatingRequired": "Review message is required", + "ReviewForm.reviewSubmit": "Publish review", + "ReviewForm.reviewSubmitAlreadySent": "Review already sent. Please refresh the page.", + "ReviewForm.reviewSubmitFailed": "Failed to sent a review. Please try again.", + "ReviewModal.description": "Reviews are an important part of the Saunatime community. Please share what went well and what could have been improved.", + "ReviewModal.later": "Later", + "ReviewModal.title": "Leave a review for {revieweeName}", "SaleDetailsPanel.acceptSaleFailed": "Oops, accepting failed. Please try again.", "SaleDetailsPanel.activityHeading": "Activity", "SaleDetailsPanel.bannedUserDisplayName": "Banned user", From 04775c80a3a22ce371aa7eeb522e04140199e976 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 19:02:45 +0200 Subject: [PATCH 04/14] New modal: ReviewModal --- src/components/Modal/Modal.css | 3 + src/components/ReviewModal/ReviewModal.css | 29 +++++++++ src/components/ReviewModal/ReviewModal.js | 76 ++++++++++++++++++++++ src/components/index.js | 1 + 4 files changed, 109 insertions(+) create mode 100644 src/components/ReviewModal/ReviewModal.css create mode 100644 src/components/ReviewModal/ReviewModal.js diff --git a/src/components/Modal/Modal.css b/src/components/Modal/Modal.css index 74e62042..45d6a9be 100644 --- a/src/components/Modal/Modal.css +++ b/src/components/Modal/Modal.css @@ -44,6 +44,9 @@ } } +.content { +} + /* Content is explicitly hidden (this default can be overridden with passed-in class) */ /* The use case for having both .isOpen and .isClosed is ModalInMobile use case */ /* where desktop layout should not get any styling from Modal component. */ diff --git a/src/components/ReviewModal/ReviewModal.css b/src/components/ReviewModal/ReviewModal.css new file mode 100644 index 00000000..30fa5059 --- /dev/null +++ b/src/components/ReviewModal/ReviewModal.css @@ -0,0 +1,29 @@ +@import '../../marketplace.css'; + +.root { + @apply --marketplaceModalBaseStyles; + padding-top: 70px; + + @media (--viewportMedium) { + flex-basis: 567px; + } +} + +.modalContent { + flex-grow: 1; +} + +/* Icon of the modal */ +.modalIcon { + @apply --marketplaceModalIconStyles; +} + +/* Title of the modal */ +.modalTitle { + @apply --marketplaceModalTitleStyles; +} + +/* Paragraph for the Modal */ +.modalMessage { + @apply --marketplaceModalParagraphStyles; +} diff --git a/src/components/ReviewModal/ReviewModal.js b/src/components/ReviewModal/ReviewModal.js new file mode 100644 index 00000000..1632bdb8 --- /dev/null +++ b/src/components/ReviewModal/ReviewModal.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, intlShape, injectIntl } from 'react-intl'; +import classNames from 'classnames'; +import * as propTypes from '../../util/propTypes'; +import { IconReviewUser, Modal } from '../../components'; +import { ReviewForm } from '../../containers'; + +import css from './ReviewModal.css'; + +const ReviewModal = props => { + const { + className, + rootClassName, + id, + intl, + isOpen, + onCloseModal, + onManageDisableScrolling, + onSubmitReview, + revieweeName, + reviewSent, + sendReviewInProgress, + sendReviewError, + } = props; + + const classes = classNames(rootClassName || css.root, className); + const closeButtonMessage = intl.formatMessage({ id: 'ReviewModal.later' }); + + return ( + + +

+ +

+

+ +

+ +
+ ); +}; + +const { bool, string } = PropTypes; + +ReviewModal.defaultProps = { + className: null, + rootClassName: null, + reviewSent: false, + sendReviewInProgress: false, + sendReviewError: null, +}; + +ReviewModal.propTypes = { + className: string, + rootClassName: string, + intl: intlShape.isRequired, + reviewSent: bool, + sendReviewInProgress: bool, + sendReviewError: propTypes.error, +}; + +export default injectIntl(ReviewModal); diff --git a/src/components/index.js b/src/components/index.js index 0f73a6ad..012df6c2 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -83,6 +83,7 @@ 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 ReviewModal } from './ReviewModal/ReviewModal'; export { default as SaleDetailsPanel } from './SaleDetailsPanel/SaleDetailsPanel'; export { default as SearchMap } from './SearchMap/SearchMap'; export { default as SearchMapGroupLabel } from './SearchMapGroupLabel/SearchMapGroupLabel'; From e75fe995f083d5389b1ad83e86f9042bf7275f8d Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 19:05:23 +0200 Subject: [PATCH 05/14] Selector: getTransactionsById (similar as getListingsById) --- src/ducks/marketplaceData.duck.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ducks/marketplaceData.duck.js b/src/ducks/marketplaceData.duck.js index e15919e0..83cf3632 100644 --- a/src/ducks/marketplaceData.duck.js +++ b/src/ducks/marketplaceData.duck.js @@ -68,6 +68,17 @@ export const getMarketplaceEntities = (state, entityRefs) => { } }; +/** + * Get the denormalised transaction entities with the given IDs + * + * @param {Object} state the full Redux store + * @param {Array} transactionIds transaction IDs to select from the store + */ +export const getTransactionsById = (state, transactionIds) => { + const entityRefs = transactionIds.map(id => ({ id, type: 'transaction' })); + return getMarketplaceEntities(state, entityRefs); +}; + // ================ Action creators ================ // export const addMarketplaceEntities = apiResponse => ({ From 345909611f68506890eff61a818e028817ce94e5 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 19:07:58 +0200 Subject: [PATCH 06/14] Error check for invalid transition: isTransactionsTransitionInvalidTransition --- src/util/errors.js | 22 ++++++++++++++++++++++ src/util/propTypes.js | 8 ++++++++ 2 files changed, 30 insertions(+) diff --git a/src/util/errors.js b/src/util/errors.js index 44f23633..a17ce45a 100644 --- a/src/util/errors.js +++ b/src/util/errors.js @@ -11,6 +11,9 @@ import { ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND, + ERROR_CODE_TRANSACTION_INVALID_TRANSITION, + ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER, + ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER, ERROR_CODE_PAYMENT_FAILED, ERROR_CODE_EMAIL_TAKEN, ERROR_CODE_EMAIL_NOT_FOUND, @@ -123,6 +126,25 @@ export const isTransactionInitiateAmountTooLowError = error => { }); }; +/** + * Check if the given API error (from `sdk.transactions.transition(id, transition, params)`) + * is due to invalid transition attempt. + */ +export const isTransactionsTransitionInvalidTransition = error => + error && + error.status === 409 && + hasErrorWithCode(error, ERROR_CODE_TRANSACTION_INVALID_TRANSITION); + +/** + * Check if the given API error (from `sdk.transactions.transition(id, transition, params)`) + * is due to already sent review. + */ +export const isTransactionsTransitionAlreadyReviewed = error => + error && + error.status === 409 && + (hasErrorWithCode(error, ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER) || + hasErrorWithCode(error, ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER)); + /** * Check if the given API error (from `sdk.currentUser.changeEmail(params)`) * is due to giving wrong password. diff --git a/src/util/propTypes.js b/src/util/propTypes.js index 12e3cb4c..1eb451af 100644 --- a/src/util/propTypes.js +++ b/src/util/propTypes.js @@ -312,6 +312,11 @@ export const pagination = shape({ }); export const ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND = 'transaction-listing-not-found'; +export const ERROR_CODE_TRANSACTION_INVALID_TRANSITION = 'transaction-invalid-transition'; +export const ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER = + 'transaction-already-reviewed-by-customer'; +export const ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER = + 'transaction-already-reviewed-by-provider'; export const ERROR_CODE_PAYMENT_FAILED = 'transaction-payment-failed'; export const ERROR_CODE_EMAIL_TAKEN = 'email-taken'; export const ERROR_CODE_EMAIL_NOT_FOUND = 'email-not-found'; @@ -320,6 +325,9 @@ export const ERROR_CODE_TOO_MANY_VERIFICATION_REQUESTS = 'email-too-many-verific export const ERROR_CODE_UPLOAD_OVER_LIMIT = 'request-upload-over-limit'; const ERROR_CODES = [ ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND, + ERROR_CODE_TRANSACTION_INVALID_TRANSITION, + ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER, + ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER, ERROR_CODE_PAYMENT_FAILED, ERROR_CODE_EMAIL_TAKEN, ERROR_CODE_EMAIL_NOT_FOUND, From e18cf620ac6f9b5d404240cfda5bc8d8175531aa Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 28 Nov 2017 19:11:36 +0200 Subject: [PATCH 07/14] TX transitions for review states --- src/util/propTypes.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/util/propTypes.js b/src/util/propTypes.js index 1eb451af..b7144b8f 100644 --- a/src/util/propTypes.js +++ b/src/util/propTypes.js @@ -164,6 +164,8 @@ export const TX_TRANSITION_CANCEL = 'transition/cancel'; export const TX_TRANSITION_MARK_DELIVERED = 'transition/mark-delivered'; // Review transitions +// Reviews are given through transaction transitions. +// Either party (provider or customer) can be the first to give a review. 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'; From 7c8345eec6220de718d85e4fbd563005607a6bc4 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 29 Nov 2017 15:08:06 +0200 Subject: [PATCH 08/14] Defaults for TextInputField --- src/components/TextInputField/TextInputField.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/TextInputField/TextInputField.js b/src/components/TextInputField/TextInputField.js index 45aeeb84..4e8f0ed2 100644 --- a/src/components/TextInputField/TextInputField.js +++ b/src/components/TextInputField/TextInputField.js @@ -6,6 +6,8 @@ import { ValidationError, ExpandingTextarea } from '../../components'; import css from './TextInputField.css'; +const CONTENT_MAX_LENGTH = 5000; + class TextInputFieldComponent extends Component { componentWillUnmount() { if (this.props.clearOnUnmount) { @@ -52,7 +54,7 @@ class TextInputFieldComponent extends Component { [css.textarea]: isTextarea, }); const inputProps = isTextarea - ? { className: inputClasses, id, ...input, ...rest } + ? { className: inputClasses, id, rows: 1, maxLength: CONTENT_MAX_LENGTH, ...input, ...rest } : { className: inputClasses, id, type, ...input, ...rest }; const classes = classNames(rootClassName || css.root, className); From e954e3271dadb473bd4f03a542e2b80d20c9dec5 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 29 Nov 2017 15:12:10 +0200 Subject: [PATCH 09/14] OrderPage can show review modal (and related changes) --- .../OrderDetailsPanel/OrderDetailsPanel.js | 48 +++++++- .../OrderDetailsPanel.test.js | 4 + .../OrderDetailsPanel.test.js.snap | 72 +++++++++++ src/containers/OrderPage/OrderPage.duck.js | 114 +++++++++++++++--- src/containers/OrderPage/OrderPage.js | 26 +++- 5 files changed, 242 insertions(+), 22 deletions(-) diff --git a/src/components/OrderDetailsPanel/OrderDetailsPanel.js b/src/components/OrderDetailsPanel/OrderDetailsPanel.js index 6d5ce15b..5ba09ee4 100644 --- a/src/components/OrderDetailsPanel/OrderDetailsPanel.js +++ b/src/components/OrderDetailsPanel/OrderDetailsPanel.js @@ -7,11 +7,12 @@ import { createSlug } from '../../util/urlHelpers'; import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data'; import { isMobileSafari } from '../../util/userAgent'; import { + ActivityFeed, + AvatarMedium, BookingBreakdown, NamedLink, ResponsiveImage, - AvatarMedium, - ActivityFeed, + ReviewModal, } from '../../components'; import { SendMessageForm } from '../../containers'; @@ -82,8 +83,29 @@ const orderMessage = (transaction, providerName) => { export class OrderDetailsPanelComponent extends Component { constructor(props) { super(props); - this.state = { sendMessageFormFocused: false }; + this.state = { + sendMessageFormFocused: false, + isReviewModalOpen: false, + reviewSubmitted: false, + }; + this.onOpenReviewModal = this.onOpenReviewModal.bind(this); + this.onSubmitReview = this.onSubmitReview.bind(this); } + + onOpenReviewModal() { + this.setState({ isReviewModalOpen: true }); + } + + onSubmitReview(values) { + const { onSendReview, transaction } = this.props; + const currentTransaction = ensureTransaction(transaction); + const { reviewRating, reviewContent } = values; + const rating = Number.parseInt(reviewRating, 10); + onSendReview(currentTransaction.id, rating, reviewContent).then(r => + this.setState({ isReviewModalOpen: false, reviewSubmitted: true }) + ); + } + render() { const { rootClassName, @@ -97,6 +119,9 @@ export class OrderDetailsPanelComponent extends Component { fetchMessagesError, sendMessageInProgress, sendMessageError, + sendReviewInProgress, + sendReviewError, + onManageDisableScrolling, onShowMoreMessages, onSendMessage, onResetForm, @@ -200,6 +225,7 @@ export class OrderDetailsPanelComponent extends Component { transaction={currentTransaction} currentUser={currentUser} hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress} + onOpenReviewModal={this.onOpenReviewModal} onShowOlderMessages={handleShowOlderMessages} fetchMessagesInProgress={fetchMessagesInProgress} /> @@ -330,6 +356,17 @@ export class OrderDetailsPanelComponent extends Component { + this.setState({ isReviewModalOpen: false })} + onManageDisableScrolling={onManageDisableScrolling} + onSubmitReview={this.onSubmitReview} + revieweeName={authorDisplayName} + reviewSent={this.state.reviewSubmitted} + sendReviewInProgress={sendReviewInProgress} + sendReviewError={sendReviewError} + /> ); } @@ -341,6 +378,7 @@ OrderDetailsPanelComponent.defaultProps = { currentUser: null, fetchMessagesError: null, sendMessageError: null, + sendReviewError: null, }; const { string, arrayOf, bool, func, number } = PropTypes; @@ -358,8 +396,12 @@ OrderDetailsPanelComponent.propTypes = { fetchMessagesError: propTypes.error, sendMessageInProgress: bool.isRequired, sendMessageError: propTypes.error, + sendReviewInProgress: bool.isRequired, + sendReviewError: propTypes.error, + onManageDisableScrolling: func.isRequired, onShowMoreMessages: func.isRequired, onSendMessage: func.isRequired, + onSendReview: func.isRequired, onResetForm: func.isRequired, // from injectIntl diff --git a/src/components/OrderDetailsPanel/OrderDetailsPanel.test.js b/src/components/OrderDetailsPanel/OrderDetailsPanel.test.js index bdb66b16..df68e3c5 100644 --- a/src/components/OrderDetailsPanel/OrderDetailsPanel.test.js +++ b/src/components/OrderDetailsPanel/OrderDetailsPanel.test.js @@ -78,8 +78,12 @@ describe('OrderDetailsPanel', () => { initialMessageFailed: false, fetchMessagesInProgress: false, sendMessageInProgress: false, + sendReviewInProgress: false, + onManageDisableScrolling: noop, + onOpenReviewModal: noop, onShowMoreMessages: noop, onSendMessage: noop, + onSendReview: noop, onResetForm: noop, }; diff --git a/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap b/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap index 71cf60de..241719cc 100644 --- a/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap +++ b/src/components/OrderDetailsPanel/__snapshots__/OrderDetailsPanel.test.js.snap @@ -310,6 +310,7 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -644,6 +645,17 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = ` + `; @@ -945,6 +957,7 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -1279,6 +1292,17 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = ` + `; @@ -1592,6 +1616,7 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -1926,6 +1951,17 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = ` + `; @@ -2227,6 +2263,7 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -2561,6 +2598,17 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = ` + `; @@ -2862,6 +2910,7 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -3196,6 +3245,17 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = ` + `; @@ -3520,6 +3580,7 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -3854,5 +3915,16 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = ` + `; diff --git a/src/containers/OrderPage/OrderPage.duck.js b/src/containers/OrderPage/OrderPage.duck.js index 651091f1..3d5d23c0 100644 --- a/src/containers/OrderPage/OrderPage.duck.js +++ b/src/containers/OrderPage/OrderPage.duck.js @@ -1,7 +1,8 @@ import { pick } from 'lodash'; import { types } from '../../util/sdkLoader'; -import { storableError } from '../../util/errors'; -import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; +import { isTransactionsTransitionInvalidTransition, storableError } from '../../util/errors'; +import * as propTypes from '../../util/propTypes'; +import { addMarketplaceEntities, getTransactionsById } from '../../ducks/marketplaceData.duck'; import { updatedEntities, denormalisedEntities } from '../../util/data'; const MESSAGES_PAGE_SIZE = 100; @@ -22,6 +23,10 @@ export const SEND_MESSAGE_REQUEST = 'app/OrderPage/SEND_MESSAGE_REQUEST'; export const SEND_MESSAGE_SUCCESS = 'app/OrderPage/SEND_MESSAGE_SUCCESS'; export const SEND_MESSAGE_ERROR = 'app/OrderPage/SEND_MESSAGE_ERROR'; +export const SEND_REVIEW_REQUEST = 'app/OrderPage/SEND_REVIEW_REQUEST'; +export const SEND_REVIEW_SUCCESS = 'app/OrderPage/SEND_REVIEW_SUCCESS'; +export const SEND_REVIEW_ERROR = 'app/OrderPage/SEND_REVIEW_ERROR'; + // ================ Reducer ================ // const initialState = { @@ -35,6 +40,8 @@ const initialState = { messageSendingFailedToTransaction: null, sendMessageInProgress: false, sendMessageError: null, + sendReviewInProgress: false, + sendReviewError: null, }; export default function checkoutPageReducer(state = initialState, action = {}) { @@ -72,6 +79,13 @@ export default function checkoutPageReducer(state = initialState, action = {}) { case SEND_MESSAGE_ERROR: return { ...state, sendMessageInProgress: false, sendMessageError: payload }; + case SEND_REVIEW_REQUEST: + return { ...state, sendReviewInProgress: true, sendReviewError: null }; + case SEND_REVIEW_SUCCESS: + return { ...state, sendReviewInProgress: false }; + case SEND_REVIEW_ERROR: + return { ...state, sendReviewInProgress: false, sendReviewError: payload }; + default: return state; } @@ -105,6 +119,10 @@ const sendMessageRequest = () => ({ type: SEND_MESSAGE_REQUEST }); const sendMessageSuccess = () => ({ type: SEND_MESSAGE_SUCCESS }); const sendMessageError = e => ({ type: SEND_MESSAGE_ERROR, error: true, payload: e }); +const sendReviewRequest = () => ({ type: SEND_REVIEW_REQUEST }); +const sendReviewSuccess = () => ({ type: SEND_REVIEW_SUCCESS }); +const sendReviewError = e => ({ type: SEND_REVIEW_ERROR, error: true, payload: e }); + // ================ Thunks ================ // const listingRelationship = txResponse => { @@ -200,21 +218,6 @@ export const fetchMoreMessages = txId => (dispatch, getState, sdk) => { return dispatch(fetchNLatestMessages(txId, messagesToFetch)); }; -// loadData is a collection of async calls that need to be made -// before page has all the info it needs to render itself -export const loadData = params => dispatch => { - const orderId = new types.UUID(params.id); - - // Clear the send error since the message form is emptied as well. - dispatch(setInitialValues({ sendMessageError: null })); - - // Order (i.e. transaction entity in API, but from buyers perspective) contains order details - return Promise.all([ - dispatch(fetchOrder(orderId)), - dispatch(fetchNLatestMessages(orderId, MESSAGES_PAGE_SIZE)), - ]); -}; - export const sendMessage = (orderId, message) => (dispatch, getState, sdk) => { dispatch(sendMessageRequest()); @@ -243,3 +246,80 @@ export const sendMessage = (orderId, message) => (dispatch, getState, sdk) => { throw e; }); }; + +// If other party (provider) has already sent a review, we need to make transition to +// TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND +const sendReviewAsSecond = (id, params, dispatch, sdk) => { + const transition = propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND; + return sdk.transactions + .transition({ id, transition, params }, { expand: true }) + .then(response => { + dispatch(addMarketplaceEntities(response)); + dispatch(sendReviewSuccess()); + return response; + }) + .catch(e => { + dispatch(sendReviewError(storableError(e))); + + // Rethrow so the page can track whether the sending failed, and + // keep the message in the form for a retry. + throw e; + }); +}; + +// If other party (provider) has not yet sent a review, we need to make transition to +// TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST +// However, the other party might have made the review after previous data synch point. +// So, error is likely to happen and then we must try another state transition +// by calling sendReviewAsSecond(). +const sendReviewAsFirst = (id, params, dispatch, sdk) => { + const transition = propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST; + return sdk.transactions + .transition({ id, transition, params }, { expand: true }) + .then(response => { + dispatch(addMarketplaceEntities(response)); + dispatch(sendReviewSuccess()); + return response; + }) + .catch(e => { + // If transaction transition is invalid, lets try another endpoint. + if (isTransactionsTransitionInvalidTransition(e)) { + sendReviewAsSecond(id, params, dispatch, sdk); + } else { + dispatch(sendReviewError(storableError(e))); + + // Rethrow so the page can track whether the sending failed, and + // keep the message in the form for a retry. + throw e; + } + }); +}; + +export const sendReview = (orderId, reviewRating, reviewContent) => (dispatch, getState, sdk) => { + const params = { reviewRating, reviewContent }; + const txs = getTransactionsById(getState(), [orderId]); + const txStateProviderFirst = + txs.length === 1 && + txs[0].attributes.lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST; + + dispatch(sendReviewRequest()); + + return txStateProviderFirst + ? sendReviewAsSecond(orderId, params, dispatch, sdk) + : sendReviewAsFirst(orderId, params, dispatch, sdk); +}; + +// loadData is a collection of async calls that need to be made +// before page has all the info it needs to render itself +export const loadData = params => dispatch => { + const orderId = new types.UUID(params.id); + + // Clear the send error since the message form is emptied as well. + dispatch(setInitialValues({ sendMessageError: null, sendReviewError: null })); + + // Order (i.e. transaction entity in API, but from buyers perspective) contains order details + return Promise.all([ + dispatch(fetchOrder(orderId)), + dispatch(fetchNLatestMessages(orderId, MESSAGES_PAGE_SIZE)), + ]); +}; diff --git a/src/containers/OrderPage/OrderPage.js b/src/containers/OrderPage/OrderPage.js index 747ff51e..d284ce14 100644 --- a/src/containers/OrderPage/OrderPage.js +++ b/src/containers/OrderPage/OrderPage.js @@ -8,7 +8,7 @@ import { FormattedMessage, intlShape, injectIntl } from 'react-intl'; import * as propTypes from '../../util/propTypes'; import { ensureListing, ensureTransaction } from '../../util/data'; import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck'; -import { isScrollingDisabled } from '../../ducks/UI.duck'; +import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck'; import { NamedRedirect, OrderDetailsPanel, @@ -21,7 +21,13 @@ import { } from '../../components'; import { TopbarContainer } from '../../containers'; -import { loadData, setInitialValues, sendMessage, fetchMoreMessages } from './OrderPage.duck'; +import { + loadData, + setInitialValues, + sendMessage, + sendReview, + fetchMoreMessages, +} from './OrderPage.duck'; import css from './OrderPage.css'; // OrderPage handles data loading @@ -37,8 +43,12 @@ export const OrderPageComponent = props => { messageSendingFailedToTransaction, sendMessageInProgress, sendMessageError, + sendReviewInProgress, + sendReviewError, + onManageDisableScrolling, onShowMoreMessages, onSendMessage, + onSendReview, onResetForm, intl, params, @@ -96,8 +106,12 @@ export const OrderPageComponent = props => { fetchMessagesError={fetchMessagesError} sendMessageInProgress={sendMessageInProgress} sendMessageError={sendMessageError} + sendReviewInProgress={sendReviewInProgress} + sendReviewError={sendReviewError} + onManageDisableScrolling={onManageDisableScrolling} onShowMoreMessages={onShowMoreMessages} onSendMessage={onSendMessage} + onSendReview={onSendReview} onResetForm={onResetForm} /> ) : ( @@ -168,6 +182,8 @@ const mapStateToProps = state => { messageSendingFailedToTransaction, sendMessageInProgress, sendMessageError, + sendReviewInProgress, + sendReviewError, } = state.OrderPage; const transactions = getMarketplaceEntities(state, transactionRef ? [transactionRef] : []); const transaction = transactions.length > 0 ? transactions[0] : null; @@ -182,14 +198,20 @@ const mapStateToProps = state => { messageSendingFailedToTransaction, sendMessageInProgress, sendMessageError, + sendReviewInProgress, + sendReviewError, scrollingDisabled: isScrollingDisabled(state), transaction, }; }; const mapDispatchToProps = dispatch => ({ + onManageDisableScrolling: (componentId, disableScrolling) => + dispatch(manageDisableScrolling(componentId, disableScrolling)), onShowMoreMessages: orderId => dispatch(fetchMoreMessages(orderId)), onSendMessage: (orderId, message) => dispatch(sendMessage(orderId, message)), + onSendReview: (orderId, reviewRating, reviewContent) => + dispatch(sendReview(orderId, reviewRating, reviewContent)), onResetForm: formName => dispatch(resetForm(formName)), }); From 888acbe1430c1d7070799572c09fcf4209816163 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 29 Nov 2017 15:12:30 +0200 Subject: [PATCH 10/14] SalePage can show review modal (and related changes) --- .../SaleDetailsPanel/SaleDetailsPanel.js | 48 ++++++- .../SaleDetailsPanel/SaleDetailsPanel.test.js | 4 + .../SaleDetailsPanel.test.js.snap | 72 ++++++++++ .../EditListingDescriptionForm.test.js.snap | 2 + src/containers/SalePage/SalePage.duck.js | 127 +++++++++++++++--- src/containers/SalePage/SalePage.js | 27 +++- 6 files changed, 254 insertions(+), 26 deletions(-) diff --git a/src/components/SaleDetailsPanel/SaleDetailsPanel.js b/src/components/SaleDetailsPanel/SaleDetailsPanel.js index 8b29437b..532e010b 100644 --- a/src/components/SaleDetailsPanel/SaleDetailsPanel.js +++ b/src/components/SaleDetailsPanel/SaleDetailsPanel.js @@ -7,14 +7,15 @@ import { createSlug } from '../../util/urlHelpers'; import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data'; import { isMobileSafari } from '../../util/userAgent'; import { + ActivityFeed, AvatarLarge, AvatarMedium, BookingBreakdown, NamedLink, - ResponsiveImage, PrimaryButton, + ResponsiveImage, + ReviewModal, SecondaryButton, - ActivityFeed, } from '../../components'; import { SendMessageForm } from '../../containers'; @@ -91,8 +92,29 @@ const saleInfoText = (transaction, customerName) => { export class SaleDetailsPanelComponent extends Component { constructor(props) { super(props); - this.state = { sendMessageFormFocused: false }; + this.state = { + sendMessageFormFocused: false, + isReviewModalOpen: false, + reviewSubmitted: false, + }; + this.onOpenReviewModal = this.onOpenReviewModal.bind(this); + this.onSubmitReview = this.onSubmitReview.bind(this); } + + onOpenReviewModal() { + this.setState({ isReviewModalOpen: true }); + } + + onSubmitReview(values) { + const { onSendReview, transaction } = this.props; + const currentTransaction = ensureTransaction(transaction); + const { reviewRating, reviewContent } = values; + const rating = Number.parseInt(reviewRating, 10); + onSendReview(currentTransaction.id, rating, reviewContent).then(r => + this.setState({ isReviewModalOpen: false, reviewSubmitted: true }) + ); + } + render() { const { rootClassName, @@ -111,6 +133,9 @@ export class SaleDetailsPanelComponent extends Component { messages, sendMessageInProgress, sendMessageError, + sendReviewInProgress, + sendReviewError, + onManageDisableScrolling, onShowMoreMessages, onSendMessage, onResetForm, @@ -196,6 +221,7 @@ export class SaleDetailsPanelComponent extends Component { transaction={currentTransaction} currentUser={currentUser} hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress} + onOpenReviewModal={this.onOpenReviewModal} onShowOlderMessages={handleShowOlderMessages} fetchMessagesInProgress={fetchMessagesInProgress} /> @@ -361,6 +387,17 @@ export class SaleDetailsPanelComponent extends Component { + this.setState({ isReviewModalOpen: false })} + onManageDisableScrolling={onManageDisableScrolling} + onSubmitReview={this.onSubmitReview} + revieweeName={customerDisplayName} + reviewSent={this.state.reviewSubmitted} + sendReviewInProgress={sendReviewInProgress} + sendReviewError={sendReviewError} + /> ); } @@ -374,6 +411,7 @@ SaleDetailsPanelComponent.defaultProps = { declineSaleError: null, fetchMessagesError: null, sendMessageError: null, + sendReviewError: null, }; const { bool, func, string, arrayOf, number } = PropTypes; @@ -396,8 +434,12 @@ SaleDetailsPanelComponent.propTypes = { messages: arrayOf(propTypes.message).isRequired, sendMessageInProgress: bool.isRequired, sendMessageError: propTypes.error, + sendReviewInProgress: bool.isRequired, + sendReviewError: propTypes.error, + onManageDisableScrolling: func.isRequired, onShowMoreMessages: func.isRequired, onSendMessage: func.isRequired, + onSendReview: func.isRequired, onResetForm: func.isRequired, // from injectIntl diff --git a/src/components/SaleDetailsPanel/SaleDetailsPanel.test.js b/src/components/SaleDetailsPanel/SaleDetailsPanel.test.js index b5a99760..1f1fc568 100644 --- a/src/components/SaleDetailsPanel/SaleDetailsPanel.test.js +++ b/src/components/SaleDetailsPanel/SaleDetailsPanel.test.js @@ -80,8 +80,12 @@ describe('SaleDetailsPanel', () => { ], fetchMessagesInProgress: false, sendMessageInProgress: false, + sendReviewInProgress: false, + onManageDisableScrolling: noop, + onOpenReviewModal: noop, onShowMoreMessages: noop, onSendMessage: noop, + onSendReview: noop, onResetForm: noop, intl: fakeIntl, }; diff --git a/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap b/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap index 020ddbac..f906332e 100644 --- a/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap +++ b/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap @@ -304,6 +304,7 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -584,6 +585,17 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = ` + `; @@ -891,6 +903,7 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -1171,6 +1184,17 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = ` + `; @@ -1478,6 +1502,7 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -1758,6 +1783,17 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = ` + `; @@ -2065,6 +2101,7 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -2345,6 +2382,17 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = ` + `; @@ -2652,6 +2700,7 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -2932,6 +2981,17 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = ` + `; @@ -3249,6 +3309,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = ` }, ] } + onOpenReviewModal={[Function]} onShowOlderMessages={[Function]} transaction={ Object { @@ -3585,5 +3646,16 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = ` + `; diff --git a/src/containers/EditListingDescriptionForm/__snapshots__/EditListingDescriptionForm.test.js.snap b/src/containers/EditListingDescriptionForm/__snapshots__/EditListingDescriptionForm.test.js.snap index 4145669d..3859ca0a 100644 --- a/src/containers/EditListingDescriptionForm/__snapshots__/EditListingDescriptionForm.test.js.snap +++ b/src/containers/EditListingDescriptionForm/__snapshots__/EditListingDescriptionForm.test.js.snap @@ -42,6 +42,7 @@ exports[`EditListingDescriptionForm matches snapshot 1`] = `