This commit is contained in:
Matthew Riley MacPherson 2017-03-27 21:27:56 +01:00
Родитель 47ee3466ee
Коммит c74a8952dc
62 изменённых файлов: 752 добавлений и 351 удалений

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

@ -216,6 +216,7 @@
"redux": "3.6.0",
"redux-connect": "4.0.2",
"redux-logger": "2.8.1",
"redux-saga": "0.14.3",
"serialize-javascript": "1.3.0",
"simple-debounce": "0.0.3",
"ua-parser-js": "0.7.12",
@ -281,6 +282,7 @@
"react-hot-loader": "^1.3.0",
"react-transform-hmr": "^1.0.4",
"redux-devtools": "^3.3.2",
"redux-saga-tester": "^1.0.247",
"require-uncached": "^1.0.3",
"rimraf": "^2.6.1",
"sass-loader": "^6.0.3",

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

@ -9,9 +9,11 @@ import users from 'core/reducers/users';
import { middleware } from 'core/store';
export default function createStore(initialState = {}) {
return _createStore(
const store = _createStore(
combineReducers({ addons, api, auth, search, reduxAsyncConnect, users }),
initialState,
middleware(),
);
return { store };
}

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

@ -4,6 +4,7 @@ import makeClient from 'core/client/base';
import 'core/tracking';
import routes from './routes';
import sagas from './sagas';
import createStore from './store';
makeClient(routes, createStore);
makeClient(routes, createStore, { sagas });

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

@ -175,8 +175,7 @@ export class AddonDetailBase extends React.Component {
const description = addon.description ? addon.description : addon.summary;
const descriptionSanitized = sanitizeHTML(
nl2br(description), allowedDescriptionTags);
const summarySanitized = sanitizeHTML(
addon.summary, ['a']);
const summarySanitized = sanitizeHTML(addon.summary, ['a']);
const title = i18n.sprintf(
// L10n: Example: The Add-On <span>by The Author</span>
i18n.gettext('%(addonName)s %(startSpan)sby %(authorList)s%(endSpan)s'), {

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

@ -1,9 +1,12 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
import Link from 'amo/components/Link';
import { visibleAddonType } from 'core/utils';
import { categoriesFetch } from 'core/actions/categories';
import { apiAddonType, visibleAddonType } from 'core/utils';
import translate from 'core/i18n/translate';
import LoadingText from 'ui/components/LoadingText';
import './Categories.scss';
@ -11,21 +14,32 @@ import './Categories.scss';
export class CategoriesBase extends React.Component {
static propTypes = {
addonType: PropTypes.string.isRequired,
dispatch: PropTypes.func,
categories: PropTypes.object.isRequired,
clientApp: PropTypes.string.isRequired,
error: PropTypes.bool,
loading: PropTypes.bool.isRequired,
i18n: PropTypes.object.isRequired,
}
render() {
const { addonType, categories, error, loading, i18n } = this.props;
componentWillMount() {
const { addonType, categories, clientApp, dispatch } = this.props;
if (!Object.values(categories).length) {
dispatch(categoriesFetch({ addonType, clientApp }));
}
}
if (loading) {
return (
<div className="Categories">
<p>{i18n.gettext('Loading...')}</p>
</div>
);
render() {
const { addonType, error, loading, i18n } = this.props;
let { categories } = this.props;
// If we aren't loading then get just the values of the categories object.
if (!loading) {
categories = categories ? Object.values(categories) : [];
} else {
// If we are loading we just set the length of the categories array to
// ten (10) because we want ten placeholders.
categories = Array(10).fill(0);
}
if (error) {
@ -36,7 +50,7 @@ export class CategoriesBase extends React.Component {
);
}
if (!Object.values(categories).length) {
if (!categories.length) {
return (
<div className="Categories">
<p>{i18n.gettext('No categories found.')}</p>
@ -46,14 +60,27 @@ export class CategoriesBase extends React.Component {
return (
<div className="Categories">
<ul className="Categories-list"
ref={(ref) => { this.categories = ref; }}>
{Object.values(categories).map((category) => (
<li key={category.slug} className="Categories-list-item">
<Link className="Categories-link"
to={`/${visibleAddonType(addonType)}/${category.slug}/`}>
{category.name}
</Link>
{loading ? (
<div className="Categories-loadingText visually-hidden">
{i18n.gettext('Loading categories.')}
</div>
) : null}
<ul
className="Categories-list"
ref={(ref) => { this.categories = ref; }}
>
{categories.map((category, index) => (
<li className="Categories-list-item" key={`category-${index}`}>
{loading ? (
<span className="Categories-link">
<LoadingText range={25} />
</span>
) : (
<Link className="Categories-link"
to={`/${visibleAddonType(addonType)}/${category.slug}/`}>
{category.name}
</Link>
)}
</li>
))}
</ul>
@ -62,6 +89,22 @@ export class CategoriesBase extends React.Component {
}
}
export function mapStateToProps(state, ownProps) {
const addonType = apiAddonType(ownProps.params.visibleAddonType);
const clientApp = state.api.clientApp;
const categories = state.categories.categories[clientApp][addonType] ?
state.categories.categories[clientApp][addonType] : {};
return {
addonType,
categories,
clientApp,
error: state.categories.error,
loading: state.categories.loading,
};
}
export default compose(
connect(mapStateToProps),
translate({ withRef: true }),
)(CategoriesBase);

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

@ -2,9 +2,4 @@
.SearchResults {
margin: $padding-page;
.LoadingIndicator-container {
margin: 10px auto;
text-align: center;
}
}

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

@ -1,30 +0,0 @@
import { compose } from 'redux';
import { connect } from 'react-redux';
import Categories from 'amo/components/Categories';
import {
loadCategoriesIfNeeded, apiAddonType, safeAsyncConnect,
} from 'core/utils';
export function mapStateToProps(state, ownProps) {
const addonType = apiAddonType(ownProps.params.visibleAddonType);
const clientApp = state.api.clientApp;
const categories = state.categories.categories[clientApp][addonType] ?
state.categories.categories[clientApp][addonType] : {};
return {
addonType,
categories,
error: state.categories.error,
loading: state.categories.loading,
};
}
export default compose(
safeAsyncConnect([{
key: 'CategoriesPage',
promise: loadCategoriesIfNeeded,
}]),
connect(mapStateToProps),
)(Categories);

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

@ -12,7 +12,7 @@ import HandleLogin from 'core/containers/HandleLogin';
import AddonReviewList from './components/AddonReviewList';
import App from './containers/App';
import CategoryList from './containers/CategoryList';
import Categories from './components/Categories';
import CategoryPage from './containers/CategoryPage';
import FeaturedAddons from './components/FeaturedAddons';
import LandingPage from './components/LandingPage';
@ -32,7 +32,7 @@ export default (
<IndexRoute component={Home} />
<Route path="addon/:slug/" component={DetailPage} />
<Route path="addon/:addonSlug/reviews/" component={AddonReviewList} />
<Route path=":visibleAddonType/categories/" component={CategoryList} />
<Route path=":visibleAddonType/categories/" component={Categories} />
<Route path=":visibleAddonType/featured/" component={FeaturedAddons} />
<Route path=":visibleAddonType/:slug/" component={CategoryPage} />
<Route path="/api/v3/accounts/authenticate/" component={HandleLogin} />

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

@ -0,0 +1,33 @@
import { hideLoading, showLoading } from 'react-redux-loading-bar';
import { call, put, select, takeEvery } from 'redux-saga/effects';
import {
categoriesFail,
categoriesLoad,
} from 'core/actions/categories';
import { categories as categoriesApi } from 'core/api';
import { CATEGORIES_FETCH } from 'core/constants';
import log from 'core/logger';
import { getApi } from './utils';
export function* fetchCategories() {
try {
yield put(showLoading());
const api = yield select(getApi);
const response = yield call(categoriesApi, { api });
yield put(categoriesLoad(response));
} catch (err) {
log.warn('Categories failed to load:', err);
yield put(categoriesFail(err));
} finally {
yield put(hideLoading());
}
}
// Starts fetchUser on each dispatched `categoriesFetch` action.
// Allows concurrent fetches of categoriesFetch.
export default function* categoriesSaga() {
yield takeEvery(CATEGORIES_FETCH, fetchCategories);
}

11
src/amo/sagas/index.js Normal file
Просмотреть файл

@ -0,0 +1,11 @@
import { fork } from 'redux-saga/effects';
import categories from './categories';
// Export all sagas for this app so runSaga can consume them.
export default function* rootSaga() {
yield [
fork(categories),
];
}

2
src/amo/sagas/utils.js Normal file
Просмотреть файл

@ -0,0 +1,2 @@
// Convenience function to extract API info.
export const getApi = (state) => state.api;

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

@ -1,6 +1,7 @@
import { loadingBarReducer } from 'react-redux-loading-bar';
import { createStore as _createStore, combineReducers } from 'redux';
import { reducer as reduxAsyncConnect } from 'redux-connect';
import createSagaMiddleware from 'redux-saga';
import featured from 'amo/reducers/featured';
import landing from 'amo/reducers/landing';
@ -18,7 +19,9 @@ import { middleware } from 'core/store';
export default function createStore(initialState = {}) {
return _createStore(
const sagaMiddleware = createSagaMiddleware();
const store = _createStore(
combineReducers({
addons,
api,
@ -36,6 +39,8 @@ export default function createStore(initialState = {}) {
search,
}),
initialState,
middleware(),
middleware({ sagaMiddleware }),
);
return { sagaMiddleware, store };
}

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

@ -1,29 +1,26 @@
import {
CATEGORIES_GET,
CATEGORIES_FETCH,
CATEGORIES_LOAD,
CATEGORIES_FAILED,
CATEGORIES_FAIL,
} from 'core/constants';
export function categoriesGet() {
export function categoriesFetch() {
return {
type: CATEGORIES_GET,
payload: { loading: true },
type: CATEGORIES_FETCH,
payload: {},
};
}
export function categoriesLoad({ result }) {
export function categoriesLoad(response) {
return {
type: CATEGORIES_LOAD,
payload: {
loading: false,
result: Object.values(result),
},
payload: { result: response.result },
};
}
export function categoriesFail() {
export function categoriesFail(error) {
return {
type: CATEGORIES_FAILED,
payload: { loading: false },
type: CATEGORIES_FAIL,
payload: { error },
};
}

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

@ -16,7 +16,9 @@ import I18nProvider from 'core/i18n/Provider';
import log from 'core/logger';
export default function makeClient(routes, createStore) {
export default function makeClient(
routes, createStore, { sagas = null } = {},
) {
// This code needs to come before anything else so we get logs/errors
// if anything else in this function goes wrong.
const publicSentryDsn = config.get('publicSentryDsn');
@ -47,7 +49,12 @@ export default function makeClient(routes, createStore) {
log.error('Could not load initial redux data');
}
}
const store = createStore(initialState);
const { sagaMiddleware, store } = createStore(initialState);
if (sagas && sagaMiddleware) {
sagaMiddleware.run(sagas);
} else {
log.warn(`sagas not found for this app (src/${appName}/sagas)`);
}
// wrapper to make redux-connect applyRouterMiddleware compatible see
// https://github.com/taion/react-router-scroll/issues/3

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

@ -40,6 +40,10 @@ export const FATAL_INSTALL_ERROR = 'FATAL_INSTALL_ERROR';
export const FATAL_UNINSTALL_ERROR = 'FATAL_UNINSTALL_ERROR';
export const FATAL_ERROR = 'FATAL_ERROR';
// Client App types
export const CLIENT_APP_ANDROID = 'android';
export const CLIENT_APP_FIREFOX = 'firefox';
// Add-on types.
export const ADDON_TYPE_DICT = 'dictionary';
export const ADDON_TYPE_EXTENSION = 'extension';
@ -85,9 +89,9 @@ export const SEARCH_SORT_POPULAR = 'hotness';
export const SEARCH_SORT_TOP_RATED = 'rating';
// Action types.
export const CATEGORIES_GET = 'CATEGORIES_GET';
export const CATEGORIES_FETCH = 'CATEGORIES_FETCH';
export const CATEGORIES_LOAD = 'CATEGORIES_LOAD';
export const CATEGORIES_FAILED = 'CATEGORIES_FAILED';
export const CATEGORIES_FAIL = 'CATEGORIES_FAIL';
export const CLEAR_ERROR = 'CLEAR_ERROR';
export const ENTITIES_LOADED = 'ENTITIES_LOADED';
export const FEATURED_GET = 'FEATURED_GET';

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

@ -3,16 +3,24 @@ import config from 'config';
import {
ADDON_TYPE_THEME,
CATEGORIES_GET,
CATEGORIES_FETCH,
CATEGORIES_LOAD,
CATEGORIES_FAILED,
CATEGORIES_FAIL,
validAddonTypes,
} from 'core/constants';
import log from 'core/logger';
export function emptyCategoryList() {
return config.get('validClientApplications')
.reduce((object, appName) => ({ ...object, [appName]: {} }), {});
.reduce((object, appName) => {
return {
...object,
[appName]: validAddonTypes.reduce((appObject, addonType) => {
return { ...appObject, [addonType]: [] };
}, {}),
};
}, {});
}
const initialState = {
@ -25,22 +33,25 @@ export default function categories(state = initialState, action) {
const { payload } = action;
switch (action.type) {
case CATEGORIES_GET:
case CATEGORIES_FETCH:
return { ...state, ...payload, loading: true };
case CATEGORIES_LOAD:
{
const categoryList = emptyCategoryList();
payload.result.forEach((result) => {
Object.values(payload.result).forEach((result) => {
// If the API returns data for an application we don't support,
// we'll ignore it for now.
if (!categoryList[result.application]) {
log.warn(oneLine`Category data for unknown application
log.warn(oneLine`Category data for unknown clientApp
"${result.application}" received from API.`);
return;
}
if (!categoryList[result.application][result.type]) {
categoryList[result.application][result.type] = [];
log.warn(oneLine`add-on category for unknown add-on type
"${result.type}" for clientApp "${result.type}" received
from API.`);
return;
}
categoryList[result.application][result.type].push(result);
@ -75,8 +86,13 @@ export default function categories(state = initialState, action) {
categories: categoryList,
};
}
case CATEGORIES_FAILED:
return { ...initialState, ...payload, error: true };
case CATEGORIES_FAIL:
return {
...initialState,
...payload,
loading: false,
error: payload.error,
};
default:
return state;
}

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

@ -16,6 +16,7 @@ import { Provider } from 'react-redux';
import { match } from 'react-router';
import { ReduxAsyncConnect, loadOnServer } from 'redux-connect';
import { loadFail } from 'redux-connect/lib/store';
import { END } from 'redux-saga';
import WebpackIsomorphicTools from 'webpack-isomorphic-tools';
import { createApiError } from 'core/api';
@ -97,7 +98,7 @@ function getPageProps({ noScriptStyles = '', store, req, res }) {
}
function showErrorPage({ createStore, error = {}, req, res, status }) {
const store = createStore();
const { store } = createStore();
const pageProps = getPageProps({ store, req, res });
const componentDeclaredStatus = NestedStatus.rewind();
@ -251,12 +252,15 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
let htmlLang;
let locale;
let pageProps;
let sagaMiddleware;
let store;
try {
cookie.plugToRequest(req, res);
store = createStore();
const storeAndSagas = createStore();
sagaMiddleware = storeAndSagas.sagaMiddleware;
store = storeAndSagas.store;
const token = cookie.load(config.get('cookieName'));
if (token) {
store.dispatch(setAuthToken(token));
@ -309,10 +313,35 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
throw errorPage.error;
}
return hydrateOnClient({
props: { component: InitialComponent },
pageProps,
res,
const props = { component: InitialComponent };
// TODO: Remove the try/catch block once all apps are using
// redux-saga.
let sagas;
try {
// eslint-disable-next-line global-require, import/no-dynamic-require
sagas = require(`${appName}/sagas`).default;
} catch (err) {
log.warn(
`sagas not found for this app (src/${appName}/sagas)`, err);
}
if (!sagas || !sagaMiddleware) {
return hydrateOnClient({ props, pageProps, res });
}
const runningSagas = sagaMiddleware.run(sagas);
// We need to render once because it will force components
// with sagas to call the sagas and load their data.
ReactDOM.renderToString(<ServerHtml {...pageProps} {...props} />);
// Send the redux-saga END action to stop sagas from running
// indefinitely. This is only done for server-side rendering.
store.dispatch(END);
// Once all sagas have completed, we load the page.
return runningSagas.done.then(() => {
return hydrateOnClient({ props, pageProps, res });
});
})
.catch((loadError) => {

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

@ -30,6 +30,7 @@ const PROMISE_PREFIXES = {
export function middleware({
_config = config, _createLogger = createLogger,
_window = typeof window !== 'undefined' ? window : null,
sagaMiddleware = null,
} = {}) {
const isDev = _config.get('isDevelopment');
@ -39,6 +40,9 @@ export function middleware({
// and only on the client side.
callbacks.push(_createLogger());
}
if (sagaMiddleware) {
callbacks.push(sagaMiddleware);
}
callbacks.push(loadingBarMiddleware(PROMISE_PREFIXES));
return compose(

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

@ -9,12 +9,7 @@ import React from 'react';
import { asyncConnect as defaultAsyncConnect } from 'redux-connect';
import { loadEntities } from 'core/actions';
import {
categoriesGet,
categoriesLoad,
categoriesFail,
} from 'core/actions/categories';
import { categories, fetchAddon } from 'core/api';
import { fetchAddon } from 'core/api';
import GenericError from 'core/components/ErrorPage/GenericError';
import NotFound from 'core/components/ErrorPage/NotFound';
import {
@ -128,27 +123,6 @@ export function loadAddonIfNeeded(
return _refreshAddon({ addonSlug: slug, apiState: state.api, dispatch });
}
// asyncConnect() helper for loading categories for browsing and displaying
// info.
export function getCategories({ dispatch, api }) {
dispatch(categoriesGet());
return categories({ api })
.then((response) => dispatch(categoriesLoad(response)))
.catch(() => dispatch(categoriesFail()));
}
export function isLoaded({ state }) {
return state.categories.length && !state.loading;
}
export function loadCategoriesIfNeeded({ store: { dispatch, getState } }) {
const state = getState();
if (!isLoaded({ state: state.categories })) {
return getCategories({ dispatch, api: state.api });
}
return true;
}
export function isAllowedOrigin(urlString, { allowedOrigins = [config.get('amoCDN')] } = {}) {
let parsedURL;
try {

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

@ -11,7 +11,7 @@ import discoResults from 'disco/reducers/discoResults';
export default function createStore(initialState = {}) {
return _createStore(
const store = _createStore(
combineReducers({
addons,
api,
@ -24,4 +24,6 @@ export default function createStore(initialState = {}) {
initialState,
middleware(),
);
return { store };
}

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

@ -0,0 +1,44 @@
/* @flow */
import classNames from 'classnames';
import React from 'react';
import './styles.scss';
type LoadingTextProps = {
minWidth: number,
range: number,
}
export default class LoadingText extends React.Component {
static defaultProps = {
minWidth: 20,
range: 60,
}
props: LoadingTextProps;
render() {
const { minWidth, range } = this.props;
// We start each animation with a slightly different delay so content
// doesn't appear to be pulsing all at once.
const delayStart = Math.floor(Math.random() * 3) + 1;
// Allow a minimum and maximum width so placeholders appear approximately
// the same size as content.
const width = Math.floor(Math.random() * range) + minWidth;
return (
<div
className={classNames(
'LoadingText',
`LoadingText--delay-${delayStart}`,
)}
style={{
width: `${width}%`,
}}
/>
);
}
}

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

@ -0,0 +1,39 @@
@import "~amo/css/inc/vars";
@keyframes placeHolderShimmer {
0% {
opacity: 0.5;
}
25% {
opacity: 0.15;
}
50% {
opacity: 0.75;
}
75% {
opacity: 0.15;
}
100% {
opacity: 0.5;
}
}
.LoadingText {
animation: placeHolderShimmer 3s infinite cubic-bezier(0.65, 0.05, 0.36, 1);
background: $text-color-default;
display: inline-block;
height: 1rem;
line-height: 1;
margin: 0;
vertical-align: middle;
}
@for $delay from 1 through 3 {
.LoadingText--delay-#{$delay} {
animation: placeHolderShimmer #{$delay * 1.5}s infinite cubic-bezier(0.65, 0.05, 0.36, 1);
}
}

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

@ -24,7 +24,7 @@ describe('AddonPage', () => {
};
function render({ props, state }) {
const store = createStore(state);
const { store } = createStore(state);
return findDOMNode(renderIntoDocument(
<Provider store={store} key="provider">
<I18nProvider i18n={getFakeI18nInst()}>

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

@ -2,19 +2,19 @@ import createStore from 'admin/store';
describe('search createStore', () => {
it('sets the reducers', () => {
const store = createStore();
const { store } = createStore();
assert.deepEqual(
Object.keys(store.getState()).sort(),
['addons', 'api', 'auth', 'reduxAsyncConnect', 'search', 'users']);
});
it('creates an empty store', () => {
const store = createStore();
const { store } = createStore();
assert.deepEqual(store.getState().addons, {});
});
it('creates a store with an initial state', () => {
const store = createStore({ addons: { foo: { slug: 'foo' } } });
const { store } = createStore({ addons: { foo: { slug: 'foo' } } });
assert.deepEqual(store.getState().addons, { foo: { slug: 'foo' } });
});
});

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

@ -31,9 +31,10 @@ describe('AddonCompatibilityError', () => {
lang: props.lang,
userAgentInfo: props.userAgentInfo,
};
const { store } = createStore({ api });
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore({ api })}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<AddonCompatibilityError minVersion={null} {...props} />
</I18nProvider>

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

@ -42,7 +42,7 @@ function renderProps({ addon = fakeAddon, setCurrentStatus = sinon.spy(), ...cus
// Configure AddonDetail with a non-redux depdendent RatingManager.
RatingManager: RatingManagerWithI18n,
setCurrentStatus,
store: createStore({ api: signedInApiState }),
store: createStore({ api: signedInApiState }).store,
...customProps,
};
}

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

@ -15,10 +15,11 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('<AddonMoreInfo />', () => {
const initialState = { api: { clientApp: 'android', lang: 'pt' } };
const { store } = createStore(initialState);
function render(props) {
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<AddonMoreInfoBase i18n={getFakeI18nInst()} addon={fakeAddon}
{...props} />
</Provider>

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

@ -56,7 +56,7 @@ describe('amo/components/AddonReviewList', () => {
...customProps
} = {}) {
const loadedReviews = reviews ? getLoadedReviews({ reviews }) : null;
const store = createStore();
const { store } = createStore();
const props = {
addon: addon && denormalizeAddon(addon),
location: { query: {} },
@ -245,7 +245,7 @@ describe('amo/components/AddonReviewList', () => {
let store;
beforeEach(() => {
store = createStore();
store = createStore().store;
});
function getMappedProps({
@ -313,7 +313,7 @@ describe('amo/components/AddonReviewList', () => {
});
it('gets initial data from the API', () => {
const store = createStore();
const { store } = createStore();
const addonSlug = fakeAddon.slug;
const page = 2;
const reviews = [fakeReview];
@ -345,7 +345,7 @@ describe('amo/components/AddonReviewList', () => {
});
it('requires a slug param', () => {
const store = createStore();
const { store } = createStore();
return loadInitialData({
location: { query: {} }, store, params: { addonSlug: null },
})

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

@ -7,41 +7,49 @@ import {
import { Provider } from 'react-redux';
import createStore from 'amo/store';
import Categories from 'amo/components/Categories';
import { CategoriesBase, mapStateToProps } from 'amo/components/Categories';
import { setClientApp, setLang } from 'core/actions';
import { categoriesLoad } from 'core/actions/categories';
import { ADDON_TYPE_EXTENSION, CLIENT_APP_ANDROID } from 'core/constants';
import { getFakeI18nInst } from 'tests/client/helpers';
const categories = {
Games: {
application: 'android',
name: 'Games',
slug: 'Games',
type: 'extension',
},
travel: {
application: 'android',
name: 'Travel',
slug: 'travel',
type: 'extension',
},
const categoriesResponse = {
result: [
{
application: 'android',
name: 'Games',
slug: 'Games',
type: 'extension',
},
{
application: 'android',
name: 'Travel',
slug: 'travel',
type: 'extension',
},
],
};
describe('Categories', () => {
function render({ ...props }) {
const { store } = createStore();
store.dispatch(setClientApp('android'));
store.dispatch(setLang('fr'));
store.dispatch(categoriesLoad(categoriesResponse));
const { categories } = store.getState().categories;
const baseProps = {
clientApp: 'android',
categories,
};
const initialState = {
api: { clientApp: 'android', lang: 'fr' },
categories,
clientApp: store.getState().api.clientApp,
categories: categories[CLIENT_APP_ANDROID][ADDON_TYPE_EXTENSION],
dispatch: sinon.stub(),
};
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Categories i18n={getFakeI18nInst()} {...baseProps} {...props} />
<Provider store={store}>
<CategoriesBase i18n={getFakeI18nInst()} {...baseProps} {...props} />
</Provider>
), Categories));
), CategoriesBase));
}
it('renders Categories', () => {
@ -88,3 +96,37 @@ describe('Categories', () => {
assert.equal(root.textContent, 'Failed to load categories.');
});
});
describe('mapStateToProps', () => {
it('maps state to props', () => {
const { store } = createStore();
store.dispatch(setClientApp('android'));
store.dispatch(setLang('fr'));
store.dispatch(categoriesLoad(categoriesResponse));
const props = mapStateToProps(store.getState(), {
params: { visibleAddonType: 'extensions' },
});
assert.deepEqual(props, {
addonType: ADDON_TYPE_EXTENSION,
categories: {
Games: {
application: 'android',
name: 'Games',
slug: 'Games',
type: 'extension',
},
travel: {
application: 'android',
name: 'Travel',
slug: 'travel',
type: 'extension',
},
},
clientApp: 'android',
error: false,
loading: false,
});
});
});

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

@ -16,7 +16,7 @@ describe('<FeaturedAddons />', () => {
beforeEach(() => {
const initialState = { api: signedInApiState };
store = createStore(initialState);
store = createStore(initialState).store;
});
function render({ ...props }) {

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

@ -15,9 +15,10 @@ import I18nProvider from 'core/i18n/Provider';
describe('Footer', () => {
function renderFooter({ ...props }) {
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
const { store } = createStore(initialState);
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<Footer {...props} />
</I18nProvider>

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

@ -25,8 +25,10 @@ describe('<LandingPage />', () => {
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
function render({ ...props }) {
const { store } = createStore(initialState);
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<LandingPageBase i18n={getFakeI18nInst()} {...props} />
</I18nProvider>
@ -93,7 +95,7 @@ describe('<LandingPage />', () => {
});
it('renders each add-on when set', () => {
const store = createStore(initialState);
const { store } = createStore(initialState);
store.dispatch(landingActions.loadLanding({
featured: {
entities: {

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

@ -18,9 +18,10 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('LanguagePicker', () => {
function renderLanguagePicker({ ...props }) {
const initialState = { api: { clientApp: 'android', lang: 'fr' } };
const { store } = createStore(initialState);
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<LanguagePickerBase i18n={getFakeI18nInst()} {...props} />
</Provider>
), LanguagePickerBase);

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

@ -23,9 +23,10 @@ describe('MastHead', () => {
function renderMastHead({ ...props }) {
const MyMastHead = translate({ withRef: true })(MastHeadBase);
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
const { store } = createStore(initialState);
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<MyMastHead {...props} />
</I18nProvider>

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

@ -17,7 +17,7 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('<NotAuthorized />', () => {
function render({ ...props }) {
const store = createStore(signedInApiState);
const { store } = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 401 },

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

@ -17,7 +17,7 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('<NotFound />', () => {
function render({ ...props }) {
const store = createStore(signedInApiState);
const { store } = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 404 },

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

@ -376,7 +376,7 @@ describe('RatingManager', () => {
let store;
beforeEach(() => {
store = createStore();
store = createStore().store;
});
function getMappedProps({

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

@ -18,9 +18,10 @@ import { ADDON_TYPE_THEME } from 'core/constants';
describe('<SearchResult />', () => {
function renderResult(result, { lang = 'en-GB' } = {}) {
const initialState = { api: { clientApp: 'android', lang } };
const { store } = createStore(initialState);
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst({ lang })}>
<SearchResult addon={result} />
</I18nProvider>

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

@ -15,9 +15,10 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('<SearchResults />', () => {
function renderResults(props) {
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
const { store } = createStore(initialState);
return findRenderedComponentWithType(render(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<SearchResults {...props} />
</I18nProvider>

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

@ -15,9 +15,10 @@ import { getFakeI18nInst } from 'tests/client/helpers';
function render(props) {
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
const { store } = createStore(initialState);
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<SearchSort {...props} />
</I18nProvider>

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

@ -17,7 +17,7 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('<ServerError />', () => {
function render({ ...props }) {
const store = createStore(signedInApiState);
const { store } = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 500 },

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

@ -14,8 +14,9 @@ import { getFakeI18nInst } from 'tests/client/helpers';
describe('<SuggestedPages />', () => {
function render({ ...props }) {
const { store } = createStore();
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore()}>
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<SuggestedPages {...props} />
</I18nProvider>

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

@ -64,7 +64,7 @@ describe('App', () => {
logOutUser: sinon.stub(),
location: sinon.stub(),
isAuthenticated: true,
store: createStore(),
store: createStore().store,
...customProps,
};
return findRenderedComponentWithType(renderIntoDocument(
@ -158,21 +158,21 @@ describe('App', () => {
});
it('sets the clientApp as props', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(setClientApp('android'));
const { clientApp } = mapStateToProps(store.getState());
assert.equal(clientApp, 'android');
});
it('sets the lang as props', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(setLang('de'));
const { lang } = mapStateToProps(store.getState());
assert.equal(lang, 'de');
});
it('sets the userAgent as props', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(setUserAgentAction('tofubrowser'));
const { userAgent } = mapStateToProps(store.getState());
assert.equal(userAgent, 'tofubrowser');
@ -187,7 +187,7 @@ describe('App', () => {
});
it('renders an error component on error', () => {
const store = createStore();
const { store } = createStore();
const apiError = createApiError({
apiURL: 'https://some-url',
response: { status: 404 },

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

@ -1,40 +0,0 @@
import { mapStateToProps } from 'amo/containers/CategoryList';
import { ADDON_TYPE_THEME } from 'core/constants';
describe('<CategoryList />', () => {
it('maps state to props', () => {
const props = mapStateToProps({
api: { clientApp: 'android', lang: 'pt' },
categories: {
categories: {
android: {
[ADDON_TYPE_THEME]: {
nature: {
name: 'Nature',
slug: 'nature',
},
},
},
firefox: {},
},
error: false,
loading: true,
},
}, {
params: { visibleAddonType: 'themes' },
});
assert.deepEqual(props, {
addonType: ADDON_TYPE_THEME,
categories: {
nature: {
name: 'Nature',
slug: 'nature',
},
},
error: false,
loading: true,
});
});
});

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

@ -39,7 +39,7 @@ describe('CategoryPage.mapStateToProps()', () => {
});
it('passes the search state if the filters and state matches', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(searchStart({ filters, results: [] }));
const props = mapStateToProps(store.getState(), ownProps);
@ -56,7 +56,7 @@ describe('CategoryPage.mapStateToProps()', () => {
});
it('does not pass search state if the filters and state do not match', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(searchStart({ filters }));
const mismatchedState = store.getState();
mismatchedState.search.filters.clientApp = 'nothing';

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

@ -14,9 +14,10 @@ import Home from 'amo/containers/Home';
describe('Home', () => {
it('renders a heading', () => {
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
const { store } = createStore(initialState);
const root = findRenderedComponentWithType(renderIntoDocument(
<Provider store={createStore(initialState)}>
<Provider store={store}>
<Home i18n={getFakeI18nInst()} />
</Provider>
), Home).getWrappedInstance();

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

@ -0,0 +1,134 @@
import { hideLoading, showLoading } from 'react-redux-loading-bar';
import SagaTester from 'redux-saga-tester';
import categoriesSaga from 'amo/sagas/categories';
import createStore from 'amo/store';
import { setClientApp, setLang } from 'core/actions';
import * as actions from 'core/actions/categories';
import * as api from 'core/api';
import {
CATEGORIES_FAIL,
CATEGORIES_LOAD,
} from 'core/constants';
import apiReducer from 'core/reducers/api';
import categoriesReducer from 'core/reducers/categories';
describe('categoriesSaga', () => {
let initialState;
let sagaTester;
let store;
beforeEach(() => {
store = createStore().store;
store.dispatch(setClientApp('firefox'));
store.dispatch(setLang('en-US'));
const state = store.getState();
initialState = {
api: state.api,
categories: state.categories,
};
sagaTester = new SagaTester({
initialState,
reducers: { api: apiReducer, categories: categoriesReducer },
});
sagaTester.start(categoriesSaga);
});
it('should get Api from state then make API request to categories', async () => {
const mockApi = sinon.mock(api);
const entities = sinon.stub();
const result = sinon.stub();
mockApi
.expects('categories')
.once()
.withArgs({
api: { ...initialState.api },
})
.returns(Promise.resolve({ entities, result }));
assert.deepEqual(sagaTester.getState(), initialState);
sagaTester.dispatch(actions.categoriesFetch());
assert.deepEqual(sagaTester.getState(), {
...initialState,
categories: { ...initialState.categories, loading: true },
});
await sagaTester.waitFor(CATEGORIES_LOAD);
const calledActions = sagaTester.getCalledActions();
// First action is CATEGORIES_FETCH.
assert.deepEqual(calledActions[0], actions.categoriesFetch());
// Next action is showing the loading bar.
assert.deepEqual(calledActions[1], showLoading());
// Next action is loading the categories returned by the API.
assert.deepEqual(calledActions[2], actions.categoriesLoad({ result }));
// Last action is to hide the loading bar.
assert.deepEqual(calledActions[3], hideLoading());
mockApi.verify();
});
it('should dispatch fail if API request fails', async () => {
const mockApi = sinon.mock(api);
const error = new Error('I have failed!');
mockApi.expects('categories').throws(error);
assert.deepEqual(sagaTester.getState(), initialState);
sagaTester.dispatch(actions.categoriesFetch());
await sagaTester.waitFor(CATEGORIES_FAIL);
const calledActions = sagaTester.getCalledActions();
// First action is CATEGORIES_FETCH.
assert.deepEqual(calledActions[0], actions.categoriesFetch());
// Next action is showing the loading bar.
assert.deepEqual(calledActions[1], showLoading());
// Next action is failure because the API request failed.
assert.deepEqual(calledActions[2], actions.categoriesFail(error));
// Last action is to hide the loading bar.
assert.deepEqual(calledActions[3], hideLoading());
});
it('should respond to all CATEGORIES_FETCH actions', async () => {
const mockApi = sinon.mock(api);
const entities = sinon.stub();
const result = sinon.stub();
mockApi
.expects('categories')
.twice()
.withArgs({
api: { ...initialState.api },
})
.returns(Promise.resolve({ entities, result }));
sagaTester.dispatch(actions.categoriesFetch());
// Dispatch the fetch action again to ensure takeEvery() is respected
// and both actions are responded to.
sagaTester.dispatch(actions.categoriesFetch());
await sagaTester.waitFor(CATEGORIES_LOAD);
assert.equal(sagaTester.numCalled(CATEGORIES_LOAD), 2);
// Ensure the categories API was called twice because we respond to every
// CATEGORIES_FETCH dispatch.
mockApi.verify();
});
});

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

@ -0,0 +1,10 @@
import rootSagas from 'amo/sagas';
describe('amo rootSagas', () => {
it('should run all sagas without an error', () => {
assert.doesNotThrow(() => {
rootSagas().next();
});
});
});

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

@ -0,0 +1,41 @@
import { takeEvery } from 'redux-saga';
import { put, select } from 'redux-saga/effects';
import SagaTester from 'redux-saga-tester';
import { getApi } from 'amo/sagas/utils';
import createStore from 'amo/store';
import { setClientApp, setLang } from 'core/actions';
import apiReducer from 'core/reducers/api';
describe('Saga utils', () => {
it('should return API state', async () => {
function* testGetApiSaga() {
yield takeEvery('TEST_GET_API', function* selectGetApiTest() {
const apiState = yield select(getApi);
yield put({ type: 'TEST_GOT_API', payload: apiState });
});
}
const store = createStore().store;
store.dispatch(setClientApp('firefox'));
store.dispatch(setLang('en-US'));
const state = store.getState();
const sagaTester = new SagaTester({
initialState: { api: state.api },
reducers: { api: apiReducer },
});
sagaTester.start(testGetApiSaga);
sagaTester.dispatch({ type: 'TEST_GET_API' });
await sagaTester.waitFor('TEST_GOT_API');
assert.deepEqual(sagaTester.getLatestCalledAction(), {
type: 'TEST_GOT_API',
payload: state.api,
});
});
});

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

@ -2,7 +2,7 @@ import createStore from 'amo/store';
describe('amo createStore', () => {
it('sets the reducers', () => {
const store = createStore();
const { store } = createStore();
assert.deepEqual(
Object.keys(store.getState()).sort(),
[
@ -25,7 +25,7 @@ describe('amo createStore', () => {
});
it('creates an empty store', () => {
const store = createStore();
const { store } = createStore();
assert.deepEqual(store.getState().addons, {});
});
});

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

@ -31,7 +31,7 @@ describe('amo/utils', () => {
describe('loadFeaturedAddons()', () => {
it('requests a large page of featured add-ons', () => {
const addonType = ADDON_TYPE_EXTENSION;
const store = createStore({ application: 'android' });
const { store } = createStore({ application: 'android' });
store.dispatch(featuredActions.getFeatured({ addonType }));
const mockApi = sinon.mock(api);
const entities = sinon.stub();
@ -55,7 +55,7 @@ describe('amo/utils', () => {
describe('loadLandingAddons()', () => {
it('calls featured and search APIs to collect results', () => {
const addonType = ADDON_TYPE_THEME;
const store = createStore({ application: 'android' });
const { store } = createStore({ application: 'android' });
store.dispatch(landingActions.getLanding({ addonType }));
const mockApi = sinon.mock(api);
const entities = sinon.stub();
@ -97,7 +97,7 @@ describe('amo/utils', () => {
});
it('returns a rejected Promise if the addonsType is wrong', () => {
const store = createStore({ application: 'android' });
const { store } = createStore({ application: 'android' });
return loadLandingAddons({
store,

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

@ -55,7 +55,7 @@ describe('withInstallHelpers', () => {
it('wraps the component in WithInstallHelpers', () => {
const _makeMapDispatchToProps = sinon.spy();
const Component = withInstallHelpers({ src: 'Howdy', _makeMapDispatchToProps })(() => {});
const store = createStore();
const { store } = createStore();
const root = shallowRender(<Component store={store} />);
assert.equal(root.type, WithInstallHelpers);
});

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

@ -1,49 +1,39 @@
import * as actions from 'core/actions/categories';
describe('CATEGORIES_GET', () => {
const params = {
loading: true,
};
const action = actions.categoriesGet(params);
describe('CATEGORIES_FETCH', () => {
const action = actions.categoriesFetch();
it('sets the type', () => {
assert.equal(action.type, 'CATEGORIES_GET');
});
it('sets the query', () => {
assert.deepEqual(action.payload, params);
assert.equal(action.type, 'CATEGORIES_FETCH');
});
});
describe('CATEGORIES_LOAD', () => {
const params = {
const response = {
entities: {},
result: ['foo', 'bar'],
loading: false,
};
const action = actions.categoriesLoad(params);
const action = actions.categoriesLoad(response);
it('sets the type', () => {
assert.equal(action.type, 'CATEGORIES_LOAD');
});
it('sets the payload', () => {
assert.deepEqual(action.payload.loading, false);
assert.deepEqual(action.payload.result, ['foo', 'bar']);
});
});
describe('CATEGORIES_FAILED', () => {
const params = {
loading: false,
};
const action = actions.categoriesFail(params);
describe('CATEGORIES_FAIL', () => {
const error = new Error('I am an error');
const action = actions.categoriesFail(error);
it('sets the type', () => {
assert.equal(action.type, 'CATEGORIES_FAILED');
assert.equal(action.type, 'CATEGORIES_FAIL');
});
it('sets the payload', () => {
assert.deepEqual(action.payload, params);
assert.deepEqual(action.payload.error, error);
});
});

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

@ -18,7 +18,7 @@ import { getFakeI18nInst, userAuthToken } from 'tests/client/helpers';
import Icon from 'ui/components/Icon';
function createStore() {
return _createStore(combineReducers({ api: apiReducer }));
return { store: _createStore(combineReducers({ api: apiReducer })) };
}
describe('<AuthenticateButton />', () => {
@ -94,7 +94,7 @@ describe('<AuthenticateButton />', () => {
};
sinon.stub(config, 'get', (key) => _config[key]);
const store = createStore();
const { store } = createStore();
store.dispatch(setAuthToken(userAuthToken({ user_id: 99 })));
const apiConfig = { token: store.getState().api.token };
assert.ok(apiConfig.token, 'token was falsey');
@ -109,7 +109,7 @@ describe('<AuthenticateButton />', () => {
});
it('pulls isAuthenticated from state', () => {
const store = createStore(combineReducers({ api }));
const { store } = createStore(combineReducers({ api }));
assert.equal(mapStateToProps(store.getState()).isAuthenticated, false);
store.dispatch(setAuthToken(userAuthToken({ user_id: 123 })));
assert.equal(mapStateToProps(store.getState()).isAuthenticated, true);

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

@ -16,7 +16,7 @@ import { signedInApiState } from 'tests/client/amo/helpers';
describe('<ErrorPage />', () => {
function render({ ...props }, store = createStore(signedInApiState)) {
function render({ ...props }, store = createStore(signedInApiState).store) {
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
@ -33,7 +33,7 @@ describe('<ErrorPage />', () => {
});
it('renders an error page on error', () => {
const store = createStore(signedInApiState);
const { store } = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 404 },

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

@ -1,4 +1,11 @@
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
import {
ADDON_TYPE_DICT,
ADDON_TYPE_EXTENSION,
ADDON_TYPE_LANG,
ADDON_TYPE_OPENSEARCH,
ADDON_TYPE_THEME,
} from 'core/constants';
import { categoriesFetch } from 'core/actions/categories';
import categories, { emptyCategoryList } from 'core/reducers/categories';
@ -9,9 +16,7 @@ describe('categories reducer', () => {
it('defaults to an empty set of categories', () => {
const state = categories(initialState, { type: 'unrelated' });
assert.deepEqual(state.categories, {
android: {}, firefox: {},
});
assert.deepEqual(state.categories, emptyCategoryList());
});
it('defaults to not loading', () => {
@ -24,13 +29,10 @@ describe('categories reducer', () => {
assert.equal(error, false);
});
describe('CATEGORIES_GET', () => {
describe('CATEGORIES_FETCH', () => {
it('sets loading', () => {
const state = categories(initialState,
{ type: 'CATEGORIES_GET', payload: { loading: true } });
assert.deepEqual(state.categories, {
android: {}, firefox: {},
});
const state = categories(initialState, categoriesFetch());
assert.deepEqual(state.categories, emptyCategoryList());
assert.equal(state.error, false);
assert.equal(state.loading, true);
});
@ -95,6 +97,12 @@ describe('categories reducer', () => {
slug: 'i-should-not-appear',
type: ADDON_TYPE_EXTENSION,
},
{
application: 'android',
name: 'I should also not appear',
slug: 'i-should-also-not-appear',
type: 'FAKE_TYPE',
},
];
state = categories(initialState, {
type: 'CATEGORIES_LOAD',
@ -103,26 +111,61 @@ describe('categories reducer', () => {
});
it('sets the categories', () => {
const themes = {
anime: {
const result = [
{
application: 'android',
name: 'Alerts & Update',
slug: 'alert-update',
type: ADDON_TYPE_EXTENSION,
},
{
application: 'android',
name: 'Blogging',
slug: 'blogging',
type: ADDON_TYPE_EXTENSION,
},
{
application: 'android',
name: 'Games',
slug: 'Games',
type: ADDON_TYPE_EXTENSION,
},
{
application: 'firefox',
name: 'Alerts & Update',
slug: 'alert-update',
type: ADDON_TYPE_EXTENSION,
},
{
application: 'firefox',
name: 'Security',
slug: 'security',
type: ADDON_TYPE_EXTENSION,
},
{
application: 'firefox',
name: 'Anime',
slug: 'anime',
type: ADDON_TYPE_THEME,
},
naturé: {
{
application: 'firefox',
name: 'Naturé',
slug: 'naturé',
type: ADDON_TYPE_THEME,
},
painting: {
{
application: 'firefox',
name: 'Painting',
slug: 'painting',
type: ADDON_TYPE_THEME,
},
};
];
state = categories(initialState, {
type: 'CATEGORIES_LOAD',
payload: { result },
});
// Notice all Firefox theme categories are also set as Android theme
// categories and no Android categories are returned. This reflects the
// current state of AMO.
@ -132,6 +175,7 @@ describe('categories reducer', () => {
// https://github.com/mozilla/addons-server/issues/4766 is fixed.
assert.deepEqual(state.categories, {
firefox: {
[ADDON_TYPE_DICT]: {},
[ADDON_TYPE_EXTENSION]: {
'alert-update': {
application: 'firefox',
@ -146,9 +190,31 @@ describe('categories reducer', () => {
type: ADDON_TYPE_EXTENSION,
},
},
[ADDON_TYPE_THEME]: themes,
[ADDON_TYPE_LANG]: {},
[ADDON_TYPE_OPENSEARCH]: {},
[ADDON_TYPE_THEME]: {
anime: {
application: 'firefox',
name: 'Anime',
slug: 'anime',
type: ADDON_TYPE_THEME,
},
naturé: {
application: 'firefox',
name: 'Naturé',
slug: 'naturé',
type: ADDON_TYPE_THEME,
},
painting: {
application: 'firefox',
name: 'Painting',
slug: 'painting',
type: ADDON_TYPE_THEME,
},
},
},
android: {
[ADDON_TYPE_DICT]: {},
[ADDON_TYPE_EXTENSION]: {
'alert-update': {
application: 'android',
@ -169,7 +235,28 @@ describe('categories reducer', () => {
type: ADDON_TYPE_EXTENSION,
},
},
[ADDON_TYPE_THEME]: themes,
[ADDON_TYPE_LANG]: {},
[ADDON_TYPE_OPENSEARCH]: {},
[ADDON_TYPE_THEME]: {
anime: {
application: 'firefox',
name: 'Anime',
slug: 'anime',
type: ADDON_TYPE_THEME,
},
naturé: {
application: 'firefox',
name: 'Naturé',
slug: 'naturé',
type: ADDON_TYPE_THEME,
},
painting: {
application: 'firefox',
name: 'Painting',
slug: 'painting',
type: ADDON_TYPE_THEME,
},
},
},
});
});
@ -185,13 +272,13 @@ describe('categories reducer', () => {
});
});
describe('CATEGORIES_FAILED', () => {
describe('CATEGORIES_FAIL', () => {
it('sets error to be true', () => {
const error = true;
const loading = false;
const state = categories(initialState, {
type: 'CATEGORIES_FAILED', payload: { error, loading },
type: 'CATEGORIES_FAIL', payload: { error, loading },
});
assert.deepEqual(state, {
categories: emptyCategoryList(), error, loading,

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

@ -44,7 +44,7 @@ describe('searchUtils loadByCategoryIfNeeded()', () => {
});
it('returns right away when loaded', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(searchActions.searchStart({ filters }));
const mockApi = sinon.mock(api);
const entities = sinon.stub();
@ -69,7 +69,7 @@ describe('searchUtils loadByCategoryIfNeeded()', () => {
});
it('sets the page', () => {
const store = createStore();
const { store } = createStore();
store.dispatch(searchActions.searchStart({ filters }));
const mockApi = sinon.mock(api);
const entities = sinon.stub();

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

@ -12,7 +12,6 @@ import { compose } from 'redux';
import UAParser from 'ua-parser-js';
import * as actions from 'core/actions';
import * as categoriesActions from 'core/actions/categories';
import * as api from 'core/api';
import {
ADDON_TYPE_OPENSEARCH,
@ -34,7 +33,6 @@ import {
isCompatibleWithUserAgent,
isValidClientApp,
loadAddonIfNeeded,
loadCategoriesIfNeeded,
ngettext,
nl2br,
refreshAddon,
@ -527,80 +525,6 @@ describe('loadAddonIfNeeded', () => {
});
});
describe('loadCategoriesIfNeeded', () => {
const apiState = { clientApp: 'android', lang: 'en-US' };
let dispatch;
let loadedCategories;
beforeEach(() => {
dispatch = sinon.spy();
loadedCategories = ['foo', 'bar'];
});
function makeProps(categories = loadedCategories) {
return {
store: {
getState: () => (
{
api: apiState,
categories: { categories, loading: false },
}
),
dispatch,
},
};
}
it('returns the categories if loaded', () => {
assert.strictEqual(loadCategoriesIfNeeded(makeProps()), true);
});
it('loads the categories if they are not loaded', () => {
const props = makeProps([]);
const results = ['foo', 'bar'];
const mockApi = sinon.mock(api);
mockApi
.expects('categories')
.once()
.withArgs({ api: apiState })
.returns(Promise.resolve({ results }));
const action = sinon.stub();
const mockActions = sinon.mock(categoriesActions);
mockActions
.expects('categoriesLoad')
.once()
.withArgs({ results })
.returns(action);
return loadCategoriesIfNeeded(props).then(() => {
assert(dispatch.calledWith(action), 'dispatch not called');
mockApi.verify();
mockActions.verify();
});
});
it('sends an error when it fails', () => {
const props = makeProps([]);
const mockApi = sinon.mock(api);
mockApi
.expects('categories')
.once()
.withArgs({ api: apiState })
.returns(Promise.reject());
const action = sinon.stub();
const mockActions = sinon.mock(categoriesActions);
mockActions
.expects('categoriesFail')
.once()
.withArgs()
.returns(action);
return loadCategoriesIfNeeded(props).then(() => {
assert(dispatch.calledWith(action), 'dispatch not called');
mockApi.verify();
mockActions.verify();
});
});
});
describe('nl2br', () => {
it('converts \n to <br/>', () => {
assert.equal(nl2br('\n'), '<br />');

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

@ -42,11 +42,12 @@ const result = {
function renderAddon({ setCurrentStatus = sinon.stub(), ...props }) {
const MyAddon = translate({ withRef: true })(AddonBase);
const getBrowserThemeData = () => '{"theme":"data"}';
const { store } = createStore({ api: signedInApiState });
return findRenderedComponentWithType(renderIntoDocument(
<MyAddon getBrowserThemeData={getBrowserThemeData} i18n={getFakeI18nInst()}
setCurrentStatus={setCurrentStatus} hasAddonManager
store={createStore({ api: signedInApiState })} {...props} />
store={store} {...props} />
), MyAddon).getWrappedInstance();
}

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

@ -20,7 +20,7 @@ class MyComponent extends React.Component {
}
}
function renderApp(extraProps = {}, store = createStore()) {
function renderApp(extraProps = {}, store = createStore().store) {
const props = {
browserVersion: '50',
i18n: getFakeI18nInst(),
@ -73,7 +73,7 @@ describe('App', () => {
describe('App errors', () => {
it('renders a 404', () => {
const store = createStore();
const { store } = createStore();
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 404 },
@ -86,7 +86,7 @@ describe('App errors', () => {
});
it('renders a generic error', () => {
const store = createStore();
const { store } = createStore();
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 500 },

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

@ -36,7 +36,7 @@ describe('AddonPage', () => {
function render(props) {
// Stub InfoDialog since it uses the store and is irrelevant.
sinon.stub(InfoDialog, 'default', () => <p>InfoDialog</p>);
const store = createStore({
const { store } = createStore({
addons: { foo: { type: ADDON_TYPE_EXTENSION } },
discoResults: [{ addon: 'foo' }],
});

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

@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { renderIntoDocument } from 'react-addons-test-utils';
import LoadingText from 'ui/components/LoadingText';
describe('<LoadingText />', () => {
function render() {
return renderIntoDocument(
<LoadingText />
);
}
it('renders LoadingText element with className', () => {
const root = render();
const rootNode = ReactDOM.findDOMNode(root);
assert.include(rootNode.className, 'LoadingText');
});
});