feat: Use redux-saga (fix #2150)
This commit is contained in:
Родитель
47ee3466ee
Коммит
c74a8952dc
|
@ -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);
|
||||
}
|
|
@ -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),
|
||||
];
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче