Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
module.exports = {
apiHost: 'https://localhost',
allowErrorSimulation: true,
enableClientConsole: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this make test output (any) noiser? I imagine the aim of setting this to false here was to make tests not output a log of logging stuff.

Copy link
Contributor Author

@kumar303 kumar303 Jun 26, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the aim of setting this to false here was to make tests not output a log of logging stuff.

I sure hope not because that would be the most cruel trick ever played on a developer. It's 100% essential to see logging in test output. However, I would love to hide the test output for passing tests. Maybe that's possible. As for reducing noise, you can still use --silent.

};
34 changes: 33 additions & 1 deletion src/amo/actions/reviews.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* @flow */
import { SET_ADDON_REVIEWS, SET_REVIEW } from 'amo/constants';
import {
FETCH_REVIEWS, SET_ADDON_REVIEWS, SET_REVIEW,
} from 'amo/constants';
import type { ApiReviewType } from 'amo/api';

export type UserReviewType = {|
Expand Down Expand Up @@ -47,6 +49,36 @@ export const setReview = (review: ApiReviewType): SetReviewAction => {
return { type: SET_REVIEW, payload: denormalizeReview(review) };
};

type FetchReviewsParams = {|
addonSlug: string,
errorHandlerId: string,
page?: number,
|};

export type FetchReviewsAction = {|
type: string,
payload: {|
addonSlug: string,
errorHandlerId: string,
page: number,
|},
|};

export function fetchReviews(
{ addonSlug, errorHandlerId, page = 1 }: FetchReviewsParams
): FetchReviewsAction {
if (!errorHandlerId) {
throw new Error('errorHandlerId cannot be empty');
}
if (!addonSlug) {
throw new Error('addonSlug cannot be empty');
}
return {
type: FETCH_REVIEWS,
payload: { addonSlug, errorHandlerId, page },
};
}

export const setDenormalizedReview = (
review: UserReviewType
): SetReviewAction => {
Expand Down
3 changes: 2 additions & 1 deletion src/amo/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export function submitReview({
}

type GetReviewsParams = {|
addon?: number,
// This is the addon ID, slug, or guid.
addon?: number | string,
apiState?: ApiStateType,
filter?: string,
page?: number,
Expand Down
4 changes: 2 additions & 2 deletions src/amo/components/Addon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,11 @@ export class AddonBase extends React.Component {
renderShowMoreCard() {
const { addon, i18n } = this.props;
const addonType = addon ? addon.type : ADDON_TYPE_EXTENSION;
let description;

const descriptionProps = {};
if (addon) {
description = addon.description ? addon.description : addon.summary;
const description =
addon.description ? addon.description : addon.summary;
if (!description || !description.length) {
return null;
}
Expand Down
218 changes: 125 additions & 93 deletions src/amo/components/AddonReviewList.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
/* @flow */
/* eslint-disable react/no-unused-prop-types */
/* eslint-disable react/sort-comp, react/no-unused-prop-types */
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';

import Rating from 'ui/components/Rating';
import { setAddonReviews } from 'amo/actions/reviews';
import { getReviews } from 'amo/api';
import { fetchReviews } from 'amo/actions/reviews';
import { setViewContext } from 'amo/actions/viewContext';
import fallbackIcon from 'amo/img/icons/default-64.png';
import { fetchAddon } from 'core/actions/addons';
import Paginate from 'core/components/Paginate';
import { withErrorHandler } from 'core/errorHandler';
import translate from 'core/i18n/translate';
import {
isAllowedOrigin,
findAddon,
loadAddonIfNeeded,
safeAsyncConnect,
} from 'core/utils';
import { isAllowedOrigin, findAddon } from 'core/utils';
import log from 'core/logger';
import { parsePage } from 'core/searchUtils';
import Link from 'amo/components/Link';
import CardList from 'ui/components/CardList';
import type { ErrorHandlerType } from 'core/errorHandler';
import type { UserReviewType } from 'amo/actions/reviews';
import type { ReviewState } from 'amo/reducers/reviews';
import type { AddonType } from 'core/types/addons';
import type { DispatchFunc, ReduxStore } from 'core/types/redux';
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';

Expand All @@ -34,15 +34,67 @@ type AddonReviewListRouteParams = {|
type AddonReviewListProps = {|
i18n: Object,
addon?: AddonType,
dispatch: DispatchFunc,
errorHandler: ErrorHandlerType,
location: ReactRouterLocation,
params: AddonReviewListRouteParams,
reviewCount?: number,
reviews?: Array<UserReviewType>,
|};

type RenderReviewParams = {|
review?: UserReviewType,
key: string,
|};

export class AddonReviewListBase extends React.Component {
props: AddonReviewListProps;

componentWillMount() {
this.loadDataIfNeeded();
}

componentWillReceiveProps(nextProps: AddonReviewListProps) {
this.loadDataIfNeeded(nextProps);
}

loadDataIfNeeded(nextProps?: AddonReviewListProps) {
const {
addon, dispatch, errorHandler, params, reviews,
} = {
...this.props,
...nextProps,
};

if (errorHandler.hasError()) {
log.warn('Not loading data because of an error');
return;
}

if (!addon) {
dispatch(fetchAddon({ slug: params.addonSlug, errorHandler }));
} else {
dispatch(setViewContext(addon.type));
}

let location = this.props.location;
let locationChanged = false;
if (nextProps && nextProps.location) {
if (nextProps.location !== location) {
locationChanged = true;
}
location = nextProps.location;
}

if (!reviews || locationChanged) {
dispatch(fetchReviews({
addonSlug: params.addonSlug,
errorHandlerId: errorHandler.id,
page: parsePage(location.query.page),
}));
}
}

addonURL() {
const { addon } = this.props;
if (!addon) {
Expand All @@ -55,118 +107,101 @@ export class AddonReviewListBase extends React.Component {
return `${this.addonURL()}reviews/`;
}

renderReview(review: UserReviewType) {
renderReview({ review, key }: RenderReviewParams) {
const { i18n } = this.props;
const timestamp = i18n.moment(review.created).fromNow();

let byLine;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like byLine is a bit of a niche word... I know that's what this is but reviewAuthor would work equally well for what this is and might be a bit easier to parse.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Byline has been a standard word since the 1950s. Naming it reviewAuthor is awkward because the code already uses a variable named review.userName. Plus, this text contains name and date which makes it more of a byline than just an author name.

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 });
} else {
byLine = <LoadingText />;
}

return (
<li className="AddonReviewList-li">
<h3>{review.title}</h3>
<p>{review.body}</p>
<li className="AddonReviewList-li" key={key}>
<h3>{review ? review.title : <LoadingText />}</h3>
<p>{review ? review.body : <LoadingText />}</p>
<div className="AddonReviewList-by-line">
<Rating styleName="small" rating={review.rating} readOnly />
{/* L10n: Example: "from Jose, last week" */}
{i18n.sprintf(i18n.gettext('from %(authorName)s, %(timestamp)s'),
{ authorName: review.userName, timestamp })}
{review ?
<Rating styleName="small" rating={review.rating} readOnly />
: null
}
{byLine}
</div>
</li>
);
}

render() {
const { addon, location, params, i18n, reviewCount, reviews } = this.props;
const {
addon, errorHandler, location, params, i18n, reviewCount, reviews,
} = this.props;
if (!params.addonSlug) {
throw new Error('params.addonSlug cannot be falsey');
}
if (!reviews || !addon) {
// TODO: add a spinner
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, we finally did it! 😄

return <div>{i18n.gettext('Loading...')}</div>;

// When reviews have not loaded yet, make a list of 4 empty reviews
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// as a placeholder.
const allReviews = reviews || Array(4).fill(null);
const iconUrl = addon && isAllowedOrigin(addon.icon_url) ?
addon.icon_url : fallbackIcon;
const iconImage = (
<img src={iconUrl} alt={i18n.gettext('Add-on icon')} />
);

let header;
if (addon) {
header = i18n.sprintf(
i18n.gettext('Reviews for %(addonName)s'), { addonName: addon.name });
} else {
header = <LoadingText />;
}

const allReviews = reviews || [];
const iconUrl = isAllowedOrigin(addon.icon_url) ? addon.icon_url :
fallbackIcon;
let addonName;
if (addon) {
addonName = <Link to={this.addonURL()}>{addon.name}</Link>;
} else {
addonName = <LoadingText />;
}

return (
<div className="AddonReviewList">
{errorHandler.hasError() ? errorHandler.renderError() : null}
<div className="AddonReviewList-header">
<div className="AddonReviewList-header-icon">
<Link to={this.addonURL()}>
<img src={iconUrl} alt={i18n.gettext('Add-on icon')} />
</Link>
{addon ? <Link to={this.addonURL()}>{iconImage}</Link> : iconImage}
</div>
<div className="AddonReviewList-header-text">
<h1 className="visually-hidden">
{i18n.sprintf(i18n.gettext('Reviews for %(addonName)s'),
{ addonName: addon.name })}
</h1>
<h1 className="visually-hidden">{header}</h1>
<h2>{i18n.gettext('All written reviews')}</h2>
<h3><Link to={this.addonURL()}>{addon.name}</Link></h3>
<h3>{addonName}</h3>
</div>
</div>
<CardList>
<ul>
{allReviews.map((review) => this.renderReview(review))}
{allReviews.map((review, index) => {
return this.renderReview({ review, key: String(index) });
})}
</ul>
</CardList>
<Paginate
LinkComponent={Link}
count={reviewCount}
currentPage={parsePage(location.query.page)}
pathname={this.url()}
/>
{addon && reviewCount ?
<Paginate
LinkComponent={Link}
count={reviewCount}
currentPage={parsePage(location.query.page)}
pathname={this.url()}
/>
: null
}
</div>
);
}
}

export function loadAddonReviews(
{
addonId, addonSlug, dispatch, page = 1,
}: {|
addonId: number,
addonSlug: string,
dispatch: DispatchFunc,
page?: number,
|}
) {
return getReviews({ addon: addonId, page })
.then((response) => {
const allReviews = response.results;
// Ignore reviews with null bodies as those are incomplete.
// For example, the user selected a star rating but hasn't submitted
// review text yet.
const reviews = allReviews.filter((review) => Boolean(review.body));
dispatch(setAddonReviews({
addonSlug, reviews, reviewCount: response.count,
}));
});
}

export function loadInitialData(
{
location, params, store,
}: {|
location: ReactRouterLocation,
params: AddonReviewListRouteParams,
store: ReduxStore,
|}
) {
const { addonSlug } = params;
if (!addonSlug) {
return Promise.reject(new Error('missing URL param addonSlug'));
}
let page;
return new Promise((resolve) => {
page = parsePage(location.query.page);
return resolve();
})
.then(() => loadAddonIfNeeded({ store, params: { slug: addonSlug } }))
.then(() => findAddon(store.getState(), addonSlug))
.then((addon) => loadAddonReviews({
addonId: addon.id, addonSlug, dispatch: store.dispatch, page,
}));
}

export function mapStateToProps(
state: {| reviews: ReviewState |}, ownProps: AddonReviewListProps,
) {
Expand All @@ -183,10 +218,7 @@ export function mapStateToProps(
}

export default compose(
safeAsyncConnect([{
key: 'AddonReviewList',
promise: loadInitialData,
}]),
connect(mapStateToProps),
translate({ withRef: true }),
withErrorHandler({ name: 'AddonReviewList' }),
)(AddonReviewListBase);
1 change: 1 addition & 0 deletions src/amo/constants.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Action types.
export const FETCH_REVIEWS = 'FETCH_REVIEWS';
export const SET_ADDON_REVIEWS = 'SET_ADDON_REVIEWS';
export const SET_REVIEW = 'SET_REVIEW';

Expand Down
2 changes: 2 additions & 0 deletions src/amo/sagas/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { fork } from 'redux-saga/effects';
import addons from 'core/sagas/addons';

import categories from './categories';
import reviews from './reviews';


// Export all sagas for this app so runSaga can consume them.
export default function* rootSaga() {
yield [
fork(addons),
fork(categories),
fork(reviews),
];
}
Loading