feat: Add error pages
fix: Landing Page throws 404 not 500 * fixes #1033 * fixes #1605 * fixes #1616 * fixes #1673
This commit is contained in:
Родитель
41acb00abb
Коммит
d356292781
|
@ -163,6 +163,7 @@
|
|||
"config": "1.24.0",
|
||||
"deep-eql": "2.0.1",
|
||||
"dompurify": "0.8.4",
|
||||
"es6-error": "4.0.1",
|
||||
"express": "4.14.0",
|
||||
"extract-text-webpack-plugin": "1.0.1",
|
||||
"helmet": "3.4.0",
|
||||
|
@ -180,6 +181,7 @@
|
|||
"react-cookie": "1.0.4",
|
||||
"react-dom": "15.4.1",
|
||||
"react-helmet": "4.0.0",
|
||||
"react-nested-status": "0.1.2",
|
||||
"react-onclickoutside": "5.8.3",
|
||||
"react-photoswipe": "1.2.0",
|
||||
"react-redux": "4.4.6",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@import "~amo/css/inc/vars";
|
||||
|
||||
.ErrorPage {
|
||||
margin: $page-margin;
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'redux';
|
||||
|
||||
import { getErrorComponent } from 'amo/utils';
|
||||
|
||||
|
||||
export class ErrorPageBase extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
errorPage: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
errorPage: {},
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, errorPage } = this.props;
|
||||
|
||||
if (errorPage.hasError) {
|
||||
const ErrorComponent = getErrorComponent(errorPage.statusCode);
|
||||
return (
|
||||
<ErrorComponent error={errorPage.error} status={errorPage.statusCode} />
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
errorPage: state.errorPage,
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps),
|
||||
)(ErrorPageBase);
|
|
@ -5,6 +5,7 @@ import { asyncConnect } from 'redux-connect';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import LandingAddonsCard from 'amo/components/LandingAddonsCard';
|
||||
import NotFound from 'amo/components/NotFound';
|
||||
import { loadLandingAddons } from 'amo/utils';
|
||||
import {
|
||||
ADDON_TYPE_EXTENSION,
|
||||
|
@ -12,7 +13,12 @@ import {
|
|||
SEARCH_SORT_POPULAR,
|
||||
SEARCH_SORT_TOP_RATED,
|
||||
} from 'core/constants';
|
||||
import { apiAddonType, visibleAddonType } from 'core/utils';
|
||||
import { AddonTypeNotFound } from 'core/errors';
|
||||
import log from 'core/logger';
|
||||
import {
|
||||
apiAddonType as getApiAddonType,
|
||||
visibleAddonType as getVisibleAddonType,
|
||||
} from 'core/utils';
|
||||
import translate from 'core/i18n/translate';
|
||||
|
||||
import './LandingPage.scss';
|
||||
|
@ -20,21 +26,29 @@ import './LandingPage.scss';
|
|||
|
||||
export class LandingPageBase extends React.Component {
|
||||
static propTypes = {
|
||||
addonType: PropTypes.string.isRequired,
|
||||
apiAddonType: PropTypes.func.isRequired,
|
||||
featuredAddons: PropTypes.array,
|
||||
highlyRatedAddons: PropTypes.array,
|
||||
popularAddons: PropTypes.array,
|
||||
i18n: PropTypes.object.isRequired,
|
||||
params: PropTypes.objectOf({
|
||||
visibleAddonType: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}
|
||||
|
||||
contentForType(addonType) {
|
||||
const { i18n } = this.props;
|
||||
static defaultProps = {
|
||||
apiAddonType: getApiAddonType,
|
||||
}
|
||||
|
||||
contentForType(visibleAddonType) {
|
||||
const { apiAddonType, i18n } = this.props;
|
||||
const addonType = apiAddonType(visibleAddonType);
|
||||
|
||||
const contentForTypes = {
|
||||
[ADDON_TYPE_EXTENSION]: {
|
||||
featuredHeader: i18n.gettext('Featured extensions'),
|
||||
featuredFooterLink: {
|
||||
pathname: `/${visibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`,
|
||||
pathname: `/${getVisibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`,
|
||||
query: { addonType: ADDON_TYPE_EXTENSION },
|
||||
},
|
||||
featuredFooterText: i18n.gettext('More featured extensions'),
|
||||
|
@ -54,7 +68,7 @@ export class LandingPageBase extends React.Component {
|
|||
[ADDON_TYPE_THEME]: {
|
||||
featuredHeader: i18n.gettext('Featured themes'),
|
||||
featuredFooterLink: {
|
||||
pathname: `/${visibleAddonType(ADDON_TYPE_THEME)}/featured/`,
|
||||
pathname: `/${getVisibleAddonType(ADDON_TYPE_THEME)}/featured/`,
|
||||
query: { addonType: ADDON_TYPE_THEME },
|
||||
},
|
||||
featuredFooterText: i18n.gettext('More featured themes'),
|
||||
|
@ -73,19 +87,26 @@ export class LandingPageBase extends React.Component {
|
|||
},
|
||||
};
|
||||
|
||||
if (contentForTypes[addonType]) {
|
||||
return contentForTypes[addonType];
|
||||
}
|
||||
|
||||
throw new Error(`No LandingPage content for addonType: ${addonType}`);
|
||||
return { addonType, html: contentForTypes[addonType] };
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
addonType, featuredAddons, highlyRatedAddons, popularAddons,
|
||||
} = this.props;
|
||||
const { featuredAddons, highlyRatedAddons, popularAddons } = this.props;
|
||||
const { visibleAddonType } = this.props.params;
|
||||
|
||||
const html = this.contentForType(addonType);
|
||||
let content;
|
||||
try {
|
||||
content = this.contentForType(visibleAddonType);
|
||||
} catch (err) {
|
||||
if (err instanceof AddonTypeNotFound) {
|
||||
log.info('Rendering <NotFound /> for error:', err);
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { addonType, html } = content;
|
||||
|
||||
return (
|
||||
<div className={classNames('LandingPage', `LandingPage-${addonType}`)}>
|
||||
|
@ -108,9 +129,8 @@ export class LandingPageBase extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
export function mapStateToProps(state, ownProps) {
|
||||
export function mapStateToProps(state) {
|
||||
return {
|
||||
addonType: apiAddonType(ownProps.params.visibleAddonType),
|
||||
featuredAddons: state.landing.featured.results,
|
||||
highlyRatedAddons: state.landing.highlyRated.results,
|
||||
popularAddons: state.landing.popular.results,
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { oneLine } from 'common-tags';
|
||||
import { compose } from 'redux';
|
||||
import NestedStatus from 'react-nested-status';
|
||||
|
||||
import translate from 'core/i18n/translate';
|
||||
import { sanitizeHTML } from 'core/utils';
|
||||
import Card from 'ui/components/Card';
|
||||
|
||||
import 'amo/components/ErrorPage/ErrorPage.scss';
|
||||
|
||||
|
||||
export class NotFoundBase extends React.Component {
|
||||
static propTypes = {
|
||||
i18n: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { i18n } = this.props;
|
||||
|
||||
const fileAnIssueText = i18n.sprintf(i18n.gettext(oneLine`
|
||||
If you are signed in and think this message is an error, please
|
||||
<a href="%(url)s">file an issue</a>. Tell us where you came from
|
||||
and what you were trying to access, and we'll fix the issue.`),
|
||||
{ url: 'https://github.com/mozilla/addons-frontend/issues/new/' });
|
||||
|
||||
// TODO: Check for signed in state and offer different messages.
|
||||
// TODO: Offer a sign in link/button inside the error page.
|
||||
/* eslint-disable react/no-danger */
|
||||
return (
|
||||
<NestedStatus code={401}>
|
||||
<Card className="ErrorPage NotAuthorized"
|
||||
header={i18n.gettext('Not Authorized')}>
|
||||
<p>
|
||||
{i18n.gettext(oneLine`
|
||||
Sorry, but you aren't authorized to access this page. If you
|
||||
aren't signed in, try signing in using the link at the top
|
||||
of the page.`)}
|
||||
</p>
|
||||
|
||||
<p dangerouslySetInnerHTML={sanitizeHTML(fileAnIssueText, ['a'])} />
|
||||
</Card>
|
||||
</NestedStatus>
|
||||
);
|
||||
/* eslint-enable react/no-danger */
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
translate({ withRef: true }),
|
||||
)(NotFoundBase);
|
|
@ -0,0 +1,72 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { oneLine } from 'common-tags';
|
||||
import { compose } from 'redux';
|
||||
import NestedStatus from 'react-nested-status';
|
||||
|
||||
import Link from 'amo/components/Link';
|
||||
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
|
||||
import translate from 'core/i18n/translate';
|
||||
import { sanitizeHTML, visibleAddonType } from 'core/utils';
|
||||
import Card from 'ui/components/Card';
|
||||
|
||||
import 'amo/components/ErrorPage/ErrorPage.scss';
|
||||
|
||||
|
||||
export class NotFoundBase extends React.Component {
|
||||
static propTypes = {
|
||||
i18n: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { i18n } = this.props;
|
||||
|
||||
const fileAnIssueText = i18n.sprintf(i18n.gettext(oneLine`
|
||||
If you followed a link from somewhere, please
|
||||
<a href="%(url)s">file an issue</a>. Tell us where you came from and
|
||||
what you were looking for, and we'll do our best to fix it.`),
|
||||
{ url: 'https://github.com/mozilla/addons-frontend/issues/new/' });
|
||||
|
||||
|
||||
/* eslint-disable react/no-danger */
|
||||
return (
|
||||
<NestedStatus code={404}>
|
||||
<Card className="ErrorPage NotFound"
|
||||
header={i18n.gettext('Page not found')}>
|
||||
<p>
|
||||
{i18n.gettext(oneLine`
|
||||
Sorry, but we can't find anything at the address you entered.
|
||||
If you followed a link to an add-on, it's possible that add-on
|
||||
has been removed by its author.`)}
|
||||
</p>
|
||||
|
||||
<h2>{i18n.gettext('Suggested Pages')}</h2>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<Link to={`/${visibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`}>
|
||||
{i18n.gettext('Browse all extensions')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to={`/${visibleAddonType(ADDON_TYPE_THEME)}/featured/`}>
|
||||
{i18n.gettext('Browse all themes')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/">
|
||||
{i18n.gettext('Add-ons Home Page')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p dangerouslySetInnerHTML={sanitizeHTML(fileAnIssueText, ['a'])} />
|
||||
</Card>
|
||||
</NestedStatus>
|
||||
);
|
||||
/* eslint-enable react/no-danger */
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
translate({ withRef: true }),
|
||||
)(NotFoundBase);
|
|
@ -0,0 +1,71 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { oneLine } from 'common-tags';
|
||||
import { compose } from 'redux';
|
||||
import NestedStatus from 'react-nested-status';
|
||||
|
||||
import Link from 'amo/components/Link';
|
||||
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
|
||||
import translate from 'core/i18n/translate';
|
||||
import { sanitizeHTML, visibleAddonType } from 'core/utils';
|
||||
import Card from 'ui/components/Card';
|
||||
|
||||
import 'amo/components/ErrorPage/ErrorPage.scss';
|
||||
|
||||
|
||||
export class ServerErrorBase extends React.Component {
|
||||
static propTypes = {
|
||||
i18n: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { i18n } = this.props;
|
||||
|
||||
const fileAnIssueText = i18n.gettext(oneLine`
|
||||
If you have additional information that would help us you can
|
||||
<a href="https://github.com/mozilla/addons-frontend/issues/new/">file an
|
||||
issue</a>. Tell us what steps you took that lead to the error and we'll
|
||||
do our best to fix it.`);
|
||||
|
||||
/* eslint-disable react/no-danger */
|
||||
return (
|
||||
<NestedStatus code={500}>
|
||||
<Card className="ErrorPage ServerError"
|
||||
header={i18n.gettext('Server Error')}>
|
||||
<p>
|
||||
{i18n.gettext(oneLine`
|
||||
Sorry, but there was an error with our server and we couldn't
|
||||
complete your request. We have logged this error and will
|
||||
investigate it.`)}
|
||||
</p>
|
||||
|
||||
<h2>{i18n.gettext('Suggested Pages')}</h2>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<Link to={`/${visibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`}>
|
||||
{i18n.gettext('Browse all extensions')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to={`/${visibleAddonType(ADDON_TYPE_THEME)}/featured/`}>
|
||||
{i18n.gettext('Browse all themes')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/">
|
||||
{i18n.gettext('Add-ons Home Page')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p dangerouslySetInnerHTML={sanitizeHTML(fileAnIssueText, ['a'])} />
|
||||
</Card>
|
||||
</NestedStatus>
|
||||
);
|
||||
/* eslint-enable react/no-danger */
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
translate({ withRef: true }),
|
||||
)(ServerErrorBase);
|
|
@ -9,10 +9,10 @@ import { compose } from 'redux';
|
|||
import 'core/fonts/fira.scss';
|
||||
import 'amo/css/App.scss';
|
||||
import SearchForm from 'amo/components/SearchForm';
|
||||
import DefaultErrorPage from 'amo/components/ErrorPage';
|
||||
import { addChangeListeners } from 'core/addonManager';
|
||||
import { INSTALL_STATE } from 'core/constants';
|
||||
import InfoDialog from 'core/containers/InfoDialog';
|
||||
import { handleResourceErrors } from 'core/resourceErrors/decorator';
|
||||
import translate from 'core/i18n/translate';
|
||||
import Footer from 'amo/components/Footer';
|
||||
import MastHead from 'amo/components/MastHead';
|
||||
|
@ -20,6 +20,7 @@ import MastHead from 'amo/components/MastHead';
|
|||
|
||||
export class AppBase extends React.Component {
|
||||
static propTypes = {
|
||||
ErrorPage: PropTypes.node.isRequired,
|
||||
FooterComponent: PropTypes.node.isRequired,
|
||||
InfoDialogComponent: PropTypes.node.isRequired,
|
||||
MastHeadComponent: PropTypes.node.isRequired,
|
||||
|
@ -34,6 +35,7 @@ export class AppBase extends React.Component {
|
|||
}
|
||||
|
||||
static defaultProps = {
|
||||
ErrorPage: DefaultErrorPage,
|
||||
FooterComponent: Footer,
|
||||
InfoDialogComponent: InfoDialog,
|
||||
MastHeadComponent: MastHead,
|
||||
|
@ -57,6 +59,7 @@ export class AppBase extends React.Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
ErrorPage,
|
||||
FooterComponent,
|
||||
InfoDialogComponent,
|
||||
MastHeadComponent,
|
||||
|
@ -66,6 +69,7 @@ export class AppBase extends React.Component {
|
|||
lang,
|
||||
location,
|
||||
} = this.props;
|
||||
|
||||
const isHomePage = Boolean(location.pathname && location.pathname.match(
|
||||
new RegExp(`^\\/${lang}\\/${clientApp}\\/?$`)));
|
||||
const query = location.query ? location.query.q : null;
|
||||
|
@ -77,7 +81,7 @@ export class AppBase extends React.Component {
|
|||
SearchFormComponent={SearchForm} isHomePage={isHomePage} location={location}
|
||||
query={query} ref={(ref) => { this.mastHead = ref; }} />
|
||||
<div className="App-content">
|
||||
{children}
|
||||
<ErrorPage>{children}</ErrorPage>
|
||||
</div>
|
||||
<FooterComponent handleViewDesktop={this.onViewDesktop}
|
||||
location={location} />
|
||||
|
@ -100,7 +104,6 @@ export function mapDispatchToProps(dispatch) {
|
|||
}
|
||||
|
||||
export default compose(
|
||||
handleResourceErrors,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
translate({ withRef: true }),
|
||||
)(AppBase);
|
||||
|
|
|
@ -14,3 +14,5 @@ $breakpoints: (
|
|||
medium: '(min-width: 500px)',
|
||||
large: '(min-width: 720px)'
|
||||
);
|
||||
|
||||
$page-margin: 20px 10px;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import config from 'config';
|
||||
import React from 'react';
|
||||
import { IndexRoute, Route } from 'react-router';
|
||||
|
||||
|
@ -12,7 +13,10 @@ import FeaturedAddons from './components/FeaturedAddons';
|
|||
import LandingPage from './components/LandingPage';
|
||||
import Home from './containers/Home';
|
||||
import DetailPage from './containers/DetailPage';
|
||||
import NotAuthorized from './components/NotAuthorized';
|
||||
import NotFound from './components/NotFound';
|
||||
import SearchPage from './containers/SearchPage';
|
||||
import ServerError from './components/ServerError';
|
||||
|
||||
|
||||
export default (
|
||||
|
@ -26,6 +30,12 @@ export default (
|
|||
<Route path=":visibleAddonType/:slug/" component={CategoryPage} />
|
||||
<Route path="fxa-authenticate" component={HandleLogin} />
|
||||
<Route path="search/" component={SearchPage} />
|
||||
<Route path="401/"
|
||||
component={config.get('isDevelopment') ? NotAuthorized : NotFound} />
|
||||
<Route path="404/" component={NotFound} />
|
||||
<Route path="500/"
|
||||
component={config.get('isDevelopment') ? ServerError : NotFound} />
|
||||
<Route path=":visibleAddonType/" component={LandingPage} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Route>
|
||||
);
|
||||
|
|
|
@ -9,6 +9,7 @@ import api from 'core/reducers/api';
|
|||
import auth from 'core/reducers/authentication';
|
||||
import categories from 'core/reducers/categories';
|
||||
import errors from 'core/reducers/errors';
|
||||
import errorPage from 'core/reducers/errorPage';
|
||||
import infoDialog from 'core/reducers/infoDialog';
|
||||
import installations from 'core/reducers/installations';
|
||||
import search from 'core/reducers/search';
|
||||
|
@ -23,6 +24,7 @@ export default function createStore(initialState = {}) {
|
|||
auth,
|
||||
categories,
|
||||
errors,
|
||||
errorPage,
|
||||
featured,
|
||||
infoDialog,
|
||||
installations,
|
||||
|
|
|
@ -4,6 +4,9 @@ import {
|
|||
} from 'amo/constants';
|
||||
import { getFeatured, loadFeatured } from 'amo/actions/featured';
|
||||
import { getLanding, loadLanding, failLanding } from 'amo/actions/landing';
|
||||
import NotAuthorized from 'amo/components/NotAuthorized';
|
||||
import NotFound from 'amo/components/NotFound';
|
||||
import ServerError from 'amo/components/ServerError';
|
||||
import { featured as featuredAPI, search } from 'core/api';
|
||||
import { SEARCH_SORT_POPULAR, SEARCH_SORT_TOP_RATED } from 'core/constants';
|
||||
import { apiAddonType } from 'core/utils';
|
||||
|
@ -50,7 +53,23 @@ export function fetchLandingAddons({ addonType, api, dispatch }) {
|
|||
|
||||
export function loadLandingAddons({ store: { dispatch, getState }, params }) {
|
||||
const state = getState();
|
||||
const addonType = apiAddonType(params.visibleAddonType);
|
||||
try {
|
||||
const addonType = apiAddonType(params.visibleAddonType);
|
||||
|
||||
return fetchLandingAddons({ addonType, api: state.api, dispatch });
|
||||
return fetchLandingAddons({ addonType, api: state.api, dispatch });
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorComponent(status) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return NotAuthorized;
|
||||
case 404:
|
||||
return NotFound;
|
||||
case 500:
|
||||
default:
|
||||
return ServerError;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,11 @@ export const validInstallStates = [
|
|||
UNKNOWN,
|
||||
];
|
||||
|
||||
// redux-connect action types; we watch for these in our `errorPage`
|
||||
// reducer to display error pages.
|
||||
export const REDUX_CONNECT_END_GLOBAL_LOAD = '@redux-conn/END_GLOBAL_LOAD';
|
||||
export const REDUX_CONNECT_LOAD_FAIL = '@redux-conn/LOAD_FAIL';
|
||||
|
||||
// Add-on error states.
|
||||
export const DOWNLOAD_FAILED = 'DOWNLOAD_FAILED';
|
||||
export const INSTALL_FAILED = 'INSTALL_FAILED';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
import ExtendableError from 'es6-error';
|
||||
|
||||
|
||||
export class AddonTypeNotFound extends ExtendableError {}
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
REDUX_CONNECT_END_GLOBAL_LOAD,
|
||||
REDUX_CONNECT_LOAD_FAIL,
|
||||
} from 'core/constants';
|
||||
|
||||
export const initialState = {
|
||||
clearOnNext: false,
|
||||
error: null,
|
||||
hasError: false,
|
||||
statusCode: null,
|
||||
};
|
||||
|
||||
export default function errorPage(state = initialState, action) {
|
||||
const { payload } = action;
|
||||
switch (action.type) {
|
||||
case REDUX_CONNECT_END_GLOBAL_LOAD:
|
||||
if (state.clearOnNext) {
|
||||
return initialState;
|
||||
}
|
||||
return { ...state, clearOnNext: true };
|
||||
case REDUX_CONNECT_LOAD_FAIL: {
|
||||
// Default to a 500 error if we don't have a status code from our
|
||||
// response. See:
|
||||
// github.com/mozilla/addons-frontend/pull/1685#discussion_r99243105
|
||||
let statusCode = 500;
|
||||
if (payload.error && payload.error.response &&
|
||||
payload.error.response.status
|
||||
) {
|
||||
statusCode = payload.error.response.status;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
error: payload.error,
|
||||
hasError: true,
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getReduxConnectError } from './reduxConnectErrors';
|
||||
|
||||
|
||||
/*
|
||||
* Display resource errors such as a 404, 500, etc.
|
||||
*
|
||||
* Currently, the only way to produce a resource error is if an
|
||||
* asyncConnect callback returns one from its fetch() promise.
|
||||
*/
|
||||
function ResourceError(props) {
|
||||
const {
|
||||
reduxAsyncConnect,
|
||||
WrappedComponent,
|
||||
...componentProps
|
||||
} = props;
|
||||
|
||||
if (reduxAsyncConnect && reduxAsyncConnect.loadState) {
|
||||
const reduxResult = getReduxConnectError(reduxAsyncConnect.loadState);
|
||||
if (reduxResult.error) {
|
||||
// TODO: This will be prettier once we implement real error pages.
|
||||
// https://github.com/mozilla/addons-frontend/issues/1033
|
||||
return <div>{reduxResult.error}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return <WrappedComponent {...componentProps} />;
|
||||
}
|
||||
|
||||
/*
|
||||
* If a resource error occurs, render a ResourceError, otherwise render
|
||||
* the wrapped component.
|
||||
*
|
||||
* You only need to use this once in the top-level App component, like this:
|
||||
*
|
||||
import { handleResourceErrors } from 'core/resourceErrors/decorator';
|
||||
|
||||
class App extends React.Component {
|
||||
render() {
|
||||
// renders the top-level App with routes and all.
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
handleResourceErrors,
|
||||
)(App);
|
||||
*
|
||||
* This is complementary to server side rendering which already renders
|
||||
* resource errors by default. This decorator handles the case where client
|
||||
* side navigation might result in the same server 404 scenario.
|
||||
*/
|
||||
export function handleResourceErrors(WrappedComponent) {
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxAsyncConnect: state.reduxAsyncConnect,
|
||||
WrappedComponent,
|
||||
});
|
||||
|
||||
return compose(
|
||||
connect(mapStateToProps),
|
||||
)(ResourceError);
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import log from 'core/logger';
|
||||
|
||||
|
||||
const errorPageText = {
|
||||
401: 'Unauthorized',
|
||||
404: 'Not Found',
|
||||
500: 'Internal Server Error',
|
||||
};
|
||||
|
||||
export function getErrorMsg(statusCode) {
|
||||
const statusKey = statusCode.toString();
|
||||
// TODO: I guess when we make a real error page handler we'll map out
|
||||
// all possible statuses.
|
||||
return errorPageText[statusKey] || 'Unexpected Error';
|
||||
}
|
||||
|
||||
export function getReduxConnectError(reduxConnectLoadState) {
|
||||
// Create a list of any apiErrors detected.
|
||||
const apiErrors = Object.keys(reduxConnectLoadState)
|
||||
.map((item) => reduxConnectLoadState[item].error)
|
||||
.filter((item) => Boolean(item));
|
||||
let status;
|
||||
|
||||
if (apiErrors.length === 1) {
|
||||
// If we have a single API error reflect that in the page's response.
|
||||
status = apiErrors[0].response.status;
|
||||
} else if (apiErrors.length > 1) {
|
||||
// Otherwise we have multiple api errors it should be logged
|
||||
// and throw a 500.
|
||||
log.error(apiErrors);
|
||||
status = 500;
|
||||
}
|
||||
|
||||
return { status, error: status ? getErrorMsg(status) : undefined };
|
||||
}
|
|
@ -10,16 +10,15 @@ import helmet from 'helmet';
|
|||
import cookie from 'react-cookie';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/server';
|
||||
import NestedStatus from 'react-nested-status';
|
||||
import { Provider } from 'react-redux';
|
||||
import { match } from 'react-router';
|
||||
import { ReduxAsyncConnect, loadOnServer } from 'redux-connect';
|
||||
import { loadFail } from 'redux-connect/lib/store';
|
||||
import WebpackIsomorphicTools from 'webpack-isomorphic-tools';
|
||||
|
||||
import { createApiError } from 'core/api';
|
||||
import ServerHtml from 'core/containers/ServerHtml';
|
||||
import {
|
||||
getErrorMsg,
|
||||
getReduxConnectError,
|
||||
} from 'core/resourceErrors/reduxConnectErrors';
|
||||
import { prefixMiddleWare } from 'core/middleware';
|
||||
import { convertBoolean } from 'core/utils';
|
||||
import { setClientApp, setLang, setJwt } from 'core/actions';
|
||||
|
@ -54,17 +53,55 @@ function getNoScriptStyles({ appName }) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function showErrorPage(res, status) {
|
||||
let adjustedStatus = status;
|
||||
let error = getErrorMsg(adjustedStatus);
|
||||
if (!error) {
|
||||
adjustedStatus = 500;
|
||||
error = getErrorMsg(adjustedStatus);
|
||||
const appName = config.get('appName');
|
||||
|
||||
function getPageProps({ noScriptStyles = '', store, req, res }) {
|
||||
// Get SRI for deployed services only.
|
||||
const sriData = (isDeployed) ? JSON.parse(
|
||||
fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json'))
|
||||
) : {};
|
||||
|
||||
// Check the lang supplied by res.locals.lang for validity
|
||||
// or fall-back to the default.
|
||||
const lang = isValidLang(res.locals.lang) ?
|
||||
res.locals.lang : config.get('defaultLang');
|
||||
const dir = getDirection(lang);
|
||||
store.dispatch(setLang(lang));
|
||||
if (res.locals.clientApp) {
|
||||
store.dispatch(setClientApp(res.locals.clientApp));
|
||||
} else if (req && req.url) {
|
||||
log.warn(`No clientApp for this URL: ${req.url}`);
|
||||
} else {
|
||||
log.warn('No clientApp (error)');
|
||||
}
|
||||
return res.status(adjustedStatus).end(error);
|
||||
|
||||
return {
|
||||
appName,
|
||||
assets: webpackIsomorphicTools.assets(),
|
||||
htmlLang: lang,
|
||||
htmlDir: dir,
|
||||
includeSri: isDeployed,
|
||||
noScriptStyles,
|
||||
sriData,
|
||||
store,
|
||||
trackingEnabled: convertBoolean(config.get('trackingEnabled')),
|
||||
};
|
||||
}
|
||||
|
||||
const appName = config.get('appName');
|
||||
function showErrorPage({ createStore, error = {}, req, res, status }) {
|
||||
const store = createStore();
|
||||
const pageProps = getPageProps({ store, req, res });
|
||||
|
||||
const apiError = createApiError({ response: { status } });
|
||||
store.dispatch(loadFail('ServerBase', { ...apiError, ...error }));
|
||||
|
||||
const HTML = ReactDOM.renderToString(
|
||||
<ServerHtml {...pageProps} />);
|
||||
const httpStatus = NestedStatus.rewind();
|
||||
return res.status(status || httpStatus)
|
||||
.send(`<!DOCTYPE html>\n${HTML}`)
|
||||
.end();
|
||||
}
|
||||
|
||||
function logRequests(req, res, next) {
|
||||
const start = new Date();
|
||||
|
@ -160,55 +197,30 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
|
|||
) => {
|
||||
cookie.plugToRequest(req, res);
|
||||
|
||||
if (err) {
|
||||
log.error({ err, req });
|
||||
return showErrorPage(res, 500);
|
||||
}
|
||||
|
||||
if (!renderProps) {
|
||||
return showErrorPage(res, 404);
|
||||
}
|
||||
|
||||
const store = createStore();
|
||||
const token = cookie.load(config.get('cookieName'));
|
||||
if (token) {
|
||||
store.dispatch(setJwt(token));
|
||||
}
|
||||
// Get SRI for deployed services only.
|
||||
const sriData = (isDeployed) ? JSON.parse(
|
||||
fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json'))
|
||||
) : {};
|
||||
|
||||
// Check the lang supplied by res.locals.lang for validity
|
||||
// or fall-back to the default.
|
||||
const lang = isValidLang(res.locals.lang) ?
|
||||
res.locals.lang : config.get('defaultLang');
|
||||
const dir = getDirection(lang);
|
||||
const locale = langToLocale(lang);
|
||||
store.dispatch(setLang(lang));
|
||||
if (res.locals.clientApp) {
|
||||
store.dispatch(setClientApp(res.locals.clientApp));
|
||||
} else {
|
||||
log.warn(`No clientApp for this URL: ${req.url}`);
|
||||
// github.com/mozilla/addons-frontend/pull/1685#discussion_r99705186
|
||||
if (err) {
|
||||
return showErrorPage({ createStore, status: 500, req, res });
|
||||
}
|
||||
|
||||
function hydrateOnClient(props = {}) {
|
||||
const pageProps = {
|
||||
appName: appInstanceName,
|
||||
assets: webpackIsomorphicTools.assets(),
|
||||
htmlLang: lang,
|
||||
htmlDir: dir,
|
||||
includeSri: isDeployed,
|
||||
noScriptStyles,
|
||||
sriData,
|
||||
store,
|
||||
trackingEnabled: convertBoolean(config.get('trackingEnabled')),
|
||||
...props,
|
||||
};
|
||||
if (!renderProps) {
|
||||
return showErrorPage({ createStore, status: 404, req, res });
|
||||
}
|
||||
|
||||
const pageProps = getPageProps({ noScriptStyles, store, req, res });
|
||||
const { htmlLang } = pageProps;
|
||||
const locale = langToLocale(htmlLang);
|
||||
|
||||
function hydrateOnClient(props = {}) {
|
||||
const HTML = ReactDOM.renderToString(
|
||||
<ServerHtml {...pageProps} />);
|
||||
res.send(`<!DOCTYPE html>\n${HTML}`);
|
||||
<ServerHtml {...pageProps} {...props} />);
|
||||
const httpStatus = NestedStatus.rewind();
|
||||
res.status(httpStatus).send(`<!DOCTYPE html>\n${HTML}`);
|
||||
}
|
||||
|
||||
// Set disableSSR to true to debug
|
||||
|
@ -233,7 +245,7 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
|
|||
log.info(
|
||||
`Falling back to default lang: "${config.get('defaultLang')}".`);
|
||||
}
|
||||
const i18n = makeI18n(i18nData, lang);
|
||||
const i18n = makeI18n(i18nData, htmlLang);
|
||||
|
||||
const InitialComponent = (
|
||||
<I18nProvider i18n={i18n}>
|
||||
|
@ -243,25 +255,30 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
|
|||
</I18nProvider>
|
||||
);
|
||||
|
||||
const asyncConnectLoadState = store.getState().reduxAsyncConnect.loadState || {};
|
||||
const reduxResult = getReduxConnectError(asyncConnectLoadState);
|
||||
if (reduxResult.status) {
|
||||
return showErrorPage(res, reduxResult.status);
|
||||
const errorPage = store.getState().errorPage;
|
||||
if (errorPage && errorPage.hasError) {
|
||||
return showErrorPage({
|
||||
createStore,
|
||||
error: errorPage.error,
|
||||
req,
|
||||
res,
|
||||
status: errorPage.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
return hydrateOnClient({ component: InitialComponent });
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error({ err: error });
|
||||
return showErrorPage(res, 500);
|
||||
return showErrorPage({ createStore, error, status: 500, req, res });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
log.error({ err });
|
||||
return showErrorPage(res, 500);
|
||||
app.use((error, req, res, next) => {
|
||||
log.error({ err: error });
|
||||
return showErrorPage({ createStore, error, status: 500, req, res });
|
||||
});
|
||||
|
||||
return app;
|
||||
|
|
|
@ -4,6 +4,7 @@ import { applyMiddleware, compose } from 'redux';
|
|||
import createLogger from 'redux-logger';
|
||||
import config from 'config';
|
||||
|
||||
|
||||
/*
|
||||
* Enhance a redux store with common middleware.
|
||||
*
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
API_ADDON_TYPES_MAPPING,
|
||||
VISIBLE_ADDON_TYPES_MAPPING,
|
||||
} from 'core/constants';
|
||||
import { AddonTypeNotFound } from 'core/errors';
|
||||
import log from 'core/logger';
|
||||
import purify from 'core/purify';
|
||||
|
||||
|
@ -163,7 +164,8 @@ export function apiAddonType(addonType) {
|
|||
if (!Object.prototype.hasOwnProperty.call(
|
||||
API_ADDON_TYPES_MAPPING, addonType
|
||||
)) {
|
||||
throw new Error(`"${addonType}" not found in API_ADDON_TYPES_MAPPING`);
|
||||
throw new AddonTypeNotFound(
|
||||
`"${addonType}" not found in API_ADDON_TYPES_MAPPING`);
|
||||
}
|
||||
return API_ADDON_TYPES_MAPPING[addonType];
|
||||
}
|
||||
|
@ -172,7 +174,8 @@ export function visibleAddonType(addonType) {
|
|||
if (!Object.prototype.hasOwnProperty.call(
|
||||
VISIBLE_ADDON_TYPES_MAPPING, addonType
|
||||
)) {
|
||||
throw new Error(`"${addonType}" not found in VISIBLE_ADDON_TYPES_MAPPING`);
|
||||
throw new AddonTypeNotFound(
|
||||
`"${addonType}" not found in VISIBLE_ADDON_TYPES_MAPPING`);
|
||||
}
|
||||
return VISIBLE_ADDON_TYPES_MAPPING[addonType];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
renderIntoDocument,
|
||||
findRenderedComponentWithType,
|
||||
} from 'react-addons-test-utils';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { loadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import ErrorPage, { mapStateToProps } from 'amo/components/ErrorPage';
|
||||
import createStore from 'amo/store';
|
||||
import { createApiError } from 'core/api';
|
||||
import { getFakeI18nInst } from 'tests/client/helpers';
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import { signedInApiState } from 'tests/client/amo/helpers';
|
||||
|
||||
|
||||
describe('<ErrorPage />', () => {
|
||||
function render({ ...props }, store = createStore(signedInApiState)) {
|
||||
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
|
||||
<Provider store={store}>
|
||||
<I18nProvider i18n={getFakeI18nInst()}>
|
||||
<ErrorPage {...props} />
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
), ErrorPage));
|
||||
}
|
||||
|
||||
it('renders children when there are no errors', () => {
|
||||
const rootNode = render({ children: <div>hello</div> });
|
||||
|
||||
assert.equal(rootNode.textContent, 'hello');
|
||||
});
|
||||
|
||||
it('renders an error page on error', () => {
|
||||
const store = createStore(signedInApiState);
|
||||
const error = createApiError({
|
||||
apiURL: 'http://test.com',
|
||||
response: { status: 404 },
|
||||
});
|
||||
store.dispatch(loadFail('ReduxKey', error));
|
||||
|
||||
const rootNode = render({ children: <div>hello</div> }, store);
|
||||
|
||||
assert.notEqual(rootNode.textContent, 'hello');
|
||||
assert.include(rootNode.textContent, 'Page not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ErrorPage mapStateToProps />', () => {
|
||||
it('returns errorPage from state', () => {
|
||||
assert.deepEqual(
|
||||
mapStateToProps({ errorPage: 'howdy' }), { errorPage: 'howdy' });
|
||||
});
|
||||
});
|
|
@ -11,6 +11,7 @@ import { LandingPageBase, mapStateToProps } from 'amo/components/LandingPage';
|
|||
import createStore from 'amo/store';
|
||||
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import { visibleAddonType } from 'core/utils';
|
||||
import { fakeAddon } from 'tests/client/amo/helpers';
|
||||
import { getFakeI18nInst } from 'tests/client/helpers';
|
||||
|
||||
|
@ -30,7 +31,7 @@ describe('<LandingPage />', () => {
|
|||
|
||||
it('renders a LandingPage with no addons set', () => {
|
||||
const root = render({
|
||||
addonType: ADDON_TYPE_EXTENSION,
|
||||
params: { visibleAddonType: visibleAddonType(ADDON_TYPE_EXTENSION) },
|
||||
});
|
||||
|
||||
assert.include(root.textContent, 'Featured extensions');
|
||||
|
@ -39,7 +40,7 @@ describe('<LandingPage />', () => {
|
|||
|
||||
it('renders a LandingPage with themes HTML', () => {
|
||||
const root = render({
|
||||
addonType: ADDON_TYPE_THEME,
|
||||
params: { visibleAddonType: visibleAddonType(ADDON_TYPE_THEME) },
|
||||
});
|
||||
|
||||
assert.include(root.textContent, 'Featured themes');
|
||||
|
@ -90,8 +91,8 @@ describe('<LandingPage />', () => {
|
|||
},
|
||||
}));
|
||||
const root = render({
|
||||
...mapStateToProps(
|
||||
store.getState(), { params: { visibleAddonType: 'themes' } }),
|
||||
...mapStateToProps(store.getState()),
|
||||
params: { visibleAddonType: visibleAddonType(ADDON_TYPE_THEME) },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
|
@ -101,9 +102,17 @@ describe('<LandingPage />', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('throws if add-on type is not supported', () => {
|
||||
it('renders not found if add-on type is not supported', () => {
|
||||
const root = render({ params: { visibleAddonType: 'XUL' } });
|
||||
assert.include(root.textContent, 'Page not found');
|
||||
});
|
||||
|
||||
it('throws for any error other than an unknown addonType', () => {
|
||||
assert.throws(() => {
|
||||
render({ addonType: 'XUL' });
|
||||
}, 'No LandingPage content for addonType: XUL');
|
||||
render({
|
||||
apiAddonType: () => { throw new Error('Ice cream'); },
|
||||
params: { visibleAddonType: 'doesnotmatter' },
|
||||
});
|
||||
}, 'Ice cream');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
renderIntoDocument,
|
||||
findRenderedComponentWithType,
|
||||
} from 'react-addons-test-utils';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { loadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import NotAuthorized from 'amo/components/NotAuthorized';
|
||||
import createStore from 'amo/store';
|
||||
import { createApiError } from 'core/api';
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import { signedInApiState } from 'tests/client/amo/helpers';
|
||||
import { getFakeI18nInst } from 'tests/client/helpers';
|
||||
|
||||
|
||||
describe('<NotAuthorized />', () => {
|
||||
function render({ ...props }) {
|
||||
const store = createStore(signedInApiState);
|
||||
const error = createApiError({
|
||||
apiURL: 'http://test.com',
|
||||
response: { status: 401 },
|
||||
});
|
||||
store.dispatch(loadFail('ReduxKey', error));
|
||||
|
||||
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
|
||||
<Provider store={store}>
|
||||
<I18nProvider i18n={getFakeI18nInst()}>
|
||||
<NotAuthorized {...props} />
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
), NotAuthorized));
|
||||
}
|
||||
|
||||
it('renders a not authorized error', () => {
|
||||
const rootNode = render();
|
||||
|
||||
assert.include(rootNode.textContent, 'Not Authorized');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
renderIntoDocument,
|
||||
findRenderedComponentWithType,
|
||||
} from 'react-addons-test-utils';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { loadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import NotFound from 'amo/components/NotFound';
|
||||
import createStore from 'amo/store';
|
||||
import { createApiError } from 'core/api';
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import { signedInApiState } from 'tests/client/amo/helpers';
|
||||
import { getFakeI18nInst } from 'tests/client/helpers';
|
||||
|
||||
|
||||
describe('<NotFound />', () => {
|
||||
function render({ ...props }) {
|
||||
const store = createStore(signedInApiState);
|
||||
const error = createApiError({
|
||||
apiURL: 'http://test.com',
|
||||
response: { status: 404 },
|
||||
});
|
||||
store.dispatch(loadFail('ReduxKey', error));
|
||||
|
||||
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
|
||||
<Provider store={store}>
|
||||
<I18nProvider i18n={getFakeI18nInst()}>
|
||||
<NotFound {...props} />
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
), NotFound));
|
||||
}
|
||||
|
||||
it('renders a not found error', () => {
|
||||
const rootNode = render();
|
||||
|
||||
assert.include(rootNode.textContent, 'Page not found');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
renderIntoDocument,
|
||||
findRenderedComponentWithType,
|
||||
} from 'react-addons-test-utils';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { loadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import ServerError from 'amo/components/ServerError';
|
||||
import createStore from 'amo/store';
|
||||
import { createApiError } from 'core/api';
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import { signedInApiState } from 'tests/client/amo/helpers';
|
||||
import { getFakeI18nInst } from 'tests/client/helpers';
|
||||
|
||||
|
||||
describe('<ServerError />', () => {
|
||||
function render({ ...props }) {
|
||||
const store = createStore(signedInApiState);
|
||||
const error = createApiError({
|
||||
apiURL: 'http://test.com',
|
||||
response: { status: 500 },
|
||||
});
|
||||
store.dispatch(loadFail('ReduxKey', error));
|
||||
|
||||
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
|
||||
<Provider store={store}>
|
||||
<I18nProvider i18n={getFakeI18nInst()}>
|
||||
<ServerError {...props} />
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
), ServerError));
|
||||
}
|
||||
|
||||
it('renders a server error', () => {
|
||||
const rootNode = render();
|
||||
|
||||
assert.include(rootNode.textContent,
|
||||
'but there was an error with our server and');
|
||||
});
|
||||
});
|
|
@ -1,23 +1,35 @@
|
|||
import React from 'react';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { renderIntoDocument } from 'react-addons-test-utils';
|
||||
import { loadFail as reduxConnectLoadFail } from 'redux-connect/lib/store';
|
||||
import {
|
||||
renderIntoDocument,
|
||||
findRenderedComponentWithType,
|
||||
} from 'react-addons-test-utils';
|
||||
import { Provider } from 'react-redux';
|
||||
import { loadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import {
|
||||
// eslint-disable-next-line import/no-named-default
|
||||
default as WrappedApp,
|
||||
AppBase,
|
||||
mapDispatchToProps,
|
||||
mapStateToProps,
|
||||
} from 'amo/containers/App';
|
||||
import createStore from 'amo/store';
|
||||
import DefaultErrorPage from 'amo/components/ErrorPage';
|
||||
import { setClientApp, setLang } from 'core/actions';
|
||||
import * as api from 'core/api';
|
||||
import { createApiError } from 'core/api';
|
||||
import { INSTALL_STATE } from 'core/constants';
|
||||
import I18nProvider from 'core/i18n/Provider';
|
||||
import { getFakeI18nInst } from 'tests/client/helpers';
|
||||
|
||||
|
||||
describe('App', () => {
|
||||
class FakeErrorPageComponent extends React.Component {
|
||||
render() {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
return <div>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
class FakeFooterComponent extends React.Component {
|
||||
render() {
|
||||
return <footer />;
|
||||
|
@ -46,18 +58,24 @@ describe('App', () => {
|
|||
i18n: getFakeI18nInst(),
|
||||
location: sinon.stub(),
|
||||
isAuthenticated: true,
|
||||
store: createStore(),
|
||||
...customProps,
|
||||
};
|
||||
return renderIntoDocument(
|
||||
<AppBase
|
||||
FooterComponent={FakeFooterComponent}
|
||||
InfoDialogComponent={FakeInfoDialogComponent}
|
||||
MastHeadComponent={FakeMastHeadComponent}
|
||||
SearchFormComponent={FakeSearchFormComponent}
|
||||
{...props}>
|
||||
{children}
|
||||
</AppBase>
|
||||
);
|
||||
return findRenderedComponentWithType(renderIntoDocument(
|
||||
<Provider store={props.store}>
|
||||
<I18nProvider i18n={props.i18n}>
|
||||
<AppBase
|
||||
FooterComponent={FakeFooterComponent}
|
||||
InfoDialogComponent={FakeInfoDialogComponent}
|
||||
MastHeadComponent={FakeMastHeadComponent}
|
||||
SearchFormComponent={FakeSearchFormComponent}
|
||||
ErrorPage={FakeErrorPageComponent}
|
||||
{...props}>
|
||||
{children}
|
||||
</AppBase>
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
), AppBase);
|
||||
}
|
||||
|
||||
it('renders its children', () => {
|
||||
|
@ -95,36 +113,22 @@ describe('App', () => {
|
|||
|
||||
it('sets isHomePage to true when on the root path', () => {
|
||||
const location = { pathname: '/en-GB/android/' };
|
||||
const root = renderIntoDocument(<AppBase i18n={getFakeI18nInst()}
|
||||
FooterComponent={FakeFooterComponent}
|
||||
InfoDialogComponent={FakeInfoDialogComponent}
|
||||
MastHeadComponent={FakeMastHeadComponent}
|
||||
SearchFormComponent={FakeSearchFormComponent}
|
||||
clientApp="android" lang="en-GB" location={location} />);
|
||||
const root = render({ clientApp: 'android', lang: 'en-GB', location });
|
||||
|
||||
assert.isTrue(root.mastHead.props.isHomePage);
|
||||
});
|
||||
|
||||
it('sets isHomePage to true when on the root path without a slash', () => {
|
||||
const location = { pathname: '/en-GB/android' };
|
||||
const root = renderIntoDocument(<AppBase i18n={getFakeI18nInst()}
|
||||
FooterComponent={FakeFooterComponent}
|
||||
InfoDialogComponent={FakeInfoDialogComponent}
|
||||
MastHeadComponent={FakeMastHeadComponent}
|
||||
SearchFormComponent={FakeSearchFormComponent}
|
||||
clientApp="android" lang="en-GB" location={location} />);
|
||||
const root = render({ clientApp: 'android', lang: 'en-GB', location });
|
||||
|
||||
assert.isTrue(root.mastHead.props.isHomePage);
|
||||
});
|
||||
|
||||
it('sets isHomePage to false when not on the root path', () => {
|
||||
const location = { pathname: '/en-GB/android/404/' };
|
||||
const root = renderIntoDocument(<AppBase i18n={getFakeI18nInst()}
|
||||
FooterComponent={FakeFooterComponent}
|
||||
InfoDialogComponent={FakeInfoDialogComponent}
|
||||
MastHeadComponent={FakeMastHeadComponent}
|
||||
SearchFormComponent={FakeSearchFormComponent}
|
||||
clientApp="android" lang="en-GB" location={location} />);
|
||||
const root = render({
|
||||
clientApp: 'android', lang: 'en-GB', location });
|
||||
|
||||
assert.isFalse(root.mastHead.props.isHomePage);
|
||||
});
|
||||
|
@ -137,24 +141,6 @@ describe('App', () => {
|
|||
assert.ok(dispatch.calledWith({ type: INSTALL_STATE, payload }));
|
||||
});
|
||||
|
||||
it('renders redux-connect errors', () => {
|
||||
// This is just a sanity check to make sure the default component
|
||||
// is wrapped in handleResourceErrors
|
||||
const store = createStore();
|
||||
const apiError = api.createApiError({
|
||||
apiURL: 'https://some-url',
|
||||
response: { status: 404 },
|
||||
});
|
||||
store.dispatch(reduxConnectLoadFail('someKey', apiError));
|
||||
|
||||
const root = renderIntoDocument(
|
||||
<WrappedApp store={store} />
|
||||
);
|
||||
|
||||
const rootNode = findDOMNode(root);
|
||||
assert.include(rootNode.textContent, 'Not Found');
|
||||
});
|
||||
|
||||
it('sets the clientApp as props', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setClientApp('android'));
|
||||
|
@ -168,4 +154,25 @@ describe('App', () => {
|
|||
const { lang } = mapStateToProps(store.getState());
|
||||
assert.equal(lang, 'de');
|
||||
});
|
||||
|
||||
it('renders an error component on error', () => {
|
||||
const store = createStore();
|
||||
const apiError = createApiError({
|
||||
apiURL: 'https://some-url',
|
||||
response: { status: 404 },
|
||||
});
|
||||
|
||||
store.dispatch(loadFail('App', apiError));
|
||||
|
||||
const root = render({
|
||||
ErrorPage: DefaultErrorPage,
|
||||
clientApp: 'android',
|
||||
lang: 'en-GB',
|
||||
location: { pathname: '/en-GB/android/' },
|
||||
store,
|
||||
});
|
||||
const rootNode = findDOMNode(root);
|
||||
|
||||
assert.include(rootNode.textContent, 'Page not found');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,6 +10,7 @@ describe('amo createStore', () => {
|
|||
'api',
|
||||
'auth',
|
||||
'categories',
|
||||
'errorPage',
|
||||
'errors',
|
||||
'featured',
|
||||
'infoDialog',
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import NotAuthorized from 'amo/components/NotAuthorized';
|
||||
import NotFound from 'amo/components/NotFound';
|
||||
import ServerError from 'amo/components/ServerError';
|
||||
import createStore from 'amo/store';
|
||||
import * as featuredActions from 'amo/actions/featured';
|
||||
import * as landingActions from 'amo/actions/landing';
|
||||
|
@ -9,22 +12,20 @@ import {
|
|||
SEARCH_SORT_TOP_RATED,
|
||||
} from 'core/constants';
|
||||
import {
|
||||
getErrorComponent,
|
||||
loadFeaturedAddons,
|
||||
loadLandingAddons,
|
||||
} from 'amo/utils';
|
||||
import { unexpectedSuccess } from 'tests/client/helpers';
|
||||
|
||||
|
||||
describe('amo/utils', () => {
|
||||
let ownProps;
|
||||
|
||||
beforeEach(() => {
|
||||
ownProps = {
|
||||
params: {
|
||||
application: 'android',
|
||||
visibleAddonType: 'extensions',
|
||||
},
|
||||
};
|
||||
});
|
||||
const ownProps = {
|
||||
params: {
|
||||
application: 'android',
|
||||
visibleAddonType: 'extensions',
|
||||
},
|
||||
};
|
||||
|
||||
describe('loadFeaturedAddons()', () => {
|
||||
it('requests a large page of featured add-ons', () => {
|
||||
|
@ -51,7 +52,6 @@ describe('amo/utils', () => {
|
|||
describe('loadLandingAddons()', () => {
|
||||
it('calls featured and search APIs to collect results', () => {
|
||||
const addonType = ADDON_TYPE_THEME;
|
||||
ownProps.params.visibleAddonType = 'themes';
|
||||
const store = createStore({ application: 'android' });
|
||||
store.dispatch(landingActions.getLanding({ addonType }));
|
||||
const mockApi = sinon.mock(api);
|
||||
|
@ -82,10 +82,47 @@ describe('amo/utils', () => {
|
|||
})
|
||||
.returns(Promise.resolve({ entities, result }));
|
||||
|
||||
return loadLandingAddons({ store, params: ownProps.params })
|
||||
return loadLandingAddons({
|
||||
store,
|
||||
params: { ...ownProps.params, visibleAddonType: 'themes' },
|
||||
})
|
||||
.then(() => {
|
||||
mockApi.verify();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a rejected Promise if the addonsType is wrong', () => {
|
||||
const store = createStore({ application: 'android' });
|
||||
|
||||
return loadLandingAddons({
|
||||
store,
|
||||
params: { ...ownProps.params, visibleAddonType: 'addon-with-a-typo' },
|
||||
})
|
||||
.then(unexpectedSuccess)
|
||||
.catch((err) => {
|
||||
assert.equal(
|
||||
err.message,
|
||||
'"addon-with-a-typo" not found in API_ADDON_TYPES_MAPPING'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorComponent', () => {
|
||||
it('returns a NotAuthorized component for 401 errors', () => {
|
||||
assert.deepEqual(getErrorComponent(401), NotAuthorized);
|
||||
});
|
||||
|
||||
it('returns a NotFound component for 404 errors', () => {
|
||||
assert.deepEqual(getErrorComponent(404), NotFound);
|
||||
});
|
||||
|
||||
it('returns a ServerError component for 500 errors', () => {
|
||||
assert.deepEqual(getErrorComponent(500), ServerError);
|
||||
});
|
||||
|
||||
it('returns a ServerError component by default', () => {
|
||||
assert.deepEqual(getErrorComponent(501), ServerError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
import { createStore, combineReducers } from 'redux';
|
||||
import { endGlobalLoad, loadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import { createApiError } from 'core/api';
|
||||
import errorPage, { initialState } from 'core/reducers/errorPage';
|
||||
|
||||
|
||||
function getErrorPageState(store) {
|
||||
return store.getState().errorPage;
|
||||
}
|
||||
|
||||
describe('errorPage reducer', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore(combineReducers({ errorPage }));
|
||||
});
|
||||
|
||||
it('defaults to no error and nothing to clear', () => {
|
||||
const state = errorPage(initialState, { type: 'unrelated' });
|
||||
assert.deepEqual(state, initialState);
|
||||
});
|
||||
|
||||
describe('REDUX_CONNECT_END_GLOBAL_LOAD', () => {
|
||||
it('sets clearOnNext then clears it next time', () => {
|
||||
store.dispatch({ type: 'unrelated', payload: {} });
|
||||
let state = getErrorPageState(store);
|
||||
assert.equal(state.clearOnNext, false);
|
||||
|
||||
store.dispatch(endGlobalLoad());
|
||||
state = getErrorPageState(store);
|
||||
assert.equal(state.clearOnNext, true);
|
||||
|
||||
store.dispatch(endGlobalLoad());
|
||||
state = getErrorPageState(store);
|
||||
assert.deepEqual(state.statusCode, initialState.statusCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('REDUX_CONNECT_LOAD_FAIL', () => {
|
||||
it('sets an error on load fail; is cleared after the next request', () => {
|
||||
store.dispatch({ type: 'unrelated', payload: {} });
|
||||
let state = getErrorPageState(store);
|
||||
assert.equal(state.error, null);
|
||||
|
||||
const error = createApiError({
|
||||
apiURL: 'http://test.com',
|
||||
response: { status: 404 },
|
||||
});
|
||||
store.dispatch(loadFail('ReduxKey', error));
|
||||
state = getErrorPageState(store);
|
||||
|
||||
assert.equal(state.hasError, true);
|
||||
assert.equal(state.statusCode, error.response.status);
|
||||
assert.deepEqual(state.error, error);
|
||||
|
||||
store.dispatch(endGlobalLoad());
|
||||
state = getErrorPageState(store);
|
||||
|
||||
assert.equal(state.clearOnNext, true);
|
||||
|
||||
store.dispatch(endGlobalLoad());
|
||||
state = getErrorPageState(store);
|
||||
|
||||
assert.equal(state.clearOnNext, false);
|
||||
assert.equal(state.hasError, false);
|
||||
});
|
||||
|
||||
it('sets a 500 statusCode when no response is present', () => {
|
||||
store.dispatch({ type: 'unrelated', payload: {} });
|
||||
let state = getErrorPageState(store);
|
||||
|
||||
const error = { invalid: 'yup' };
|
||||
store.dispatch(loadFail('ReduxKey', error));
|
||||
state = getErrorPageState(store);
|
||||
|
||||
assert.equal(state.hasError, true);
|
||||
assert.equal(state.statusCode, 500);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,55 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { renderIntoDocument } from 'react-addons-test-utils';
|
||||
import { loadFail as reduxConnectLoadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import { createApiError } from 'core/api';
|
||||
import { handleResourceErrors } from 'core/resourceErrors/decorator';
|
||||
import createStore from 'amo/store';
|
||||
|
||||
|
||||
class SomeComponentBase extends React.Component {
|
||||
static propTypes = {
|
||||
counter: PropTypes.number,
|
||||
}
|
||||
render() {
|
||||
if (this.props.counter !== undefined) {
|
||||
return <div>Counter: {this.props.counter}</div>;
|
||||
}
|
||||
return <div>Static Content</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderToDOM({ store = createStore(), ...props } = {}) {
|
||||
const SomeComponent = handleResourceErrors(SomeComponentBase);
|
||||
const root = renderIntoDocument(
|
||||
<SomeComponent store={store} {...props} />
|
||||
);
|
||||
return findDOMNode(root);
|
||||
}
|
||||
|
||||
describe('core/resourceErrors/decorator', () => {
|
||||
describe('handleResourceErrors', () => {
|
||||
it('renders the wrapped component in lieu of errors', () => {
|
||||
const rootNode = renderToDOM();
|
||||
assert.include(rootNode.textContent, 'Static Content');
|
||||
});
|
||||
|
||||
it('passes through arbitrary properties', () => {
|
||||
const rootNode = renderToDOM({ counter: 2 });
|
||||
assert.include(rootNode.textContent, 'Counter: 2');
|
||||
});
|
||||
|
||||
it('renders redux-connect errors as resource errors', () => {
|
||||
const store = createStore();
|
||||
const apiError = createApiError({
|
||||
apiURL: 'https://some-url',
|
||||
response: { status: 404 },
|
||||
});
|
||||
store.dispatch(reduxConnectLoadFail('someKey', apiError));
|
||||
|
||||
const rootNode = renderToDOM({ store });
|
||||
assert.include(rootNode.textContent, 'Not Found');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,54 +0,0 @@
|
|||
import { loadFail as reduxConnectLoadFail } from 'redux-connect/lib/store';
|
||||
|
||||
import createStore from 'amo/store';
|
||||
import { createApiError } from 'core/api';
|
||||
import { getReduxConnectError } from 'core/resourceErrors/reduxConnectErrors';
|
||||
|
||||
describe('core/reduxConnectErrors', () => {
|
||||
describe('getReduxConnectError', () => {
|
||||
let store;
|
||||
|
||||
function errorWithStatus(status) {
|
||||
return createApiError({
|
||||
apiURL: 'https://some-url',
|
||||
response: { status },
|
||||
});
|
||||
}
|
||||
|
||||
function _getReduxConnectError() {
|
||||
const loadState = store.getState().reduxAsyncConnect.loadState;
|
||||
return getReduxConnectError(loadState);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
it('returns null when there are no redux-connect errors', () => {
|
||||
assert.deepEqual(_getReduxConnectError(),
|
||||
{ status: undefined, error: undefined });
|
||||
});
|
||||
|
||||
it('returns 404 status info', () => {
|
||||
store.dispatch(reduxConnectLoadFail('someKey', errorWithStatus(404)));
|
||||
|
||||
assert.deepEqual(_getReduxConnectError(),
|
||||
{ status: 404, error: 'Not Found' });
|
||||
});
|
||||
|
||||
it('returns 500 for multiple errors', () => {
|
||||
store.dispatch(reduxConnectLoadFail('someKey', errorWithStatus(404)));
|
||||
store.dispatch(reduxConnectLoadFail('anotherKey', errorWithStatus(404)));
|
||||
|
||||
assert.deepEqual(_getReduxConnectError(),
|
||||
{ status: 500, error: 'Internal Server Error' });
|
||||
});
|
||||
|
||||
it('preserves status for unexpected errors', () => {
|
||||
store.dispatch(reduxConnectLoadFail('someKey', errorWithStatus(419)));
|
||||
|
||||
assert.deepEqual(_getReduxConnectError(),
|
||||
{ status: 419, error: 'Unexpected Error' });
|
||||
});
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче