Allow users to edit their reviews (#3220)
This commit is contained in:
Родитель
66047c8b1c
Коммит
5a33c7458e
|
@ -4,6 +4,9 @@
|
|||
"CLIENT_CONFIG": true,
|
||||
"webpackIsomorphicTools": true,
|
||||
"ga": true,
|
||||
"HTMLElement": true,
|
||||
"HTMLInputElement": true,
|
||||
"Node": true,
|
||||
// See: https://github.com/facebook/flow/issues/1609
|
||||
"SyntheticEvent": true,
|
||||
},
|
||||
|
|
|
@ -245,7 +245,7 @@
|
|||
"intl": "^1.2.5",
|
||||
"intl-locales-supported": "^1.0.0",
|
||||
"jest": "^21.0.2",
|
||||
"jest-enzyme": "^3.2.0",
|
||||
"jest-enzyme": "^3.8.3",
|
||||
"json-loader": "^0.5.4",
|
||||
"mock-express-request": "^0.2.0",
|
||||
"mock-express-response": "^0.2.0",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* @flow */
|
||||
/* eslint-disable react/sort-comp */
|
||||
/* global $Shape, Event, HTMLInputElement, Node */
|
||||
/* global $Shape */
|
||||
import { oneLine } from 'common-tags';
|
||||
import defaultDebounce from 'lodash.debounce';
|
||||
import React from 'react';
|
||||
|
@ -15,6 +15,7 @@ import translate from 'core/i18n/translate';
|
|||
import defaultLocalStateCreator, { LocalState } from 'core/localState';
|
||||
import log from 'core/logger';
|
||||
import OverlayCard from 'ui/components/OverlayCard';
|
||||
import Rating from 'ui/components/Rating';
|
||||
import type { SetReviewAction, UserReviewType } from 'amo/actions/reviews';
|
||||
import type { SubmitReviewParams } from 'amo/api/index';
|
||||
import type { ApiStateType } from 'core/reducers/api';
|
||||
|
@ -22,7 +23,7 @@ import type { ErrorHandler as ErrorHandlerType } from 'core/errorHandler';
|
|||
import type { ElementEvent } from 'core/types/dom';
|
||||
import type { DispatchFunc } from 'core/types/redux';
|
||||
|
||||
import 'amo/css/AddonReview.scss';
|
||||
import './styles.scss';
|
||||
|
||||
type AddonReviewProps = {|
|
||||
apiState?: ApiStateType,
|
||||
|
@ -30,6 +31,7 @@ type AddonReviewProps = {|
|
|||
debounce: typeof defaultDebounce,
|
||||
errorHandler: ErrorHandlerType,
|
||||
i18n: Object,
|
||||
onEscapeOverlay?: () => void,
|
||||
onReviewSubmitted: () => void | Promise<void>,
|
||||
refreshAddon: () => Promise<void>,
|
||||
review: UserReviewType,
|
||||
|
@ -46,7 +48,7 @@ export class AddonReviewBase extends React.Component {
|
|||
props: AddonReviewProps;
|
||||
reviewForm: Node;
|
||||
reviewPrompt: Node;
|
||||
reviewTextarea: Node;
|
||||
reviewTextarea: HTMLElement;
|
||||
state: AddonReviewState;
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -70,6 +72,12 @@ export class AddonReviewBase extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.reviewTextarea) {
|
||||
this.reviewTextarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
checkForStoredState() {
|
||||
return this.localState.load()
|
||||
.then((storedState) => {
|
||||
|
@ -81,7 +89,7 @@ export class AddonReviewBase extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onSubmit = (event: Event) => {
|
||||
onSubmit = (event: SyntheticEvent) => {
|
||||
const { apiState, errorHandler, onReviewSubmitted, review } = this.props;
|
||||
const { reviewBody } = this.state;
|
||||
event.preventDefault();
|
||||
|
@ -94,6 +102,7 @@ export class AddonReviewBase extends React.Component {
|
|||
addonId: review.addonId,
|
||||
apiState,
|
||||
errorHandler,
|
||||
rating: review.rating,
|
||||
reviewId: review.id,
|
||||
...newReviewParams,
|
||||
};
|
||||
|
@ -132,6 +141,15 @@ export class AddonReviewBase extends React.Component {
|
|||
this.setState(newState);
|
||||
}
|
||||
|
||||
onSelectRating = (rating: number) => {
|
||||
// Update the review object with a new rating but don't submit it
|
||||
// to the API yet.
|
||||
this.props.setDenormalizedReview({
|
||||
...this.props.review,
|
||||
rating,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorHandler, i18n, review } = this.props;
|
||||
const { reviewBody } = this.state;
|
||||
|
@ -157,9 +175,18 @@ export class AddonReviewBase extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<OverlayCard visibleOnLoad className="AddonReview">
|
||||
<OverlayCard
|
||||
visibleOnLoad
|
||||
onEscapeOverlay={this.props.onEscapeOverlay}
|
||||
className="AddonReview"
|
||||
>
|
||||
<h2 className="AddonReview-header">{i18n.gettext('Write a review')}</h2>
|
||||
<p ref={(ref) => { this.reviewPrompt = ref; }}>{prompt}</p>
|
||||
<Rating
|
||||
styleName="large"
|
||||
rating={review.rating}
|
||||
onSelectRating={this.onSelectRating}
|
||||
/>
|
||||
<form onSubmit={this.onSubmit} ref={(ref) => { this.reviewForm = ref; }}>
|
||||
<div className="AddonReview-form-input">
|
||||
{errorHandler.renderErrorIfPresent()}
|
|
@ -1,8 +1,15 @@
|
|||
@import "~amo/css/inc/vars";
|
||||
@import "~core/css/inc/mixins";
|
||||
|
||||
.AddonReview .Card-contents {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
.Overlay-contents .AddonReview {
|
||||
.Card-contents {
|
||||
background: $base-color;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.Rating {
|
||||
margin: 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.AddonReview-form-input {
|
||||
|
@ -29,8 +36,12 @@
|
|||
}
|
||||
|
||||
.AddonReview-textarea {
|
||||
min-height: 200px;
|
||||
min-height: 125px;
|
||||
resize: vertical;
|
||||
|
||||
@include respond-to(medium) {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.AddonReview-submit {
|
|
@ -4,28 +4,30 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import { compose } from 'redux';
|
||||
|
||||
import Rating from 'ui/components/Rating';
|
||||
import AddonReviewListItem from 'amo/components/AddonReviewListItem';
|
||||
import { fetchReviews } from 'amo/actions/reviews';
|
||||
import { setViewContext } from 'amo/actions/viewContext';
|
||||
import { expandReviewObjects } from 'amo/reducers/reviews';
|
||||
import { fetchAddon } from 'core/reducers/addons';
|
||||
import Paginate from 'core/components/Paginate';
|
||||
import { withErrorHandler } from 'core/errorHandler';
|
||||
import translate from 'core/i18n/translate';
|
||||
import { findAddon, nl2br, parsePage, sanitizeHTML } from 'core/utils';
|
||||
import { findAddon, parsePage } from 'core/utils';
|
||||
import { getAddonIconUrl } from 'core/imageUtils';
|
||||
import log from 'core/logger';
|
||||
import Link from 'amo/components/Link';
|
||||
import CardList from 'ui/components/CardList';
|
||||
import NotFound from 'amo/components/ErrorPage/NotFound';
|
||||
import CardList from 'ui/components/CardList';
|
||||
import LoadingText from 'ui/components/LoadingText';
|
||||
import type { ErrorHandlerType } from 'core/errorHandler';
|
||||
import type { UserReviewType } from 'amo/actions/reviews';
|
||||
import type { ReviewState } from 'amo/reducers/reviews';
|
||||
import type { UserStateType } from 'core/reducers/user';
|
||||
import type { AddonType } from 'core/types/addons';
|
||||
import type { DispatchFunc } from 'core/types/redux';
|
||||
import type { ReactRouterLocation } from 'core/types/router';
|
||||
import LoadingText from 'ui/components/LoadingText';
|
||||
|
||||
import 'amo/css/AddonReviewList.scss';
|
||||
import './styles.scss';
|
||||
|
||||
type AddonReviewListProps = {|
|
||||
i18n: Object,
|
||||
|
@ -38,11 +40,6 @@ type AddonReviewListProps = {|
|
|||
reviews?: Array<UserReviewType>,
|
||||
|};
|
||||
|
||||
type RenderReviewParams = {|
|
||||
review?: UserReviewType,
|
||||
key: string,
|
||||
|};
|
||||
|
||||
export class AddonReviewListBase extends React.Component {
|
||||
props: AddonReviewListProps;
|
||||
|
||||
|
@ -55,6 +52,8 @@ export class AddonReviewListBase extends React.Component {
|
|||
}
|
||||
|
||||
loadDataIfNeeded(nextProps?: AddonReviewListProps) {
|
||||
const lastAddon = this.props.addon;
|
||||
const nextAddon = nextProps && nextProps.addon;
|
||||
const {
|
||||
addon, dispatch, errorHandler, params, reviews,
|
||||
} = {
|
||||
|
@ -69,7 +68,12 @@ export class AddonReviewListBase extends React.Component {
|
|||
|
||||
if (!addon) {
|
||||
dispatch(fetchAddon({ slug: params.addonSlug, errorHandler }));
|
||||
} else {
|
||||
} else if (
|
||||
// This is the first time rendering the component.
|
||||
!nextProps ||
|
||||
// The component is getting updated with a new addon type.
|
||||
(nextAddon && lastAddon && nextAddon.type !== lastAddon.type)
|
||||
) {
|
||||
dispatch(setViewContext(addon.type));
|
||||
}
|
||||
|
||||
|
@ -103,41 +107,6 @@ export class AddonReviewListBase extends React.Component {
|
|||
return `${this.addonURL()}reviews/`;
|
||||
}
|
||||
|
||||
renderReview({ review, key }: RenderReviewParams) {
|
||||
const { i18n } = this.props;
|
||||
|
||||
let byLine;
|
||||
let reviewBody;
|
||||
if (review) {
|
||||
const timestamp = i18n.moment(review.created).fromNow();
|
||||
// L10n: Example: "from Jose, last week"
|
||||
byLine = i18n.sprintf(
|
||||
i18n.gettext('from %(authorName)s, %(timestamp)s'),
|
||||
{ authorName: review.userName, timestamp });
|
||||
|
||||
const reviewBodySanitized = sanitizeHTML(nl2br(review.body), ['br']);
|
||||
// eslint-disable-next-line react/no-danger
|
||||
reviewBody = <p dangerouslySetInnerHTML={reviewBodySanitized} />;
|
||||
} else {
|
||||
byLine = <LoadingText />;
|
||||
reviewBody = <p><LoadingText /></p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="AddonReviewList-li" key={key}>
|
||||
<h3>{review ? review.title : <LoadingText />}</h3>
|
||||
{reviewBody}
|
||||
<div className="AddonReviewList-by-line">
|
||||
{review ?
|
||||
<Rating styleName="small" rating={review.rating} readOnly />
|
||||
: null
|
||||
}
|
||||
{byLine}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
addon, errorHandler, location, params, i18n, reviewCount, reviews,
|
||||
|
@ -204,7 +173,11 @@ export class AddonReviewListBase extends React.Component {
|
|||
<CardList>
|
||||
<ul>
|
||||
{allReviews.map((review, index) => {
|
||||
return this.renderReview({ review, key: String(index) });
|
||||
return (
|
||||
<li key={String(index)}>
|
||||
<AddonReviewListItem review={review} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardList>
|
||||
|
@ -223,17 +196,22 @@ export class AddonReviewListBase extends React.Component {
|
|||
}
|
||||
|
||||
export function mapStateToProps(
|
||||
state: {| reviews: ReviewState |}, ownProps: AddonReviewListProps,
|
||||
state: {| user: UserStateType, reviews: ReviewState |},
|
||||
ownProps: AddonReviewListProps,
|
||||
) {
|
||||
if (!ownProps || !ownProps.params || !ownProps.params.addonSlug) {
|
||||
throw new Error('The component had a falsey params.addonSlug parameter');
|
||||
}
|
||||
const addonSlug = ownProps.params.addonSlug;
|
||||
const reviewData = state.reviews.byAddon[addonSlug];
|
||||
|
||||
return {
|
||||
addon: findAddon(state, addonSlug),
|
||||
reviewCount: reviewData && reviewData.reviewCount,
|
||||
reviews: reviewData && reviewData.reviews,
|
||||
reviews: reviewData && expandReviewObjects({
|
||||
state: state.reviews,
|
||||
reviews: reviewData.reviews,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -50,27 +50,3 @@
|
|||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.AddonReviewList-li {
|
||||
word-wrap: break-word;
|
||||
|
||||
> h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
font-size: $font-size-default;
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
|
||||
.AddonReviewList-by-line {
|
||||
color: $sub-text-color;
|
||||
display: flex;
|
||||
font-size: $font-size-s;
|
||||
}
|
||||
|
||||
.Rating {
|
||||
margin: 0 5px 0 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
/* @flow */
|
||||
/* eslint-disable react/sort-comp, jsx-a11y/href-no-hash */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'redux';
|
||||
|
||||
import AddonReview from 'amo/components/AddonReview';
|
||||
import translate from 'core/i18n/translate';
|
||||
import { isAuthenticated } from 'core/reducers/user';
|
||||
import { nl2br, sanitizeHTML } from 'core/utils';
|
||||
import LoadingText from 'ui/components/LoadingText';
|
||||
import Rating from 'ui/components/Rating';
|
||||
import type { UserReviewType } from 'amo/actions/reviews';
|
||||
import type { UserStateType } from 'core/reducers/user';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
type PropsType = {|
|
||||
isAuthenticated: boolean,
|
||||
i18n: Object,
|
||||
review: UserReviewType,
|
||||
siteUser: UserStateType,
|
||||
|};
|
||||
|
||||
export class AddonReviewListItemBase extends React.Component {
|
||||
props: PropsType;
|
||||
state: {| editingReview: boolean |};
|
||||
|
||||
constructor(props: PropsType) {
|
||||
super(props);
|
||||
this.state = { editingReview: false };
|
||||
}
|
||||
|
||||
onClickToEditReview = (event: SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
this.setState({ editingReview: true });
|
||||
}
|
||||
|
||||
onEscapeReviewOverlay = () => {
|
||||
// Even though an escaped overlay will be hidden, we still have to
|
||||
// synchronize our show/hide state otherwise we won't be able to
|
||||
// show the overlay after it has been escaped.
|
||||
this.setState({ editingReview: false });
|
||||
}
|
||||
|
||||
onReviewSubmitted = () => {
|
||||
this.setState({ editingReview: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isAuthenticated: userIsAuthenticated, i18n, review, siteUser,
|
||||
} = this.props;
|
||||
|
||||
let byLine;
|
||||
let reviewBody;
|
||||
if (review) {
|
||||
const timestamp = i18n.moment(review.created).fromNow();
|
||||
// translators: Example: "from Jose, last week"
|
||||
byLine = i18n.sprintf(
|
||||
i18n.gettext('from %(authorName)s, %(timestamp)s'),
|
||||
{ authorName: review.userName, timestamp });
|
||||
|
||||
const reviewBodySanitized = sanitizeHTML(nl2br(review.body), ['br']);
|
||||
// eslint-disable-next-line react/no-danger
|
||||
reviewBody = <p dangerouslySetInnerHTML={reviewBodySanitized} />;
|
||||
} else {
|
||||
byLine = <LoadingText />;
|
||||
reviewBody = <p><LoadingText /></p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="AddonReviewListItem">
|
||||
<h3>{review ? review.title : <LoadingText />}</h3>
|
||||
{reviewBody}
|
||||
<div className="AddonReviewListItem-by-line">
|
||||
{review ?
|
||||
<Rating styleName="small" rating={review.rating} readOnly />
|
||||
: null
|
||||
}
|
||||
{byLine}
|
||||
</div>
|
||||
{userIsAuthenticated && review && review.userId === siteUser.id ?
|
||||
<div className="AddonReviewListItem-controls">
|
||||
{/* This will render an overlay to edit the review */}
|
||||
{this.state.editingReview ?
|
||||
<AddonReview
|
||||
onEscapeOverlay={this.onEscapeReviewOverlay}
|
||||
onReviewSubmitted={this.onReviewSubmitted}
|
||||
review={review}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
<a
|
||||
href="#"
|
||||
onClick={this.onClickToEditReview}
|
||||
className="AddonReviewListItem-edit"
|
||||
>
|
||||
{i18n.gettext('Edit my review')}
|
||||
</a>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function mapStateToProps(
|
||||
state: {| user: UserStateType |},
|
||||
) {
|
||||
return {
|
||||
isAuthenticated: isAuthenticated(state),
|
||||
siteUser: state.user,
|
||||
};
|
||||
}
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps),
|
||||
translate({ withRef: true }),
|
||||
)(AddonReviewListItemBase);
|
|
@ -0,0 +1,41 @@
|
|||
@import "~amo/css/inc/vars";
|
||||
|
||||
.AddonReviewListItem {
|
||||
word-wrap: break-word;
|
||||
|
||||
> h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
font-size: $font-size-default;
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
|
||||
.Rating {
|
||||
margin: 0 5px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.AddonReviewListItem-by-line {
|
||||
color: $sub-text-color;
|
||||
display: flex;
|
||||
font-size: $font-size-s;
|
||||
}
|
||||
|
||||
.AddonReviewListItem-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
|
||||
a:link,
|
||||
a:visited,
|
||||
a:hover,
|
||||
a:active {
|
||||
color: $link-color;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
|
@ -2,15 +2,20 @@
|
|||
import { SET_ADDON_REVIEWS, SET_REVIEW } from 'amo/constants';
|
||||
import type { UserReviewType } from 'amo/actions/reviews';
|
||||
|
||||
type ReviewsById = {
|
||||
[id: number]: UserReviewType,
|
||||
}
|
||||
|
||||
type ReviewsByAddon = {
|
||||
[slug: string]: {|
|
||||
reviewCount: number,
|
||||
reviews: Array<UserReviewType>,
|
||||
reviews: Array<number>,
|
||||
|},
|
||||
}
|
||||
|
||||
export type ReviewState = {|
|
||||
byAddon: ReviewsByAddon,
|
||||
byId: ReviewsById,
|
||||
|
||||
// This is what the current data structure looks like:
|
||||
// [userId: string]: {
|
||||
|
@ -28,8 +33,26 @@ export type ReviewState = {|
|
|||
//
|
||||
|};
|
||||
|
||||
export const initialState = {
|
||||
export const initialState: ReviewState = {
|
||||
byAddon: {},
|
||||
byId: {},
|
||||
};
|
||||
|
||||
type ExpandReviewObjectsParams = {|
|
||||
state: ReviewState,
|
||||
reviews: Array<number>,
|
||||
|};
|
||||
|
||||
export const expandReviewObjects = (
|
||||
{ state, reviews }: ExpandReviewObjectsParams
|
||||
): Array<UserReviewType> => {
|
||||
return reviews.map((id) => {
|
||||
const review = state.byId[id];
|
||||
if (!review) {
|
||||
throw new Error(`No stored review exists for ID ${id}`);
|
||||
}
|
||||
return review;
|
||||
});
|
||||
};
|
||||
|
||||
function mergeInNewReview(
|
||||
|
@ -50,8 +73,27 @@ function mergeInNewReview(
|
|||
return mergedReviews;
|
||||
}
|
||||
|
||||
type StoreReviewObjectsParams = {|
|
||||
state: ReviewState,
|
||||
reviews: Array<UserReviewType>,
|
||||
|};
|
||||
|
||||
export default function reviews(
|
||||
export const storeReviewObjects = (
|
||||
{ state, reviews }: StoreReviewObjectsParams
|
||||
): ReviewsById => {
|
||||
const byId = { ...state.byId };
|
||||
|
||||
reviews.forEach((review) => {
|
||||
if (!review.id) {
|
||||
throw new Error('Cannot store review because review.id is falsy');
|
||||
}
|
||||
byId[review.id] = review;
|
||||
});
|
||||
|
||||
return byId;
|
||||
};
|
||||
|
||||
export default function reviewsReducer(
|
||||
state: ReviewState = initialState,
|
||||
{ payload, type }: {| payload: any, type: string |},
|
||||
) {
|
||||
|
@ -62,8 +104,13 @@ export default function reviews(
|
|||
const latestReview = payload;
|
||||
return {
|
||||
...state,
|
||||
byId: storeReviewObjects({ state, reviews: [payload] }),
|
||||
[payload.userId]: {
|
||||
...state[payload.userId],
|
||||
// TODO: this should be a list of review IDs, not objects. It will
|
||||
// be complicated because we also need to preserve handling of the
|
||||
// isLatest flag.
|
||||
// https://github.com/mozilla/addons-frontend/issues/3221
|
||||
[payload.addonId]: mergeInNewReview(latestReview, existingReviews),
|
||||
},
|
||||
};
|
||||
|
@ -71,11 +118,12 @@ export default function reviews(
|
|||
case SET_ADDON_REVIEWS: {
|
||||
return {
|
||||
...state,
|
||||
byId: storeReviewObjects({ state, reviews: payload.reviews }),
|
||||
byAddon: {
|
||||
...state.byAddon,
|
||||
[payload.addonSlug]: {
|
||||
reviewCount: payload.reviewCount,
|
||||
reviews: payload.reviews,
|
||||
reviews: payload.reviews.map((review) => review.id),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@ export default class Overlay extends React.Component {
|
|||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
onEscapeOverlay: PropTypes.func,
|
||||
visibleOnLoad: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
|
@ -28,6 +29,9 @@ export default class Overlay extends React.Component {
|
|||
}
|
||||
|
||||
onClickBackground = () => {
|
||||
if (this.props.onEscapeOverlay) {
|
||||
this.props.onEscapeOverlay();
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ export default class OverlayCard extends React.Component {
|
|||
header: PropTypes.node,
|
||||
footerLink: PropTypes.node,
|
||||
footerText: PropTypes.node,
|
||||
onEscapeOverlay: PropTypes.func,
|
||||
visibleOnLoad: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
|
@ -41,6 +42,7 @@ export default class OverlayCard extends React.Component {
|
|||
|
||||
return (
|
||||
<Overlay
|
||||
onEscapeOverlay={this.props.onEscapeOverlay}
|
||||
visibleOnLoad={visibleOnLoad}
|
||||
ref={(ref) => { this.overlay = ref; }}
|
||||
>
|
||||
|
|
|
@ -5,16 +5,23 @@ import {
|
|||
Simulate,
|
||||
} from 'react-addons-test-utils';
|
||||
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import translate from 'core/i18n/translate';
|
||||
import { SET_REVIEW } from 'amo/constants';
|
||||
import { setReview } from 'amo/actions/reviews';
|
||||
import { setDenormalizedReview, setReview } from 'amo/actions/reviews';
|
||||
import * as amoApi from 'amo/api';
|
||||
import * as coreUtils from 'core/utils';
|
||||
import {
|
||||
import AddonReview, {
|
||||
mapDispatchToProps, mapStateToProps, AddonReviewBase,
|
||||
} from 'amo/components/AddonReview';
|
||||
import { fakeAddon, fakeReview, signedInApiState } from 'tests/unit/amo/helpers';
|
||||
import { createStubErrorHandler, getFakeI18nInst } from 'tests/unit/helpers';
|
||||
import {
|
||||
dispatchClientMetadata, fakeAddon, fakeReview, signedInApiState,
|
||||
} from 'tests/unit/amo/helpers';
|
||||
import {
|
||||
createStubErrorHandler, getFakeI18nInst, shallowUntilTarget,
|
||||
} from 'tests/unit/helpers';
|
||||
import OverlayCard from 'ui/components/OverlayCard';
|
||||
import Rating from 'ui/components/Rating';
|
||||
|
||||
const defaultReview = {
|
||||
id: 3321, addonId: fakeAddon.id, addonSlug: fakeAddon.slug, rating: 5,
|
||||
|
@ -29,37 +36,55 @@ function fakeLocalState(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function render({ ...customProps } = {}) {
|
||||
const props = {
|
||||
createLocalState: () => fakeLocalState(),
|
||||
errorHandler: createStubErrorHandler(),
|
||||
i18n: getFakeI18nInst(),
|
||||
apiState: signedInApiState,
|
||||
onReviewSubmitted: () => {},
|
||||
refreshAddon: () => Promise.resolve(),
|
||||
review: defaultReview,
|
||||
setDenormalizedReview: () => {},
|
||||
updateReviewText: () => Promise.resolve(),
|
||||
...customProps,
|
||||
describe(__filename, () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = dispatchClientMetadata().store;
|
||||
});
|
||||
|
||||
const renderProps = (customProps = {}) => {
|
||||
return {
|
||||
createLocalState: () => fakeLocalState(),
|
||||
errorHandler: createStubErrorHandler(),
|
||||
i18n: getFakeI18nInst(),
|
||||
apiState: signedInApiState,
|
||||
onReviewSubmitted: () => {},
|
||||
refreshAddon: () => Promise.resolve(),
|
||||
review: defaultReview,
|
||||
setDenormalizedReview: () => {},
|
||||
store,
|
||||
updateReviewText: () => Promise.resolve(),
|
||||
...customProps,
|
||||
};
|
||||
};
|
||||
const AddonReview = translate({ withRef: true })(AddonReviewBase);
|
||||
const root = findRenderedComponentWithType(renderIntoDocument(
|
||||
<AddonReview {...props} />
|
||||
), AddonReview);
|
||||
|
||||
return root.getWrappedInstance();
|
||||
}
|
||||
function render(customProps = {}) {
|
||||
const props = renderProps(customProps);
|
||||
const AddonReviewI18n = translate({ withRef: true })(AddonReviewBase);
|
||||
const root = findRenderedComponentWithType(renderIntoDocument(
|
||||
<I18nProvider i18n={props.i18n}>
|
||||
<AddonReviewI18n {...props} />
|
||||
</I18nProvider>
|
||||
), AddonReviewI18n);
|
||||
|
||||
return root.getWrappedInstance();
|
||||
}
|
||||
|
||||
const shallowRender = (customProps = {}) => {
|
||||
const props = renderProps(customProps);
|
||||
return shallowUntilTarget(<AddonReview {...props} />, AddonReviewBase);
|
||||
};
|
||||
|
||||
describe('AddonReview', () => {
|
||||
it('can update a review', () => {
|
||||
const onReviewSubmitted = sinon.spy(() => {});
|
||||
const setDenormalizedReview = sinon.spy(() => {});
|
||||
const _setDenormalizedReview = sinon.spy(() => {});
|
||||
const refreshAddon = sinon.spy(() => Promise.resolve());
|
||||
const updateReviewText = sinon.spy(() => Promise.resolve());
|
||||
const errorHandler = createStubErrorHandler();
|
||||
const root = render({
|
||||
onReviewSubmitted,
|
||||
setDenormalizedReview,
|
||||
setDenormalizedReview: _setDenormalizedReview,
|
||||
refreshAddon,
|
||||
updateReviewText,
|
||||
errorHandler,
|
||||
|
@ -75,29 +100,38 @@ describe('AddonReview', () => {
|
|||
|
||||
return root.onSubmit(event)
|
||||
.then(() => {
|
||||
expect(event.preventDefault.called).toBeTruthy();
|
||||
sinon.assert.called(event.preventDefault);
|
||||
|
||||
expect(setDenormalizedReview.called).toBeTruthy();
|
||||
expect(setDenormalizedReview.firstCall.args[0]).toEqual({ ...defaultReview, body: 'some review' });
|
||||
sinon.assert.called(_setDenormalizedReview);
|
||||
expect(_setDenormalizedReview.firstCall.args[0])
|
||||
.toEqual({ ...defaultReview, body: 'some review' });
|
||||
|
||||
expect(updateReviewText.called).toBeTruthy();
|
||||
sinon.assert.called(updateReviewText);
|
||||
const params = updateReviewText.firstCall.args[0];
|
||||
expect(params.body).toEqual('some review');
|
||||
expect(params.addonId).toEqual(defaultReview.addonId);
|
||||
expect(params.errorHandler).toEqual(errorHandler);
|
||||
expect(params.rating).toEqual(defaultReview.rating);
|
||||
expect(params.reviewId).toEqual(defaultReview.id);
|
||||
expect(params.apiState).toEqual(signedInApiState);
|
||||
|
||||
expect(refreshAddon.called).toBeTruthy();
|
||||
sinon.assert.called(refreshAddon);
|
||||
expect(refreshAddon.firstCall.args[0]).toEqual({
|
||||
addonSlug: defaultReview.addonSlug,
|
||||
apiState: signedInApiState,
|
||||
});
|
||||
|
||||
expect(onReviewSubmitted.called).toBeTruthy();
|
||||
sinon.assert.called(onReviewSubmitted);
|
||||
});
|
||||
});
|
||||
|
||||
it('it passes onEscapeOverlay to OverlayCard', () => {
|
||||
const onEscapeOverlay = sinon.stub();
|
||||
const root = shallowRender({ onEscapeOverlay });
|
||||
expect(root.find(OverlayCard))
|
||||
.toHaveProp('onEscapeOverlay', onEscapeOverlay);
|
||||
});
|
||||
|
||||
it('updates review state from a new review property', () => {
|
||||
const root = render();
|
||||
root.componentWillReceiveProps({
|
||||
|
@ -111,22 +145,22 @@ describe('AddonReview', () => {
|
|||
});
|
||||
|
||||
it('looks for state in a local store at initialization', () => {
|
||||
const store = fakeLocalState({
|
||||
const localState = fakeLocalState({
|
||||
load: sinon.spy(() => Promise.resolve({
|
||||
reviewBody: 'stored body',
|
||||
})),
|
||||
});
|
||||
render({ createLocalState: () => store });
|
||||
expect(store.load.called).toBeTruthy();
|
||||
render({ createLocalState: () => localState });
|
||||
sinon.assert.called(localState.load);
|
||||
});
|
||||
|
||||
it('looks for state in a local store and loads it', () => {
|
||||
const store = fakeLocalState({
|
||||
const localState = fakeLocalState({
|
||||
load: sinon.spy(() => Promise.resolve({
|
||||
reviewBody: 'stored body',
|
||||
})),
|
||||
});
|
||||
const root = render({ createLocalState: () => store });
|
||||
const root = render({ createLocalState: () => localState });
|
||||
return root.checkForStoredState()
|
||||
.then(() => {
|
||||
expect(root.state.reviewBody).toEqual('stored body');
|
||||
|
@ -134,11 +168,11 @@ describe('AddonReview', () => {
|
|||
});
|
||||
|
||||
it('ignores null entries when retrieving locally stored state', () => {
|
||||
const store = fakeLocalState({
|
||||
const localState = fakeLocalState({
|
||||
load: sinon.spy(() => Promise.resolve(null)),
|
||||
});
|
||||
const root = render({
|
||||
createLocalState: () => store,
|
||||
createLocalState: () => localState,
|
||||
review: {
|
||||
...defaultReview,
|
||||
body: 'Existing body',
|
||||
|
@ -151,13 +185,13 @@ describe('AddonReview', () => {
|
|||
});
|
||||
|
||||
it('overrides existing text with locally stored text', () => {
|
||||
const store = fakeLocalState({
|
||||
const localState = fakeLocalState({
|
||||
load: sinon.spy(() => Promise.resolve({
|
||||
reviewBody: 'Stored text',
|
||||
})),
|
||||
});
|
||||
const root = render({
|
||||
createLocalState: () => store,
|
||||
createLocalState: () => localState,
|
||||
review: {
|
||||
...defaultReview,
|
||||
body: 'Existing text',
|
||||
|
@ -170,11 +204,11 @@ describe('AddonReview', () => {
|
|||
});
|
||||
|
||||
it('stores text locally when you type text', () => {
|
||||
const store = fakeLocalState({
|
||||
const localState = fakeLocalState({
|
||||
save: sinon.spy(() => Promise.resolve()),
|
||||
});
|
||||
const root = render({
|
||||
createLocalState: () => store,
|
||||
createLocalState: () => localState,
|
||||
debounce: (callback) => (...args) => callback(...args),
|
||||
});
|
||||
|
||||
|
@ -182,18 +216,18 @@ describe('AddonReview', () => {
|
|||
textarea.value = 'some review';
|
||||
Simulate.input(textarea);
|
||||
|
||||
expect(store.save.called).toBeTruthy();
|
||||
expect(store.save.firstCall.args[0]).toEqual({
|
||||
sinon.assert.called(localState.save);
|
||||
expect(localState.save.firstCall.args[0]).toEqual({
|
||||
reviewBody: 'some review',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the stored state after a successful submission', () => {
|
||||
const store = fakeLocalState({
|
||||
const localState = fakeLocalState({
|
||||
clear: sinon.spy(() => Promise.resolve()),
|
||||
});
|
||||
const root = render({
|
||||
createLocalState: () => store,
|
||||
createLocalState: () => localState,
|
||||
});
|
||||
|
||||
const textarea = root.reviewTextarea;
|
||||
|
@ -207,7 +241,7 @@ describe('AddonReview', () => {
|
|||
|
||||
return root.onSubmit(event)
|
||||
.then(() => {
|
||||
expect(store.clear.called).toBeTruthy();
|
||||
sinon.assert.called(localState.clear);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -235,17 +269,29 @@ describe('AddonReview', () => {
|
|||
Simulate.submit(root.reviewForm);
|
||||
|
||||
// Just make sure the submit handler is hooked up.
|
||||
expect(updateReviewText.called).toBeTruthy();
|
||||
sinon.assert.called(updateReviewText);
|
||||
});
|
||||
|
||||
it('requires a review object', () => {
|
||||
const review = { nope: 'not even close' };
|
||||
try {
|
||||
render({ review });
|
||||
expect(false).toBeTruthy();
|
||||
} catch (error) {
|
||||
expect(error.message).toMatch(/Unexpected review property: {"nope".*/);
|
||||
}
|
||||
expect(() => render({ review }))
|
||||
.toThrow(/Unexpected review property: {"nope".*/);
|
||||
});
|
||||
|
||||
it('lets you change the star rating', () => {
|
||||
const fakeDispatch = sinon.stub(store, 'dispatch');
|
||||
const review = { ...defaultReview };
|
||||
const root = shallowRender({ review });
|
||||
|
||||
const rating = root.find(Rating);
|
||||
const onSelectRating = rating.prop('onSelectRating');
|
||||
const newRating = 1;
|
||||
onSelectRating(newRating);
|
||||
|
||||
sinon.assert.calledWith(fakeDispatch, setDenormalizedReview({
|
||||
...review,
|
||||
rating: newRating,
|
||||
}));
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
|
@ -285,8 +331,7 @@ describe('AddonReview', () => {
|
|||
return actions.updateReviewText({ ...params })
|
||||
.then(() => {
|
||||
mockApi.verify();
|
||||
expect(dispatch.called).toBeTruthy();
|
||||
expect(dispatch.firstCall.args[0]).toEqual(setReview(fakeReview));
|
||||
sinon.assert.calledWith(dispatch, setReview(fakeReview));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -313,7 +358,7 @@ describe('AddonReview', () => {
|
|||
};
|
||||
actions.setDenormalizedReview(review);
|
||||
|
||||
expect(dispatch.called).toBeTruthy();
|
||||
sinon.assert.called(dispatch);
|
||||
const action = dispatch.firstCall.args[0];
|
||||
expect(action.type).toEqual(SET_REVIEW);
|
||||
expect(action.payload).toEqual(review);
|
||||
|
|
|
@ -1,104 +1,119 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import fallbackIcon from 'amo/img/icons/default-64.png';
|
||||
import createStore from 'amo/store';
|
||||
import { fetchReviews, setAddonReviews } from 'amo/actions/reviews';
|
||||
import { setViewContext } from 'amo/actions/viewContext';
|
||||
import {
|
||||
import AddonReviewList, {
|
||||
AddonReviewListBase,
|
||||
mapStateToProps,
|
||||
} from 'amo/components/AddonReviewList';
|
||||
import AddonReviewListItem from 'amo/components/AddonReviewListItem';
|
||||
import NotFound from 'amo/components/ErrorPage/NotFound';
|
||||
import Link from 'amo/components/Link';
|
||||
import Paginate from 'core/components/Paginate';
|
||||
import {
|
||||
ADDON_TYPE_EXTENSION,
|
||||
ADDON_TYPE_THEME,
|
||||
} from 'core/constants';
|
||||
import {
|
||||
fetchAddon, createInternalAddon, loadAddons,
|
||||
} from 'core/reducers/addons';
|
||||
import ErrorList from 'ui/components/ErrorList';
|
||||
import Rating from 'ui/components/Rating';
|
||||
import { fakeAddon, fakeReview, dispatchClientMetadata } from 'tests/unit/amo/helpers';
|
||||
import {
|
||||
dispatchClientMetadata, fakeAddon, fakeReview,
|
||||
} from 'tests/unit/amo/helpers';
|
||||
import {
|
||||
createFetchAddonResult,
|
||||
createStubErrorHandler,
|
||||
getFakeI18nInst,
|
||||
shallowUntilTarget,
|
||||
} from 'tests/unit/helpers';
|
||||
import { setError } from 'core/actions/errors';
|
||||
import { createApiError } from 'core/api/index';
|
||||
import LoadingText from 'ui/components/LoadingText';
|
||||
|
||||
function getLoadedReviews({
|
||||
addonSlug = fakeAddon.slug, reviews = [fakeReview], reviewCount = 1 } = {},
|
||||
) {
|
||||
const action = setAddonReviews({ addonSlug, reviewCount, reviews });
|
||||
// This is how reviews look after they have been loaded.
|
||||
return action.payload.reviews;
|
||||
}
|
||||
|
||||
describe('amo/components/AddonReviewList', () => {
|
||||
describe('<AddonReviewListBase/>', () => {
|
||||
function render({
|
||||
addon = fakeAddon,
|
||||
dispatch = sinon.stub(),
|
||||
errorHandler = createStubErrorHandler(),
|
||||
params = {
|
||||
addonSlug: fakeAddon.slug,
|
||||
},
|
||||
reviews = [fakeReview],
|
||||
...customProps
|
||||
} = {}) {
|
||||
const loadedReviews = reviews ? getLoadedReviews({ reviews }) : null;
|
||||
const props = {
|
||||
addon,
|
||||
dispatch,
|
||||
errorHandler,
|
||||
i18n: getFakeI18nInst(),
|
||||
location: { query: {} },
|
||||
params,
|
||||
reviewCount: loadedReviews && loadedReviews.length,
|
||||
reviews: loadedReviews,
|
||||
...customProps,
|
||||
};
|
||||
describe(__filename, () => {
|
||||
let store;
|
||||
|
||||
return shallow(<AddonReviewListBase {...props} />);
|
||||
}
|
||||
beforeEach(() => {
|
||||
store = dispatchClientMetadata().store;
|
||||
});
|
||||
|
||||
it('requires an addonSlug property', () => {
|
||||
const render = ({
|
||||
params = {
|
||||
addonSlug: fakeAddon.slug,
|
||||
},
|
||||
...customProps
|
||||
} = {}) => {
|
||||
const props = {
|
||||
i18n: getFakeI18nInst(),
|
||||
location: { query: {} },
|
||||
params,
|
||||
store,
|
||||
...customProps,
|
||||
};
|
||||
return shallowUntilTarget(
|
||||
<AddonReviewList {...props} />, AddonReviewListBase
|
||||
);
|
||||
};
|
||||
|
||||
const dispatchAddon = (addon = fakeAddon) => {
|
||||
store.dispatch(loadAddons(createFetchAddonResult(addon).entities));
|
||||
};
|
||||
|
||||
const dispatchAddonReviews = ({
|
||||
addon = fakeAddon, reviews = [{ ...fakeReview, id: 1 }],
|
||||
} = {}) => {
|
||||
const action = setAddonReviews({
|
||||
addonSlug: addon.slug, reviews, reviewCount: reviews.length,
|
||||
});
|
||||
store.dispatch(action);
|
||||
};
|
||||
|
||||
describe('<AddonReviewList/>', () => {
|
||||
it('requires location params', () => {
|
||||
expect(() => render({ params: null }))
|
||||
.toThrowError(/component had a falsey params\.addonSlug/);
|
||||
});
|
||||
|
||||
it('requires an addonSlug param', () => {
|
||||
expect(() => render({ params: {} }))
|
||||
.toThrowError(/component had a falsey params\.addonSlug/);
|
||||
});
|
||||
|
||||
it('requires a non-empty addonSlug param', () => {
|
||||
expect(() => render({ params: { addonSlug: null } }))
|
||||
.toThrowError(/addonSlug cannot be falsey/);
|
||||
.toThrowError(/component had a falsey params\.addonSlug/);
|
||||
});
|
||||
|
||||
it('waits for an addon and reviews to load', () => {
|
||||
const root = render({ addon: null, reviews: null });
|
||||
const root = render({ addon: null });
|
||||
expect(root.find('.AddonReviewList-header-icon img').prop('src'))
|
||||
.toContain('default');
|
||||
.toEqual(fallbackIcon);
|
||||
expect(root.find('.AddonReviewList-header-text').find(LoadingText))
|
||||
.toHaveLength(2);
|
||||
|
||||
// Make sure four review placeholders were rendered.
|
||||
expect(root.find('.AddonReviewList-li')).toHaveLength(4);
|
||||
expect(root.find(AddonReviewListItem)).toHaveLength(4);
|
||||
// Do a sanity check on the first placeholder;
|
||||
expect(root.find('.AddonReviewList-li h3').at(0).find(LoadingText))
|
||||
.toHaveLength(1);
|
||||
expect(root.find('.AddonReviewList-li p').at(0).find(LoadingText))
|
||||
.toHaveLength(1);
|
||||
expect(root.find('.AddonReviewList-by-line').at(0).find(LoadingText))
|
||||
.toHaveLength(1);
|
||||
expect(root.find(AddonReviewListItem).at(0))
|
||||
.toHaveProp('review', null);
|
||||
});
|
||||
|
||||
it('does not paginate before reviews have loaded', () => {
|
||||
const root = render({ addon: fakeAddon, reviews: null });
|
||||
dispatchAddon(fakeAddon);
|
||||
const root = render({ reviews: null });
|
||||
|
||||
expect(root.find(Paginate)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fetches an addon if needed', () => {
|
||||
const addonSlug = 'some-addon-slug';
|
||||
const dispatch = sinon.stub();
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler();
|
||||
|
||||
render({
|
||||
addon: null, errorHandler, params: { addonSlug }, dispatch,
|
||||
addon: null, errorHandler, params: { addonSlug },
|
||||
});
|
||||
|
||||
sinon.assert.calledWith(dispatch, fetchAddon({
|
||||
|
@ -106,17 +121,24 @@ describe('amo/components/AddonReviewList', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('ignores other add-ons', () => {
|
||||
dispatchAddon();
|
||||
const root = render({
|
||||
params: { addonSlug: 'other-slug' },
|
||||
});
|
||||
expect(root.instance().props.addon).toBe(undefined);
|
||||
});
|
||||
|
||||
it('fetches reviews if needed', () => {
|
||||
const addon = { ...fakeAddon, slug: 'some-other-slug' };
|
||||
const dispatch = sinon.stub();
|
||||
dispatchAddon(addon);
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler();
|
||||
|
||||
render({
|
||||
addon,
|
||||
reviews: null,
|
||||
errorHandler,
|
||||
params: { addonSlug: addon.slug },
|
||||
dispatch,
|
||||
});
|
||||
|
||||
sinon.assert.calledWith(dispatch, fetchReviews({
|
||||
|
@ -126,8 +148,10 @@ describe('amo/components/AddonReviewList', () => {
|
|||
});
|
||||
|
||||
it('fetches reviews if needed during an update', () => {
|
||||
const addon = { ...fakeAddon, slug: 'some-other-slug' };
|
||||
const dispatch = sinon.stub();
|
||||
const addon = createInternalAddon({
|
||||
...fakeAddon, slug: 'some-other-slug',
|
||||
});
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler();
|
||||
|
||||
const root = render({
|
||||
|
@ -135,8 +159,8 @@ describe('amo/components/AddonReviewList', () => {
|
|||
reviews: null,
|
||||
errorHandler,
|
||||
params: { addonSlug: addon.slug },
|
||||
dispatch,
|
||||
});
|
||||
|
||||
dispatch.reset();
|
||||
// Simulate how a redux state change will introduce an addon.
|
||||
root.setProps({ addon });
|
||||
|
@ -148,7 +172,7 @@ describe('amo/components/AddonReviewList', () => {
|
|||
});
|
||||
|
||||
it('fetches reviews by page', () => {
|
||||
const dispatch = sinon.stub();
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler();
|
||||
const addonSlug = fakeAddon.slug;
|
||||
const page = 2;
|
||||
|
@ -158,7 +182,6 @@ describe('amo/components/AddonReviewList', () => {
|
|||
errorHandler,
|
||||
location: { query: { page } },
|
||||
params: { addonSlug },
|
||||
dispatch,
|
||||
});
|
||||
|
||||
sinon.assert.calledWith(dispatch, fetchReviews({
|
||||
|
@ -169,7 +192,7 @@ describe('amo/components/AddonReviewList', () => {
|
|||
});
|
||||
|
||||
it('fetches reviews when the page changes', () => {
|
||||
const dispatch = sinon.stub();
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler();
|
||||
const addonSlug = fakeAddon.slug;
|
||||
|
||||
|
@ -177,7 +200,6 @@ describe('amo/components/AddonReviewList', () => {
|
|||
errorHandler,
|
||||
location: { query: { page: 2 } },
|
||||
params: { addonSlug },
|
||||
dispatch,
|
||||
});
|
||||
dispatch.reset();
|
||||
root.setProps({ location: { query: { page: 3 } } });
|
||||
|
@ -191,38 +213,67 @@ describe('amo/components/AddonReviewList', () => {
|
|||
|
||||
it('does not fetch an addon if there is an error', () => {
|
||||
const addon = { ...fakeAddon, slug: 'some-other-slug' };
|
||||
const dispatch = sinon.stub();
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler(new Error('some error'));
|
||||
|
||||
render({
|
||||
addon: null,
|
||||
errorHandler,
|
||||
params: { addonSlug: addon.slug },
|
||||
dispatch,
|
||||
});
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
|
||||
it('does not fetch reviews if there is an error', () => {
|
||||
const dispatch = sinon.stub();
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const errorHandler = createStubErrorHandler(new Error('some error'));
|
||||
|
||||
render({
|
||||
reviews: null,
|
||||
errorHandler,
|
||||
dispatch,
|
||||
});
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
|
||||
it('dispatches a view context for the add-on', () => {
|
||||
const dispatch = sinon.stub();
|
||||
render({ addon: fakeAddon, dispatch });
|
||||
const addon = fakeAddon;
|
||||
dispatchAddon(addon);
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
render();
|
||||
|
||||
sinon.assert.calledWith(
|
||||
dispatch, setViewContext(fakeAddon.type));
|
||||
sinon.assert.calledWith(dispatch, setViewContext(addon.type));
|
||||
});
|
||||
|
||||
it('does not dispatch a view context for similar add-ons', () => {
|
||||
const addon1 = fakeAddon;
|
||||
dispatchAddon(addon1);
|
||||
dispatchAddonReviews();
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const root = render();
|
||||
|
||||
dispatch.reset();
|
||||
// Update the component with a different addon having the same type.
|
||||
root.setProps({
|
||||
addon: createInternalAddon({ ...addon1, id: 345 }),
|
||||
});
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
|
||||
it('dispatches a view context for new add-on types', () => {
|
||||
const addon1 = { ...fakeAddon, type: ADDON_TYPE_EXTENSION };
|
||||
const addon2 = { ...addon1, type: ADDON_TYPE_THEME };
|
||||
|
||||
dispatchAddon(addon1);
|
||||
const dispatch = sinon.stub(store, 'dispatch');
|
||||
const root = render();
|
||||
|
||||
dispatch.reset();
|
||||
root.setProps({ addon: createInternalAddon(addon2) });
|
||||
|
||||
sinon.assert.calledWith(dispatch, setViewContext(addon2.type));
|
||||
});
|
||||
|
||||
it('renders an error', () => {
|
||||
|
@ -234,7 +285,6 @@ describe('amo/components/AddonReviewList', () => {
|
|||
|
||||
it('renders NotFound page if API returns 401 error', () => {
|
||||
const id = 'error-handler-id';
|
||||
const { store } = dispatchClientMetadata();
|
||||
|
||||
const error = createApiError({
|
||||
response: { status: 401 },
|
||||
|
@ -254,7 +304,6 @@ describe('amo/components/AddonReviewList', () => {
|
|||
|
||||
it('renders NotFound page if API returns 403 error', () => {
|
||||
const id = 'error-handler-id';
|
||||
const { store } = dispatchClientMetadata();
|
||||
|
||||
const error = createApiError({
|
||||
response: { status: 403 },
|
||||
|
@ -274,7 +323,6 @@ describe('amo/components/AddonReviewList', () => {
|
|||
|
||||
it('renders NotFound page if API returns 404 error', () => {
|
||||
const id = 'error-handler-id';
|
||||
const { store } = dispatchClientMetadata();
|
||||
|
||||
const error = createApiError({
|
||||
response: { status: 404 },
|
||||
|
@ -294,72 +342,63 @@ describe('amo/components/AddonReviewList', () => {
|
|||
|
||||
it('renders a list of reviews with ratings', () => {
|
||||
const reviews = [
|
||||
{ ...fakeReview, rating: 1 },
|
||||
{ ...fakeReview, rating: 2 },
|
||||
{ ...fakeReview, id: 1, rating: 1 },
|
||||
{ ...fakeReview, id: 2, rating: 2 },
|
||||
];
|
||||
const tree = render({ reviews });
|
||||
const ratings = tree.find(Rating);
|
||||
expect(ratings).toHaveLength(2);
|
||||
dispatchAddon();
|
||||
dispatchAddonReviews({ reviews });
|
||||
const tree = render();
|
||||
const items = tree.find(AddonReviewListItem);
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
expect(ratings.at(0)).toHaveProp('rating', 1);
|
||||
expect(ratings.at(0)).toHaveProp('readOnly', true);
|
||||
expect(ratings.at(1)).toHaveProp('rating', 2);
|
||||
expect(ratings.at(1)).toHaveProp('readOnly', true);
|
||||
});
|
||||
|
||||
it('renders a review', () => {
|
||||
const root = render({ reviews: [fakeReview] });
|
||||
const fakeReviewWithNewLine = {
|
||||
...fakeReview,
|
||||
body: "It's awesome \n isn't it?",
|
||||
};
|
||||
const wrapper = render({ reviews: [fakeReviewWithNewLine] });
|
||||
|
||||
expect(root.find('.AddonReviewList-li h3'))
|
||||
.toHaveText(fakeReview.title);
|
||||
|
||||
expect(root.find('.AddonReviewList-li p'))
|
||||
.toHaveHTML(`<p>${fakeReview.body}</p>`);
|
||||
|
||||
expect(root.find('.AddonReviewList-by-line'))
|
||||
.toIncludeText(fakeReview.user.name);
|
||||
|
||||
expect(wrapper.find('.AddonReviewList-li p').render().find('br'))
|
||||
.toHaveLength(1);
|
||||
expect(items.at(0)).toHaveProp('review');
|
||||
expect(items.at(0).prop('review')).toMatchObject({
|
||||
rating: reviews[0].rating,
|
||||
});
|
||||
expect(items.at(1)).toHaveProp('review');
|
||||
expect(items.at(1).prop('review')).toMatchObject({
|
||||
rating: reviews[1].rating,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the add-on's icon in the header", () => {
|
||||
const root = render({ addon: fakeAddon });
|
||||
const addon = fakeAddon;
|
||||
dispatchAddon(addon);
|
||||
const root = render();
|
||||
const img = root.find('.AddonReviewList-header-icon img');
|
||||
expect(img).toHaveProp('src', fakeAddon.icon_url);
|
||||
expect(img).toHaveProp('src', addon.icon_url);
|
||||
});
|
||||
|
||||
it('renders the fallback icon if the origin is not allowed', () => {
|
||||
const root = render({
|
||||
addon: {
|
||||
...fakeAddon,
|
||||
icon_url: 'http://foo.com/hax.png',
|
||||
},
|
||||
dispatchAddon({
|
||||
...fakeAddon, icon_url: 'http://foo.com/hax.png',
|
||||
});
|
||||
const root = render();
|
||||
const img = root.find('.AddonReviewList-header-icon img');
|
||||
expect(img).toHaveProp('src', fallbackIcon);
|
||||
});
|
||||
|
||||
it('renders a hidden h1 for SEO', () => {
|
||||
const root = render({ addon: fakeAddon });
|
||||
const addon = fakeAddon;
|
||||
dispatchAddon(addon);
|
||||
const root = render();
|
||||
const h1 = root.find('.AddonReviewList-header h1');
|
||||
expect(h1).toHaveClassName('visually-hidden');
|
||||
expect(h1).toHaveText(`Reviews for ${fakeAddon.name}`);
|
||||
expect(h1).toHaveText(`Reviews for ${addon.name}`);
|
||||
});
|
||||
|
||||
it('produces an addon URL', () => {
|
||||
const addon = fakeAddon;
|
||||
dispatchAddon(addon);
|
||||
expect(render().instance().addonURL())
|
||||
.toEqual(`/addon/${fakeAddon.slug}/`);
|
||||
.toEqual(`/addon/${addon.slug}/`);
|
||||
});
|
||||
|
||||
it('produces a URL to itself', () => {
|
||||
const addon = fakeAddon;
|
||||
dispatchAddon(addon);
|
||||
expect(render().instance().url())
|
||||
.toEqual(`/addon/${fakeAddon.slug}/reviews/`);
|
||||
.toEqual(`/addon/${addon.slug}/reviews/`);
|
||||
});
|
||||
|
||||
it('requires an addon prop to produce a URL', () => {
|
||||
|
@ -368,91 +407,45 @@ describe('amo/components/AddonReviewList', () => {
|
|||
});
|
||||
|
||||
it('configures a paginator with the right URL', () => {
|
||||
dispatchAddon();
|
||||
dispatchAddonReviews();
|
||||
const root = render();
|
||||
expect(root.find(Paginate))
|
||||
.toHaveProp('pathname', root.instance().url());
|
||||
});
|
||||
|
||||
it('configures a paginator with the right Link', () => {
|
||||
expect(render().find(Paginate)).toHaveProp('LinkComponent', Link);
|
||||
dispatchAddon();
|
||||
dispatchAddonReviews();
|
||||
const root = render();
|
||||
expect(root.find(Paginate)).toHaveProp('LinkComponent', Link);
|
||||
});
|
||||
|
||||
it('configures a paginator with the right review count', () => {
|
||||
const root = render({ reviewCount: 500 });
|
||||
expect(root.find(Paginate)).toHaveProp('count', 500);
|
||||
const reviews = [
|
||||
{ ...fakeReview, id: 1 },
|
||||
{ ...fakeReview, id: 2 },
|
||||
{ ...fakeReview, id: 3 },
|
||||
];
|
||||
dispatchAddon();
|
||||
dispatchAddonReviews({ reviews });
|
||||
const root = render();
|
||||
expect(root.find(Paginate)).toHaveProp('count', reviews.length);
|
||||
});
|
||||
|
||||
it('sets the paginator to page 1 without a query', () => {
|
||||
dispatchAddon();
|
||||
dispatchAddonReviews();
|
||||
// Render with an empty query string.
|
||||
const root = render({ location: { query: {} } });
|
||||
expect(root.find(Paginate)).toHaveProp('currentPage', 1);
|
||||
});
|
||||
|
||||
it('sets the paginator to the query string page', () => {
|
||||
dispatchAddon();
|
||||
dispatchAddonReviews();
|
||||
const root = render({ location: { query: { page: 3 } } });
|
||||
expect(root.find(Paginate)).toHaveProp('currentPage', 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore().store;
|
||||
});
|
||||
|
||||
function getMappedProps({
|
||||
addonSlug = fakeAddon.slug, params = { addonSlug },
|
||||
} = {}) {
|
||||
return mapStateToProps(store.getState(), { params });
|
||||
}
|
||||
|
||||
it('loads addon from state', () => {
|
||||
store.dispatch(loadAddons(createFetchAddonResult(fakeAddon).entities));
|
||||
const props = getMappedProps();
|
||||
expect(props.addon).toEqual(createInternalAddon(fakeAddon));
|
||||
});
|
||||
|
||||
it('ignores other add-ons', () => {
|
||||
store.dispatch(loadAddons(createFetchAddonResult(fakeAddon).entities));
|
||||
const props = getMappedProps({ addonSlug: 'other-slug' });
|
||||
expect(props.addon).toBe(undefined);
|
||||
});
|
||||
|
||||
it('requires component properties', () => {
|
||||
expect(() => getMappedProps({ params: null }))
|
||||
.toThrowError(/component had a falsey params.addonSlug parameter/);
|
||||
});
|
||||
|
||||
it('requires an existing slug property', () => {
|
||||
expect(() => getMappedProps({ params: {} }))
|
||||
.toThrowError(/component had a falsey params.addonSlug parameter/);
|
||||
});
|
||||
|
||||
it('loads all reviews from state', () => {
|
||||
const reviews = [{ ...fakeReview, id: 1 }, { ...fakeReview, id: 2 }];
|
||||
const action = setAddonReviews({
|
||||
addonSlug: fakeAddon.slug, reviews, reviewCount: reviews.length,
|
||||
});
|
||||
store.dispatch(action);
|
||||
|
||||
const props = getMappedProps();
|
||||
expect(props.reviews).toEqual(action.payload.reviews);
|
||||
});
|
||||
|
||||
it('only loads existing reviews', () => {
|
||||
const props = getMappedProps();
|
||||
expect(props.reviews).toBe(undefined);
|
||||
expect(props.reviewCount).toBe(undefined);
|
||||
});
|
||||
|
||||
it('sets reviewCount prop from from state', () => {
|
||||
store.dispatch(setAddonReviews({
|
||||
addonSlug: fakeAddon.slug, reviews: [fakeReview], reviewCount: 1,
|
||||
}));
|
||||
|
||||
const props = getMappedProps();
|
||||
expect(props.reviewCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
import React from 'react';
|
||||
|
||||
import { denormalizeReview } from 'amo/actions/reviews';
|
||||
import AddonReview from 'amo/components/AddonReview';
|
||||
import AddonReviewListItem, {
|
||||
AddonReviewListItemBase,
|
||||
} from 'amo/components/AddonReviewListItem';
|
||||
import {
|
||||
dispatchClientMetadata, dispatchSignInActions, fakeReview,
|
||||
} from 'tests/unit/amo/helpers';
|
||||
import {
|
||||
createFakeEvent,
|
||||
getFakeI18nInst,
|
||||
shallowUntilTarget,
|
||||
} from 'tests/unit/helpers';
|
||||
import LoadingText from 'ui/components/LoadingText';
|
||||
import Rating from 'ui/components/Rating';
|
||||
|
||||
|
||||
describe(__filename, () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = dispatchClientMetadata().store;
|
||||
});
|
||||
|
||||
const render = (customProps = {}) => {
|
||||
const props = {
|
||||
i18n: getFakeI18nInst(),
|
||||
store,
|
||||
...customProps,
|
||||
};
|
||||
return shallowUntilTarget(
|
||||
<AddonReviewListItem {...props} />, AddonReviewListItemBase
|
||||
);
|
||||
};
|
||||
|
||||
const signInAndDispatchSavedReview = ({
|
||||
siteUserId = 123, reviewUserId = siteUserId,
|
||||
} = {}) => {
|
||||
dispatchSignInActions({ store, userId: siteUserId });
|
||||
return denormalizeReview({
|
||||
...fakeReview,
|
||||
user: {
|
||||
...fakeReview.user,
|
||||
id: reviewUserId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders a review', () => {
|
||||
const root = render({
|
||||
review: denormalizeReview({
|
||||
...fakeReview, id: 1, rating: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(root.find('h3'))
|
||||
.toHaveText(fakeReview.title);
|
||||
|
||||
expect(root.find('p'))
|
||||
.toHaveHTML(`<p>${fakeReview.body}</p>`);
|
||||
|
||||
expect(root.find('.AddonReviewListItem-by-line'))
|
||||
.toIncludeText(fakeReview.user.name);
|
||||
|
||||
const rating = root.find(Rating);
|
||||
expect(rating).toHaveProp('rating', 2);
|
||||
expect(rating).toHaveProp('readOnly', true);
|
||||
});
|
||||
|
||||
it('renders newlines in review bodies', () => {
|
||||
const fakeReviewWithNewLine = {
|
||||
...fakeReview,
|
||||
body: "It's awesome \n isn't it?",
|
||||
};
|
||||
const root = render({
|
||||
review: denormalizeReview(fakeReviewWithNewLine),
|
||||
});
|
||||
|
||||
expect(root.find('p').render().find('br'))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders loading text for falsy reviews', () => {
|
||||
const root = render({ review: null });
|
||||
|
||||
expect(root.find('h3').at(0).find(LoadingText))
|
||||
.toHaveLength(1);
|
||||
expect(root.find('p').at(0).find(LoadingText))
|
||||
.toHaveLength(1);
|
||||
expect(root.find('.AddonReviewListItem-by-line').at(0)
|
||||
.find(LoadingText)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not render review controls unless the user wrote a review', () => {
|
||||
dispatchSignInActions({ store });
|
||||
const root = render({ review: null });
|
||||
|
||||
expect(root.find('.AddonReviewListItem-controls')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not render controls when the review belongs to another user', () => {
|
||||
const review = signInAndDispatchSavedReview({
|
||||
siteUserId: 123, reviewUserId: 987,
|
||||
});
|
||||
const root = render({ review });
|
||||
|
||||
expect(root.find('.AddonReviewListItem-controls')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lets you edit your review', () => {
|
||||
const review = signInAndDispatchSavedReview();
|
||||
const root = render({ review });
|
||||
|
||||
const editButton = root.find('.AddonReviewListItem-edit');
|
||||
editButton.simulate('click', createFakeEvent());
|
||||
|
||||
const reviewComponent = root.find(AddonReview);
|
||||
expect(reviewComponent).toHaveLength(1);
|
||||
expect(reviewComponent).toHaveProp('review', review);
|
||||
});
|
||||
|
||||
it('hides AddonReview when the overlay is escaped', () => {
|
||||
const review = signInAndDispatchSavedReview();
|
||||
const root = render({ review });
|
||||
root.setState({ editingReview: true });
|
||||
|
||||
const reviewComponent = root.find(AddonReview);
|
||||
expect(reviewComponent).toHaveLength(1);
|
||||
|
||||
const onEscapeOverlay = reviewComponent.prop('onEscapeOverlay');
|
||||
// Simulate escaping the review.
|
||||
onEscapeOverlay();
|
||||
|
||||
expect(root.find(AddonReview)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hides AddonReview after a review has been submitted', () => {
|
||||
const review = signInAndDispatchSavedReview();
|
||||
const root = render({ review });
|
||||
root.setState({ editingReview: true });
|
||||
|
||||
const reviewComponent = root.find(AddonReview);
|
||||
expect(reviewComponent).toHaveLength(1);
|
||||
|
||||
const onReviewSubmitted = reviewComponent.prop('onReviewSubmitted');
|
||||
// Simulate submitting the review.
|
||||
onReviewSubmitted();
|
||||
|
||||
expect(root.find(AddonReview)).toHaveLength(0);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,9 @@
|
|||
import { setAddonReviews, setReview } from 'amo/actions/reviews';
|
||||
import reviews, { initialState } from 'amo/reducers/reviews';
|
||||
import {
|
||||
denormalizeReview, setAddonReviews, setReview,
|
||||
} from 'amo/actions/reviews';
|
||||
import reviewsReducer, {
|
||||
expandReviewObjects, initialState, storeReviewObjects,
|
||||
} from 'amo/reducers/reviews';
|
||||
import { fakeAddon, fakeReview } from 'tests/unit/amo/helpers';
|
||||
|
||||
describe('amo.reducers.reviews', () => {
|
||||
|
@ -28,12 +32,12 @@ describe('amo.reducers.reviews', () => {
|
|||
}
|
||||
|
||||
it('defaults to an empty object', () => {
|
||||
expect(reviews(undefined, { type: 'SOME_OTHER_ACTION' })).toEqual(initialState);
|
||||
expect(reviewsReducer(undefined, { type: 'SOME_OTHER_ACTION' })).toEqual(initialState);
|
||||
});
|
||||
|
||||
it('stores a user review', () => {
|
||||
const action = setFakeReview();
|
||||
const state = reviews(undefined, action);
|
||||
const state = reviewsReducer(undefined, action);
|
||||
const storedReview =
|
||||
state[fakeReview.user.id][fakeReview.addon.id][fakeReview.id];
|
||||
expect(storedReview).toEqual({
|
||||
|
@ -52,24 +56,31 @@ describe('amo.reducers.reviews', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stores a review object', () => {
|
||||
const review = { ...fakeReview, id: 1 };
|
||||
const action = setReview(review);
|
||||
const state = reviewsReducer(undefined, action);
|
||||
expect(state.byId[review.id]).toEqual(denormalizeReview(review));
|
||||
});
|
||||
|
||||
it('preserves existing user rating data', () => {
|
||||
let state;
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
addonId: 1,
|
||||
rating: 1,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 2,
|
||||
userId: 1,
|
||||
addonId: 2,
|
||||
rating: 5,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 3,
|
||||
userId: 2,
|
||||
addonId: 2,
|
||||
|
@ -87,17 +98,17 @@ describe('amo.reducers.reviews', () => {
|
|||
const userId = fakeReview.user.id;
|
||||
const addonId = fakeReview.addon.id;
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 1,
|
||||
versionId: 1,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 2,
|
||||
versionId: 2,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 3,
|
||||
versionId: 3,
|
||||
}));
|
||||
|
@ -110,7 +121,7 @@ describe('amo.reducers.reviews', () => {
|
|||
|
||||
it('preserves unrelated state', () => {
|
||||
let state = { ...initialState, somethingUnrelated: 'erp' };
|
||||
state = reviews(state, setFakeReview());
|
||||
state = reviewsReducer(state, setFakeReview());
|
||||
expect(state.somethingUnrelated).toEqual('erp');
|
||||
});
|
||||
|
||||
|
@ -119,17 +130,17 @@ describe('amo.reducers.reviews', () => {
|
|||
const userId = fakeReview.user.id;
|
||||
let state;
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 1,
|
||||
is_latest: true,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 2,
|
||||
is_latest: true,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 3,
|
||||
is_latest: true,
|
||||
}));
|
||||
|
@ -145,12 +156,12 @@ describe('amo.reducers.reviews', () => {
|
|||
const userId = fakeReview.user.id;
|
||||
let state;
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 1,
|
||||
is_latest: true,
|
||||
}));
|
||||
|
||||
state = reviews(state, setFakeReview({
|
||||
state = reviewsReducer(state, setFakeReview({
|
||||
id: 2,
|
||||
is_latest: false,
|
||||
}));
|
||||
|
@ -166,11 +177,11 @@ describe('amo.reducers.reviews', () => {
|
|||
const action = setAddonReviews({
|
||||
addonSlug: fakeAddon.slug, reviews: [review1, review2], reviewCount: 2,
|
||||
});
|
||||
const state = reviews(undefined, action);
|
||||
const state = reviewsReducer(undefined, action);
|
||||
const storedReviews = state.byAddon[fakeAddon.slug].reviews;
|
||||
expect(storedReviews.length).toEqual(2);
|
||||
expect(storedReviews[0].id).toEqual(review1.id);
|
||||
expect(storedReviews[1].id).toEqual(review2.id);
|
||||
expect(storedReviews[0]).toEqual(review1.id);
|
||||
expect(storedReviews[1]).toEqual(review2.id);
|
||||
});
|
||||
|
||||
it('preserves existing add-on reviews', () => {
|
||||
|
@ -181,23 +192,34 @@ describe('amo.reducers.reviews', () => {
|
|||
const review3 = { ...fakeReview, id: 4 };
|
||||
|
||||
let state;
|
||||
state = reviews(state, setAddonReviews({
|
||||
state = reviewsReducer(state, setAddonReviews({
|
||||
addonSlug: addon1.slug, reviews: [review1], reviewCount: 1,
|
||||
}));
|
||||
state = reviews(state, setAddonReviews({
|
||||
state = reviewsReducer(state, setAddonReviews({
|
||||
addonSlug: addon2.slug, reviews: [review2, review3], reviewCount: 2,
|
||||
}));
|
||||
|
||||
expect(state.byAddon[addon1.slug].reviews[0].id).toEqual(review1.id);
|
||||
expect(state.byAddon[addon2.slug].reviews[0].id).toEqual(review2.id);
|
||||
expect(state.byAddon[addon2.slug].reviews[1].id).toEqual(review3.id);
|
||||
expect(state.byAddon[addon1.slug].reviews[0]).toEqual(review1.id);
|
||||
expect(state.byAddon[addon2.slug].reviews[0]).toEqual(review2.id);
|
||||
expect(state.byAddon[addon2.slug].reviews[1]).toEqual(review3.id);
|
||||
});
|
||||
|
||||
it('stores review objects', () => {
|
||||
const review1 = fakeReview;
|
||||
const review2 = { ...fakeReview, id: 3 };
|
||||
const action = setAddonReviews({
|
||||
addonSlug: fakeAddon.slug, reviews: [review1, review2], reviewCount: 2,
|
||||
});
|
||||
const state = reviewsReducer(undefined, action);
|
||||
expect(state.byId[review1.id]).toEqual(denormalizeReview(review1));
|
||||
expect(state.byId[review2.id]).toEqual(denormalizeReview(review2));
|
||||
});
|
||||
|
||||
it('stores review counts', () => {
|
||||
const state = reviews(undefined, setAddonReviews({
|
||||
const state = reviewsReducer(undefined, setAddonReviews({
|
||||
addonSlug: 'slug1', reviews: [fakeReview], reviewCount: 1,
|
||||
}));
|
||||
const newState = reviews(state, setAddonReviews({
|
||||
const newState = reviewsReducer(state, setAddonReviews({
|
||||
addonSlug: 'slug2', reviews: [fakeReview, fakeReview], reviewCount: 2,
|
||||
}));
|
||||
|
||||
|
@ -205,4 +227,73 @@ describe('amo.reducers.reviews', () => {
|
|||
expect(newState.byAddon.slug2.reviewCount).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandReviewObjects', () => {
|
||||
it('expands IDs into objects', () => {
|
||||
const review1 = { ...fakeReview, id: 1 };
|
||||
const review2 = { ...fakeReview, id: 2 };
|
||||
const action = setAddonReviews({
|
||||
addonSlug: fakeAddon.slug,
|
||||
reviews: [review1, review2],
|
||||
reviewCount: 2,
|
||||
});
|
||||
const state = reviewsReducer(undefined, action);
|
||||
|
||||
const expanded = expandReviewObjects({
|
||||
state,
|
||||
reviews: state.byAddon[fakeAddon.slug].reviews,
|
||||
});
|
||||
|
||||
expect(expanded[0]).toEqual(denormalizeReview(review1));
|
||||
expect(expanded[1]).toEqual(denormalizeReview(review2));
|
||||
});
|
||||
|
||||
it('throws an error if the review does not exist', () => {
|
||||
const nonExistantIds = [99678];
|
||||
expect(() => {
|
||||
expandReviewObjects({
|
||||
state: initialState, reviews: nonExistantIds,
|
||||
});
|
||||
}).toThrow(/No stored review exists for ID 99678/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeReviewObjects', () => {
|
||||
it('stores review objects by ID', () => {
|
||||
const reviews = [
|
||||
denormalizeReview({ ...fakeReview, id: 1 }),
|
||||
denormalizeReview({ ...fakeReview, id: 2 }),
|
||||
];
|
||||
expect(storeReviewObjects({ state: initialState, reviews }))
|
||||
.toEqual({
|
||||
[reviews[0].id]: reviews[0],
|
||||
[reviews[1].id]: reviews[1],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing reviews', () => {
|
||||
const review1 = denormalizeReview({ ...fakeReview, id: 1 });
|
||||
const review2 = denormalizeReview({ ...fakeReview, id: 2 });
|
||||
|
||||
const state = initialState;
|
||||
const byId = storeReviewObjects({ state, reviews: [review1] });
|
||||
|
||||
expect(storeReviewObjects({
|
||||
state: { ...state, byId },
|
||||
reviews: [review2],
|
||||
})).toEqual({
|
||||
[review1.id]: review1,
|
||||
[review2.id]: review2,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error for falsy IDs', () => {
|
||||
const reviews = [
|
||||
denormalizeReview({ ...fakeReview, id: undefined }),
|
||||
];
|
||||
expect(() => {
|
||||
storeReviewObjects({ state: initialState, reviews });
|
||||
}).toThrow(/Cannot store review because review.id is falsy/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -45,6 +45,13 @@ describe('<Overlay />', () => {
|
|||
expect(root.overlayContainer.className).not.toContain('Overlay--visible');
|
||||
});
|
||||
|
||||
it('calls onEscapeOverlay when clicking the background', () => {
|
||||
const onEscapeOverlay = sinon.stub();
|
||||
const root = render({ visibleOnLoad: true, onEscapeOverlay });
|
||||
Simulate.click(root.overlayBackground);
|
||||
sinon.assert.called(onEscapeOverlay);
|
||||
});
|
||||
|
||||
it('is shown and hidden when `hide()` and `show()` are called', () => {
|
||||
const root = render();
|
||||
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import React from 'react';
|
||||
import { renderIntoDocument } from 'react-addons-test-utils';
|
||||
import {
|
||||
findRenderedComponentWithType, renderIntoDocument,
|
||||
} from 'react-addons-test-utils';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
|
||||
import Overlay from 'ui/components/Overlay';
|
||||
import OverlayCard from 'ui/components/OverlayCard';
|
||||
|
||||
|
||||
|
@ -15,6 +18,13 @@ describe('<OverlayCard />', () => {
|
|||
expect(root.overlayCard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('passes onEscapeOverlay to Overlay', () => {
|
||||
const onEscapeOverlay = sinon.stub();
|
||||
const root = render({ onEscapeOverlay });
|
||||
const overlay = findRenderedComponentWithType(root, Overlay);
|
||||
expect(overlay.props.onEscapeOverlay).toEqual(onEscapeOverlay);
|
||||
});
|
||||
|
||||
it('passes the header', () => {
|
||||
const root = render({ header: 'header' });
|
||||
const rootNode = findDOMNode(root);
|
||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -2543,9 +2543,9 @@ entities@^1.1.1, entities@~1.1.1:
|
|||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
|
||||
|
||||
enzyme-matchers@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/enzyme-matchers/-/enzyme-matchers-3.4.0.tgz#48c2db3d5c70c8ac3b0993f7b2673367457eea9c"
|
||||
enzyme-matchers@^3.8.3:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.yarnpkg.com/enzyme-matchers/-/enzyme-matchers-3.8.3.tgz#6269d47b0d81d5222745da503f27ac003ba208d2"
|
||||
dependencies:
|
||||
deep-equal-ident "^1.1.1"
|
||||
|
||||
|
@ -4384,12 +4384,12 @@ jest-environment-node@^21.0.2:
|
|||
jest-mock "^21.0.2"
|
||||
jest-util "^21.0.2"
|
||||
|
||||
jest-enzyme@^3.2.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-enzyme/-/jest-enzyme-3.4.0.tgz#c19bc8cc48cd8faa72e504077e5c1a9187bb93e2"
|
||||
jest-enzyme@^3.8.3:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-enzyme/-/jest-enzyme-3.8.3.tgz#5112fcc77d12cb75c3e26c09733f1831f5e45bb7"
|
||||
dependencies:
|
||||
"@types/react" "^15.0.22"
|
||||
enzyme-matchers "^3.4.0"
|
||||
enzyme-matchers "^3.8.3"
|
||||
enzyme-to-json "^1.5.0"
|
||||
|
||||
jest-get-type@^21.0.2:
|
||||
|
|
Загрузка…
Ссылка в новой задаче