Allow users to edit their reviews (#3220)

This commit is contained in:
Kumar McMillan 2017-09-26 12:18:54 -05:00 коммит произвёл GitHub
Родитель 66047c8b1c
Коммит 5a33c7458e
18 изменённых файлов: 868 добавлений и 358 удалений

Просмотреть файл

@ -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);

Просмотреть файл

@ -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: