diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 0775a1f25b9..00000000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.eslintrc b/.eslintrc
index 3f7f69d230c..d626310c83d 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -12,6 +12,7 @@
},
"parser": "babel-eslint",
"rules": {
+ "arrow-body-style": ["off", "always"],
"arrow-parens": ["error", "always"],
// Beware about turning this rule back on. The rule encourages you to create
// static class methods on React components when they don't use `this`.
diff --git a/assets/.DS_Store b/assets/.DS_Store
index 225cc6e5fa5..b02d2d303f5 100644
Binary files a/assets/.DS_Store and b/assets/.DS_Store differ
diff --git a/assets/fonts/Tofino/Tofino Black.otf b/assets/fonts/Tofino/Tofino Black.otf
new file mode 100644
index 00000000000..aaab69aea03
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Black.otf differ
diff --git a/assets/fonts/Tofino/Tofino Bold.otf b/assets/fonts/Tofino/Tofino Bold.otf
new file mode 100644
index 00000000000..b48669742c8
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Bold.otf differ
diff --git a/assets/fonts/Tofino/Tofino Book.otf b/assets/fonts/Tofino/Tofino Book.otf
new file mode 100644
index 00000000000..256fbe9b767
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Book.otf differ
diff --git a/assets/fonts/Tofino/Tofino Light.otf b/assets/fonts/Tofino/Tofino Light.otf
new file mode 100644
index 00000000000..bc4a9184a45
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Light.otf differ
diff --git a/assets/fonts/Tofino/Tofino Medium.otf b/assets/fonts/Tofino/Tofino Medium.otf
new file mode 100644
index 00000000000..8164ec29d7b
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Medium.otf differ
diff --git a/assets/fonts/Tofino/Tofino Regular.otf b/assets/fonts/Tofino/Tofino Regular.otf
new file mode 100644
index 00000000000..da1c00f2330
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Regular.otf differ
diff --git a/assets/fonts/Tofino/Tofino Thin.otf b/assets/fonts/Tofino/Tofino Thin.otf
new file mode 100644
index 00000000000..98a5663a9d8
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Thin.otf differ
diff --git a/assets/fonts/Tofino/Tofino Ultra.otf b/assets/fonts/Tofino/Tofino Ultra.otf
new file mode 100644
index 00000000000..808471c8ef6
Binary files /dev/null and b/assets/fonts/Tofino/Tofino Ultra.otf differ
diff --git a/src/amo/components/Categories.js b/src/amo/components/Categories.js
new file mode 100644
index 00000000000..eebac412977
--- /dev/null
+++ b/src/amo/components/Categories.js
@@ -0,0 +1,84 @@
+import React, { PropTypes } from 'react';
+import { connect } from 'react-redux';
+import { asyncConnect } from 'redux-connect';
+import { compose } from 'redux';
+
+import Link from 'amo/components/Link';
+import translate from 'core/i18n/translate';
+import { loadCategoriesIfNeeded } from 'core/utils';
+
+import './Categories.scss';
+
+
+export function filterAndSortCategories(categories, addonType, clientApp) {
+ return categories.filter((category) => {
+ return category.type === addonType && category.application === clientApp;
+ }).sort((a, b) => {
+ return a.name > b.name;
+ });
+}
+
+export class CategoriesBase extends React.Component {
+ static propTypes = {
+ addonType: PropTypes.string.isRequired,
+ categories: PropTypes.arrayOf(PropTypes.object),
+ clientApp: PropTypes.string.isRequired,
+ error: PropTypes.bool,
+ loading: PropTypes.bool.isRequired,
+ i18n: PropTypes.object.isRequired,
+ }
+
+ render() {
+ const {
+ addonType, categories, clientApp, error, loading, i18n,
+ } = this.props;
+
+ if (loading && !categories.length) {
+ return
{i18n.gettext('Loading...')}
;
+ }
+
+ if (error) {
+ return {i18n.gettext('Failed to load categories.')}
;
+ }
+
+ const categoriesToShow = filterAndSortCategories(
+ categories, addonType, clientApp);
+
+ if (!loading && !categoriesToShow.length) {
+ return {i18n.gettext('No categories found.')}
;
+ }
+
+ return (
+
+
{ this.categories = ref; }}>
+ {categoriesToShow.map((category) => {
+ const queryParams = { category: category.slug, type: addonType };
+ return (
+ -
+
+ {category.name}
+
+
+ );
+ })}
+
+
+ );
+ }
+}
+
+export function mapStateToProps(state) {
+ return { clientApp: state.api.clientApp, ...state.categories };
+}
+
+export default compose(
+ asyncConnect([{
+ deferred: true,
+ key: 'Categories',
+ promise: loadCategoriesIfNeeded,
+ }]),
+ connect(mapStateToProps),
+ translate({ withRef: true }),
+)(CategoriesBase);
diff --git a/src/amo/components/Categories.scss b/src/amo/components/Categories.scss
new file mode 100644
index 00000000000..5892b742d97
--- /dev/null
+++ b/src/amo/components/Categories.scss
@@ -0,0 +1,21 @@
+.Categories-list {
+ margin: 0;
+ padding: 0;
+}
+
+.Categories-listItem {
+ background: url('../img/icons/arrow.svg') 100% 50% no-repeat;
+ border-bottom: 2px solid #000;
+ display: block;
+ list-style: none;
+ margin: 0 auto;
+ padding: 0;
+ width: 95%;
+
+ a {
+ color: #000;
+ display: block;
+ padding: 1.1em 0 0.9em;
+ text-decoration: none;
+ }
+}
diff --git a/src/amo/components/CategoryInfo.js b/src/amo/components/CategoryInfo.js
new file mode 100644
index 00000000000..858040c8761
--- /dev/null
+++ b/src/amo/components/CategoryInfo.js
@@ -0,0 +1,64 @@
+import classNames from 'classnames';
+import React, { PropTypes } from 'react';
+import { connect } from 'react-redux';
+import { asyncConnect } from 'redux-connect';
+import { compose } from 'redux';
+
+import translate from 'core/i18n/translate';
+import { loadCategoriesIfNeeded } from 'core/utils';
+
+import './CategoryInfo.scss';
+
+
+export class CategoryInfoBase extends React.Component {
+ static propTypes = {
+ addonType: PropTypes.string.isRequired,
+ categories: PropTypes.arrayOf(PropTypes.object),
+ clientApp: PropTypes.string.isRequired,
+ slug: PropTypes.string.isRequired,
+ }
+
+ render() {
+ const { addonType, categories, clientApp, slug } = this.props;
+
+ if (!categories) {
+ return null;
+ }
+
+ const categoryMatch = categories.filter((category) => {
+ return (category.application === clientApp && category.slug === slug &&
+ category.type === addonType);
+ });
+ const category = categoryMatch.length ? categoryMatch[0] : false;
+
+ if (!category) {
+ return null;
+ }
+
+ return (
+
+
{ this.header = ref; }}>
+ {category.name}
+
+
+ );
+ }
+}
+
+export function mapStateToProps(state) {
+ return {
+ clientApp: state.api.clientApp,
+ ...state.categories,
+ };
+}
+
+export default compose(
+ asyncConnect([{
+ deferred: true,
+ key: 'CategoryInfo',
+ promise: loadCategoriesIfNeeded,
+ }]),
+ connect(mapStateToProps),
+ translate({ withRef: true }),
+)(CategoryInfoBase);
diff --git a/src/amo/components/CategoryInfo.scss b/src/amo/components/CategoryInfo.scss
new file mode 100644
index 00000000000..e6db8231249
--- /dev/null
+++ b/src/amo/components/CategoryInfo.scss
@@ -0,0 +1,27 @@
+@import "~core/css/inc/vars";
+
+$padding-icon: 20px;
+
+.CategoryInfo {
+ background: $padding-icon 50% no-repeat;
+ margin: 0 auto;
+ min-height: 72px;
+ padding: 0 0 0 ($padding-icon * 6);
+
+ &.alerts-updates {
+ background-image: url('../img/categories/alerts-updates.svg');
+ }
+}
+
+.CategoryInfo-header {
+ font-size: $font-size-default;
+ margin: $padding-icon 0 0;
+ padding: 0;
+}
+
+.CategoryInfo-description {
+ line-height: 1.2;
+ font-size: $font-size-s;
+ margin: 0;
+ max-width: 275px;
+}
diff --git a/src/amo/components/CategoryLink.js b/src/amo/components/CategoryLink.js
new file mode 100644
index 00000000000..445272a98d1
--- /dev/null
+++ b/src/amo/components/CategoryLink.js
@@ -0,0 +1,39 @@
+// import React, { PropTypes } from 'react';
+// import { connect } from 'react-redux';
+// import { Link } from 'react-router'
+// import { compose } from 'redux';
+//
+//
+// export class CategoryLinkBase extends React.Component {
+// static propTypes = {
+// addonType: PropTypes.string,
+// clientApp: PropTypes.string.isRequired,
+// lang: PropTypes.string.isRequired,
+// name: PropTypes.string.isRequired,
+// slug: PropTypes.string.isRequired,
+// }
+//
+// static defaultProps = {
+// addonType: 'extension',
+// }
+//
+// render() {
+// const { addonType, clientApp, lang, name, slug } = this.props;
+//
+// return (
+// { this.link = ref; }}
+// to={`/${lang}/${clientApp}/search/?category=${slug}&type=${addonType}`}>
+// {name}
+//
+// );
+// }
+// }
+//
+// export const mapStateToProps = (state) => ({
+// clientApp: state.api.clientApp,
+// lang: state.api.lang,
+// });
+//
+// export default compose(
+// connect(mapStateToProps),
+// )(CategoryLinkBase);
diff --git a/src/amo/components/SearchPage.js b/src/amo/components/SearchPage.js
index b2f15761108..fd160d54b67 100644
--- a/src/amo/components/SearchPage.js
+++ b/src/amo/components/SearchPage.js
@@ -1,16 +1,20 @@
import React, { PropTypes } from 'react';
-import { connect } from 'react-redux';
-import { compose } from 'redux';
import Link from 'amo/components/Link';
import Paginate from 'core/components/Paginate';
import SearchResults from 'core/components/Search/SearchResults';
+import CategoryInfo from './CategoryInfo';
import SearchResult from './SearchResult';
-export class SearchPageBase extends React.Component {
+export default class SearchPage extends React.Component {
static propTypes = {
+ CategoryInfoComponent: PropTypes.node.isRequired,
+ LinkComponent: PropTypes.node.isRequired,
+ ResultComponent: PropTypes.node.isRequired,
+ addonType: PropTypes.string.isRequired,
+ category: PropTypes.string,
count: PropTypes.number,
loading: PropTypes.bool.isRequired,
page: PropTypes.number,
@@ -18,26 +22,27 @@ export class SearchPageBase extends React.Component {
query: PropTypes.string,
}
+ static defaultProps = {
+ CategoryInfoComponent: CategoryInfo,
+ LinkComponent: Link,
+ ResultComponent: SearchResult,
+ }
+
render() {
- const { count, loading, page, query, results } = this.props;
+ const { CategoryInfoComponent, LinkComponent, ResultComponent,
+ addonType, category, count, loading, page, query, results } = this.props;
const paginator = query && count > 0 ?
- : [];
return (
-
+
{paginator}
);
}
}
-
-export function mapStateToProps(state) {
- return { clientApp: state.api.clientApp, lang: state.api.lang };
-}
-
-export default compose(
- connect(mapStateToProps),
-)(SearchPageBase);
diff --git a/src/amo/constants.js b/src/amo/constants.js
index 03ec987db3e..e34af727ffa 100644
--- a/src/amo/constants.js
+++ b/src/amo/constants.js
@@ -1,2 +1,3 @@
// Action types.
export const SET_REVIEW = 'SET_REVIEW';
+export const SET_USER_RATING = 'SET_USER_RATING';
diff --git a/src/amo/containers/CategoriesPage.js b/src/amo/containers/CategoriesPage.js
new file mode 100644
index 00000000000..860bd929e6e
--- /dev/null
+++ b/src/amo/containers/CategoriesPage.js
@@ -0,0 +1,44 @@
+import React, { PropTypes } from 'react';
+import { compose } from 'redux';
+import { asyncConnect } from 'redux-connect';
+import { connect } from 'react-redux';
+
+import Categories from 'amo/components/Categories';
+import { loadCategoriesIfNeeded } from 'core/utils';
+
+
+export class CategoriesPageBase extends React.Component {
+ static propTypes = {
+ addonType: PropTypes.string.isRequired,
+ CategoriesComponent: PropTypes.node.isRequired,
+ }
+
+ static defaultProps = {
+ CategoriesComponent: Categories,
+ }
+
+ render() {
+ const { CategoriesComponent, addonType } = this.props;
+ return (
+ { this.container = ref; }}>
+
+
+ );
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { addonType } = ownProps.params;
+ return {
+ addonType,
+ };
+}
+
+export default compose(
+ asyncConnect([{
+ deferred: true,
+ key: 'CategoriesPage',
+ promise: loadCategoriesIfNeeded,
+ }]),
+ connect(mapStateToProps),
+)(CategoriesPageBase);
diff --git a/src/amo/containers/Home.js b/src/amo/containers/Home.js
index 0663af42a56..4d31df01724 100644
--- a/src/amo/containers/Home.js
+++ b/src/amo/containers/Home.js
@@ -6,6 +6,7 @@ import translate from 'core/i18n/translate';
import 'amo/css/Home.scss';
+
export class HomePageBase extends React.Component {
static propTypes = {
i18n: PropTypes.object.isRequired,
@@ -24,7 +25,7 @@ export class HomePageBase extends React.Component {
{i18n.gettext('Be social')}
{i18n.gettext('Share stuff')}
-
+
{i18n.gettext('Browse all extensions')}
diff --git a/src/amo/containers/SearchPage.js b/src/amo/containers/SearchPage.js
index 3984abbb278..0f1e115a743 100644
--- a/src/amo/containers/SearchPage.js
+++ b/src/amo/containers/SearchPage.js
@@ -1,5 +1,25 @@
+import { connect } from 'react-redux';
+import { asyncConnect } from 'redux-connect';
+import { compose } from 'redux';
+
import SearchPage from 'amo/components/SearchPage';
-import createSearchPage from 'core/containers/SearchPage';
+import {
+ loadSearchResultsIfNeeded,
+ mapStateToProps,
+} from 'core/containers/SearchPage';
+import { loadCategoriesIfNeeded } from 'core/utils';
-export default createSearchPage(SearchPage);
+export default compose(
+ asyncConnect([
+ {
+ deferred: true,
+ promise: loadSearchResultsIfNeeded,
+ },
+ {
+ deferred: true,
+ promise: loadCategoriesIfNeeded,
+ },
+ ]),
+ connect(mapStateToProps)
+)(SearchPage);
diff --git a/src/amo/img/categories/alerts-updates.svg b/src/amo/img/categories/alerts-updates.svg
new file mode 100644
index 00000000000..30604a324f1
--- /dev/null
+++ b/src/amo/img/categories/alerts-updates.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/amo/routes.js b/src/amo/routes.js
index 414f1470f88..257cab5783a 100644
--- a/src/amo/routes.js
+++ b/src/amo/routes.js
@@ -5,6 +5,7 @@ import HandleLogin from 'core/containers/HandleLogin';
import AddonReview from './components/AddonReview';
import App from './containers/App';
+import CategoriesPage from './containers/CategoriesPage';
import Home from './containers/Home';
import DetailPage from './containers/DetailPage';
import SearchPage from './containers/SearchPage';
@@ -14,6 +15,7 @@ export default (
+
diff --git a/src/amo/store.js b/src/amo/store.js
index c9131a4a1e5..c248dfd8977 100644
--- a/src/amo/store.js
+++ b/src/amo/store.js
@@ -5,12 +5,15 @@ import { middleware } from 'core/store';
import addons from 'core/reducers/addons';
import api from 'core/reducers/api';
import auth from 'core/reducers/authentication';
+import categories from 'core/reducers/categories';
import reviews from 'amo/reducers/reviews';
import search from 'core/reducers/search';
export default function createStore(initialState = {}) {
return _createStore(
- combineReducers({ addons, api, auth, search, reviews, reduxAsyncConnect }),
+ combineReducers({
+ addons, api, auth, categories, search, reviews, reduxAsyncConnect,
+ }),
initialState,
middleware(),
);
diff --git a/src/core/actions/categories.js b/src/core/actions/categories.js
new file mode 100644
index 00000000000..33ef47a77bd
--- /dev/null
+++ b/src/core/actions/categories.js
@@ -0,0 +1,31 @@
+import {
+ CATEGORIES_GET,
+ CATEGORIES_LOAD,
+ CATEGORIES_FAILED,
+} from 'core/constants';
+
+export function categoriesGet() {
+ return {
+ type: CATEGORIES_GET,
+ payload: { loading: true },
+ };
+}
+
+export function categoriesLoad({ result }) {
+ return {
+ type: CATEGORIES_LOAD,
+ payload: {
+ loading: false,
+ results: Object.keys(result).map((key) => {
+ return result[key];
+ }),
+ },
+ };
+}
+
+export function categoriesFail() {
+ return {
+ type: CATEGORIES_FAILED,
+ payload: { loading: false },
+ };
+}
diff --git a/src/core/actions/search.js b/src/core/actions/search.js
index c897e605e91..d29618d8de0 100644
--- a/src/core/actions/search.js
+++ b/src/core/actions/search.js
@@ -4,23 +4,23 @@ import {
SEARCH_FAILED,
} from 'core/constants';
-export function searchStart(query, page) {
+export function searchStart({ page, query, addonType, app, category }) {
return {
type: SEARCH_STARTED,
- payload: { page, query },
+ payload: { page, addonType, app, category, query },
};
}
-export function searchLoad({ query, entities, result }) {
+export function searchLoad({ query, entities, result, addonType, app, category }) {
return {
type: SEARCH_LOADED,
- payload: { entities, query, result },
+ payload: { entities, result, addonType, app, category, query },
};
}
-export function searchFail({ page, query }) {
+export function searchFail({ page, query, addonType, app, category }) {
return {
type: SEARCH_FAILED,
- payload: { page, query },
+ payload: { page, addonType, app, category, query },
};
}
diff --git a/src/core/api/index.js b/src/core/api/index.js
index 923c4ad649c..ef6149b1917 100644
--- a/src/core/api/index.js
+++ b/src/core/api/index.js
@@ -9,6 +9,7 @@ import config from 'config';
const API_BASE = `${config.get('apiHost')}${config.get('apiPath')}`;
export const addon = new Schema('addons', { idAttribute: 'slug' });
+export const categorySchema = new Schema('categories', { idAttribute: 'slug' });
export const user = new Schema('users', { idAttribute: 'username' });
function makeQueryString(query) {
@@ -59,12 +60,22 @@ export function callApi({
.then((response) => (schema ? normalize(response, schema) : response));
}
-export function search({ api, page, query, auth = false }) {
+export function categories({ api }) {
+ return callApi({
+ endpoint: 'addons/categories',
+ schema: { results: arrayOf(categorySchema) },
+ state: api,
+ });
+}
+
+export function search({ api, page, query, auth = false, category, addonType }) {
+ const clientApp = api ? api.clientApp : null;
+
// TODO: Get the language from the server.
return callApi({
endpoint: 'addons/search',
schema: { results: arrayOf(addon) },
- params: { q: query, page },
+ params: { q: query, page, app: clientApp, category, type: addonType },
state: api,
auth,
});
diff --git a/src/core/components/Search/SearchResults.js b/src/core/components/Search/SearchResults.js
index 58a96942146..264858aaef4 100644
--- a/src/core/components/Search/SearchResults.js
+++ b/src/core/components/Search/SearchResults.js
@@ -10,31 +10,50 @@ import SearchResult from './SearchResult';
class SearchResults extends React.Component {
static propTypes = {
+ CategoryInfoComponent: PropTypes.object.isRequired,
+ ResultComponent: PropTypes.object.isRequired,
+ addonType: PropTypes.string,
+ category: PropTypes.string,
count: PropTypes.number,
i18n: PropTypes.object.isRequired,
loading: PropTypes.bool,
query: PropTypes.string,
results: PropTypes.arrayOf(PropTypes.object),
- ResultComponent: PropTypes.object.isRequired,
}
static defaultProps = {
- count: 0,
- query: null,
+ CategoryInfoComponent: null,
ResultComponent: SearchResult,
+ category: undefined,
+ count: 0,
+ query: undefined,
results: [],
}
render() {
const {
- ResultComponent, count, i18n, loading, query, results,
+ CategoryInfoComponent, ResultComponent, addonType, category, count,
+ i18n, loading, query, results,
} = this.props;
- let searchResults;
- let messageText;
+ const searchParamIsPresent = [
+ addonType, category, query,
+ ].filter((param) => {
+ return param !== undefined && param.length;
+ }).length;
+
let hideMessageText = false;
+ let messageText;
+ let resultHeader;
+ let searchResults;
+
+ if (category && category.length && CategoryInfoComponent) {
+ resultHeader = (
+
+ );
+ }
- if (query && count > 0) {
+ if (searchParamIsPresent && count > 0) {
hideMessageText = true;
messageText = i18n.sprintf(
i18n.ngettext(
@@ -52,23 +71,30 @@ class SearchResults extends React.Component {
))}
);
- } else if (query && loading) {
+ } else if (searchParamIsPresent && loading) {
messageText = i18n.gettext('Searching...');
- } else if (query && results.length === 0) {
+ } else if (!loading && query && count === 0) {
messageText = i18n.sprintf(
i18n.gettext('No results were found for "%(query)s".'), { query });
- } else if (query !== null) {
- messageText = i18n.gettext('Please supply a valid search');
+ } else if (!loading && searchParamIsPresent && count === 0) {
+ // TODO: Add the extension type, if available, so it says "no extensions"
+ // found that match your search or something.
+ messageText = i18n.gettext('No results were found.');
+ } else if (!searchParamIsPresent && !loading) {
+ messageText = i18n.gettext(
+ "Please enter search terms to search all of Mozilla's Add-ons.");
}
- const message = messageText ?
+ const message = (
{ this.message = ref; }} className={classNames({
'visually-hidden': hideMessageText,
'SearchReuslts-message': !hideMessageText,
- })}>{messageText}
: null;
+ })}>{messageText}
+ );
return (
{ this.container = ref; }} className="SearchResults">
+ {resultHeader}
{message}
{searchResults}
diff --git a/src/core/constants.js b/src/core/constants.js
index bc1b767e309..e3ce1c2fc3a 100644
--- a/src/core/constants.js
+++ b/src/core/constants.js
@@ -44,6 +44,9 @@ export const validAddonTypes = [
];
// Action types.
+export const CATEGORIES_GET = 'CATEGORIES_GET';
+export const CATEGORIES_LOAD = 'CATEGORIES_LOAD';
+export const CATEGORIES_FAILED = 'CATEGORIES_FAILED';
export const ENTITIES_LOADED = 'ENTITIES_LOADED';
export const LOG_OUT_USER = 'LOG_OUT_USER';
export const SEARCH_FAILED = 'SEARCH_FAILED';
diff --git a/src/core/containers/SearchPage.js b/src/core/containers/SearchPage.js
index d6c3c43cfa8..cb5601a1b45 100644
--- a/src/core/containers/SearchPage.js
+++ b/src/core/containers/SearchPage.js
@@ -1,30 +1,61 @@
import { connect } from 'react-redux';
import { asyncConnect } from 'redux-connect';
+import { compose } from 'redux';
import { search } from 'core/api';
import { searchStart, searchLoad, searchFail } from 'core/actions/search';
+
export function mapStateToProps(state, ownProps) {
const { location } = ownProps;
- const lang = state.api.lang;
- if (location.query.q === state.search.query) {
- return { lang, ...state.search };
+
+ const queryStringMap = {
+ category: 'category',
+ q: 'query',
+ type: 'addonType',
+ };
+ const doingSearch = Object.keys(queryStringMap).filter((queryKey) => {
+ return (location.query[queryKey] !== undefined &&
+ location.query[queryKey] === state.search[queryStringMap[queryKey]]);
+ }).length;
+
+ if (doingSearch) {
+ return { ...state.search };
}
- return { lang };
+
+ return {};
}
-function performSearch({ dispatch, page, query, api, auth = false }) {
- if (!query) {
+function performSearch({
+ dispatch, page, api, auth = false, addonType, category, query,
+}) {
+ // If none of the optional search params are found, we aren't searching for
+ if ([addonType, category, query].filter((param) => {
+ return param !== undefined && param.length;
+ }).length === 0) {
return Promise.resolve();
}
- dispatch(searchStart(query, page));
- return search({ page, query, api, auth })
- .then((response) => dispatch(searchLoad({ page, query, ...response })))
- .catch(() => dispatch(searchFail({ page, query })));
+
+ dispatch(searchStart({ page, addonType, category, query }));
+ return search({ page, api, auth, addonType, category, query })
+ .then((response) => {
+ return dispatch(searchLoad({
+ page,
+ addonType,
+ category,
+ query,
+ ...response,
+ }));
+ })
+ .catch(() => {
+ return dispatch(searchFail({ page, addonType, category, query }));
+ });
}
-export function isLoaded({ page, query, state }) {
- return state.query === query && state.page === page && !state.loading;
+export function isLoaded({ page, query, addonType, category, state }) {
+ return state.query === query && state.page === page &&
+ state.addonType === addonType && state.category === category &&
+ !state.loading;
}
export function parsePage(page) {
@@ -33,18 +64,39 @@ export function parsePage(page) {
}
export function loadSearchResultsIfNeeded({ store: { dispatch, getState }, location }) {
+ const addonType = location.query.type;
+ const category = location.query.category;
const query = location.query.q;
const page = parsePage(location.query.page);
const state = getState();
- if (!isLoaded({ state: state.search, query, page })) {
- return performSearch({ dispatch, page, query, api: state.api, auth: state.auth });
+ const loaded = isLoaded({
+ addonType,
+ category,
+ page,
+ query,
+ state: state.search,
+ });
+
+ if (!loaded) {
+ return performSearch({
+ addonType,
+ api: state.api,
+ auth: state.auth,
+ category,
+ dispatch,
+ page,
+ query,
+ });
}
return true;
}
export default function createSearchPage(SearchPageComponent) {
- return asyncConnect([{
- deferred: true,
- promise: loadSearchResultsIfNeeded,
- }])(connect(mapStateToProps)(SearchPageComponent));
+ return compose(
+ asyncConnect([{
+ deferred: true,
+ promise: loadSearchResultsIfNeeded,
+ }]),
+ connect(mapStateToProps)
+ )(SearchPageComponent);
}
diff --git a/src/core/reducers/categories.js b/src/core/reducers/categories.js
new file mode 100644
index 00000000000..bc8c749c43d
--- /dev/null
+++ b/src/core/reducers/categories.js
@@ -0,0 +1,32 @@
+import {
+ CATEGORIES_GET,
+ CATEGORIES_LOAD,
+ CATEGORIES_FAILED,
+} from 'core/constants';
+
+
+const initialState = {
+ categories: [],
+ error: false,
+ loading: false,
+};
+
+export default function categories(state = initialState, action) {
+ const { payload } = action;
+
+ switch (action.type) {
+ case CATEGORIES_GET:
+ return { ...state, ...payload, loading: true, categories: [] };
+ case CATEGORIES_LOAD:
+ return {
+ ...state,
+ ...payload,
+ loading: false,
+ categories: payload.results,
+ };
+ case CATEGORIES_FAILED:
+ return { ...initialState, ...payload, error: true };
+ default:
+ return state;
+ }
+}
diff --git a/src/core/reducers/search.js b/src/core/reducers/search.js
index 6225b4de8d9..b6e316c7d84 100644
--- a/src/core/reducers/search.js
+++ b/src/core/reducers/search.js
@@ -5,35 +5,36 @@ import {
SET_QUERY,
} from 'core/constants';
+
const initialState = {
+ category: undefined,
count: 0,
loading: false,
page: 1,
- query: null,
+ query: undefined,
results: [],
};
export default function search(state = initialState, action) {
const { payload } = action;
+
switch (action.type) {
case SET_QUERY:
- return { ...state, query: payload.query };
+ return { ...state, ...payload };
case SEARCH_STARTED:
return { ...state, ...payload, count: 0, loading: true, results: [] };
case SEARCH_LOADED:
return {
...state,
+ ...payload,
count: payload.result.count,
loading: false,
- query: payload.query,
- results: payload.result.results.map((slug) => payload.entities.addons[slug]),
+ results: payload.result.results.map((slug) => {
+ return payload.entities.addons[slug];
+ }),
};
case SEARCH_FAILED:
- return {
- ...initialState,
- page: payload.page,
- query: payload.query,
- };
+ return { ...initialState, ...payload, page: payload.page };
default:
return state;
}
diff --git a/src/core/utils.js b/src/core/utils.js
index b9e969b83c6..bdbdded94f0 100644
--- a/src/core/utils.js
+++ b/src/core/utils.js
@@ -4,7 +4,12 @@ import camelCase from 'camelcase';
import config from 'config';
import { loadEntities } from 'core/actions';
-import { fetchAddon } from 'core/api';
+import {
+ categoriesGet,
+ categoriesLoad,
+ categoriesFail,
+} from 'core/actions/categories';
+import { categories, fetchAddon } from 'core/api';
import log from 'core/logger';
import purify from 'core/purify';
@@ -105,6 +110,31 @@ export function loadAddonIfNeeded(
.then(({ entities }) => dispatch(loadEntities(entities)));
}
+// asyncConnect() helper for loading categories for browsing and displaying
+// info.
+export function getCategories({ dispatch, api }) {
+ dispatch(categoriesGet());
+ return categories({ api })
+ .then((response) => {
+ return dispatch(categoriesLoad(response));
+ })
+ .catch(() => {
+ return 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 {
diff --git a/tests/client/amo/components/TestCategeories.js b/tests/client/amo/components/TestCategeories.js
new file mode 100644
index 00000000000..834c5c6c95f
--- /dev/null
+++ b/tests/client/amo/components/TestCategeories.js
@@ -0,0 +1,117 @@
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+ renderIntoDocument,
+ findRenderedComponentWithType,
+} from 'react-addons-test-utils';
+import { Provider } from 'react-redux';
+
+import createStore from 'amo/store';
+import Categories, { filterAndSortCategories } from 'amo/components/Categories';
+import { getFakeI18nInst } from 'tests/client/helpers';
+
+
+const categories = [
+ {
+ application: 'firefox',
+ name: 'motorbikes',
+ slug: 'motorbikes',
+ type: 'extension',
+ },
+ {
+ application: 'android',
+ name: 'Travel',
+ slug: 'travel',
+ type: 'extension',
+ },
+ {
+ application: 'android',
+ name: 'Games',
+ slug: 'games',
+ type: 'extension',
+ },
+ {
+ application: 'android',
+ name: 'Cats',
+ slug: 'cats',
+ type: 'theme',
+ },
+];
+
+describe('Categories', () => {
+ function render({ ...props }) {
+ const initialState = {
+ api: { clientApp: 'android', lang: 'fr' },
+ categories: { ...categories },
+ };
+
+ return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
+
+
+
+ ), Categories));
+ }
+
+ it('renders Categories', () => {
+ const root = render({
+ addonType: 'extension',
+ categories,
+ error: false,
+ loading: false,
+ });
+
+ assert.ok(root.querySelector('.Categories-list'));
+ });
+
+ it('renders loading when loading', () => {
+ const root = render({
+ addonType: 'extension',
+ categories: [],
+ error: false,
+ loading: true,
+ });
+
+ assert.include(root.textContent, 'Loading');
+ });
+
+ it('renders a message when there are no categories', () => {
+ const root = render({
+ addonType: 'extension',
+ categories: [],
+ error: false,
+ loading: false,
+ });
+
+ assert.equal(root.textContent, 'No categories found.');
+ });
+
+ it('renders an error', () => {
+ const root = render({
+ addonType: 'extension',
+ categories: [],
+ error: true,
+ loading: false,
+ });
+
+ assert.equal(root.textContent, 'Failed to load categories.');
+ });
+});
+
+describe('filterAndSortCategories', () => {
+ it('filters out categories by addonType and clientApp', () => {
+ assert.lengthOf(
+ filterAndSortCategories(categories, 'extension', 'firefox'), 1);
+ assert.lengthOf(
+ filterAndSortCategories(categories, 'extension', 'android'), 2);
+ assert.lengthOf(
+ filterAndSortCategories(categories, 'theme', 'android'), 1);
+ });
+
+ it('sorts categories by name and weight', () => {
+ const sortedCategories = filterAndSortCategories(
+ categories, 'extension', 'android');
+
+ assert.equal(sortedCategories[0].slug, 'games');
+ assert.equal(sortedCategories[1].slug, 'travel');
+ });
+});
diff --git a/tests/client/amo/components/TestCategoryInfo.js b/tests/client/amo/components/TestCategoryInfo.js
new file mode 100644
index 00000000000..451a61fbe4b
--- /dev/null
+++ b/tests/client/amo/components/TestCategoryInfo.js
@@ -0,0 +1,88 @@
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+ renderIntoDocument,
+ findRenderedComponentWithType,
+} from 'react-addons-test-utils';
+import { Provider } from 'react-redux';
+
+import createStore from 'amo/store';
+import CategoryInfo from 'amo/components/CategoryInfo';
+
+
+describe('CategoryInfo', () => {
+ const categories = [
+ {
+ application: 'firefox',
+ name: 'motorbikes',
+ slug: 'motorbikes',
+ type: 'extension',
+ },
+ {
+ application: 'android',
+ name: 'Travel',
+ slug: 'travel',
+ type: 'extension',
+ },
+ {
+ application: 'android',
+ name: 'Cats',
+ slug: 'cats',
+ type: 'theme',
+ },
+ ];
+
+ function render({ ...props }) {
+ const initialState = {
+ api: { clientApp: 'android', lang: 'fr' },
+ categories: { ...categories },
+ };
+
+ return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
+
+
+
+ ), CategoryInfo));
+ }
+
+ it('renders CategoryInfo', () => {
+ const root = render({
+ addonType: 'extension',
+ categories,
+ slug: 'travel',
+ });
+
+ assert.equal(root.querySelector('.CategoryInfo-header').textContent,
+ 'Travel');
+ });
+
+ it('uses the slug in the class name', () => {
+ const root = render({
+ addonType: 'extension',
+ categories,
+ slug: 'travel',
+ });
+
+ assert.include(root.className, 'travel');
+ });
+
+ it("doesn't render if all props don't match", () => {
+ const root = render({
+ addonType: 'extension',
+ categories,
+ slug: 'motorbikes',
+ });
+
+ assert.equal(root, null);
+ });
+
+ it("doesn't render if there are no categories", () => {
+ const root = render({
+ addonType: 'extension',
+ categories: null,
+ slug: 'travel',
+ });
+
+ assert.equal(root, null);
+ });
+});
diff --git a/tests/client/amo/components/TestMastHead.js b/tests/client/amo/components/TestMastHead.js
index 6c57178af60..47df468d238 100644
--- a/tests/client/amo/components/TestMastHead.js
+++ b/tests/client/amo/components/TestMastHead.js
@@ -33,6 +33,7 @@ describe('MastHead', () => {
it('renders a heading when isHomepage is true', () => {
const root = renderMastHead({
+ application: 'firefox',
isHomePage: true,
children: FakeChild,
SearchFormComponent: FakeChild,
@@ -43,6 +44,7 @@ describe('MastHead', () => {
it('renders a link when isHomepage is false', () => {
const root = renderMastHead({
+ application: 'firefox',
isHomePage: false,
children: FakeChild,
SearchFormComponent: FakeChild,
diff --git a/tests/client/amo/components/TestSearchPage.js b/tests/client/amo/components/TestSearchPage.js
index f71b25111e9..b789d21f064 100644
--- a/tests/client/amo/components/TestSearchPage.js
+++ b/tests/client/amo/components/TestSearchPage.js
@@ -1,44 +1,52 @@
import React from 'react';
-import { SearchPageBase, mapStateToProps } from 'amo/components/SearchPage';
+import SearchPage from 'amo/components/SearchPage';
+import CategoryInfo from 'amo/components/CategoryInfo';
import SearchResult from 'amo/components/SearchResult';
import SearchResults from 'core/components/Search/SearchResults';
import Paginate from 'core/components/Paginate';
import { findAllByTag, findByTag, shallowRender } from 'tests/client/helpers';
+
describe('', () => {
let props;
function render(extra = {}) {
- return shallowRender();
+ return shallowRender();
}
beforeEach(() => {
props = {
- clientApp: 'firefox',
+ CategoryInfoComponent: CategoryInfo,
+ ResultComponent: SearchResult,
+ addonType: 'extension',
+ category: null,
count: 80,
- lang: 'en-GB',
page: 3,
handleSearch: sinon.spy(),
loading: false,
results: [{ name: 'Foo', slug: 'foo' }, { name: 'Bar', slug: 'bar' }],
query: 'foo',
- ResultComponent: SearchResult,
};
});
it('renders the results', () => {
const root = render();
const results = findByTag(root, SearchResults);
+ assert.strictEqual(results.props.CategoryInfoComponent,
+ props.CategoryInfoComponent);
+ assert.strictEqual(results.props.ResultComponent, props.ResultComponent);
+ assert.strictEqual(results.props.addonType, props.addonType);
+ assert.strictEqual(results.props.category, props.category);
assert.strictEqual(results.props.count, props.count);
+ assert.strictEqual(results.props.loading, props.loading);
assert.strictEqual(results.props.results, props.results);
assert.strictEqual(results.props.query, props.query);
- assert.strictEqual(results.props.loading, props.loading);
- assert.strictEqual(results.props.ResultComponent, props.ResultComponent);
assert.deepEqual(
- Object.keys(results.props).sort(),
- ['count', 'loading', 'results', 'ResultComponent', 'query'].sort()
- );
+ Object.keys(results.props).sort(), [
+ 'CategoryInfoComponent', 'ResultComponent', 'addonType', 'category',
+ 'count', 'loading', 'results', 'query'].sort()
+ );
});
it('renders a Paginate', () => {
@@ -51,16 +59,8 @@ describe('', () => {
});
it('does not render a Paginate when there is no search term', () => {
- const root = render({ query: null, count: 0 });
+ const root = render({ query: undefined, count: 0 });
const paginators = findAllByTag(root, Paginate);
assert.deepEqual(paginators, []);
});
-
- it('maps api state to props', () => {
- const stateProps = mapStateToProps({
- api: { clientApp: 'android', lang: 'de' },
- });
-
- assert.deepEqual(stateProps, { clientApp: 'android', lang: 'de' });
- });
});
diff --git a/tests/client/amo/containers/TestApp.js b/tests/client/amo/containers/TestApp.js
index 20530d2b400..da5234b3df4 100644
--- a/tests/client/amo/containers/TestApp.js
+++ b/tests/client/amo/containers/TestApp.js
@@ -90,6 +90,8 @@ describe('App', () => {
const { handleLogIn } = setupMapStateToProps(_window)({
auth: {},
api: { lang: 'en-GB' },
+ }, {
+ params: { application: 'firefox' },
});
handleLogIn(location);
assert.equal(_window.location, 'https://a.m.org/login');
diff --git a/tests/client/amo/containers/TestCategoriesPage.js b/tests/client/amo/containers/TestCategoriesPage.js
new file mode 100644
index 00000000000..58bb80e8a81
--- /dev/null
+++ b/tests/client/amo/containers/TestCategoriesPage.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+ findRenderedComponentWithType,
+ renderIntoDocument,
+} from 'react-addons-test-utils';
+import { Provider } from 'react-redux';
+
+import createStore from 'amo/store';
+import CategoriesPage from 'amo/containers/CategoriesPage';
+
+
+describe('', () => {
+ class FakeCategoryComponents extends React.Component {
+ render() {
+ return Fake Category!
;
+ }
+ }
+
+ const categoriesProps = {
+ categories: [{ name: 'Alerts', slug: 'alerts' }],
+ loading: false,
+ params: {
+ addonType: 'extension',
+ },
+ };
+
+ function render(props) {
+ const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
+
+ return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
+
+
+
+ ), CategoriesPage));
+ }
+
+ it('renders a categories page', () => {
+ const root = render(categoriesProps);
+
+ assert.equal(root.className, 'Categories-Page');
+ assert.equal(root.querySelector('.FakeCategory').textContent,
+ 'Fake Category!');
+ });
+});
diff --git a/tests/client/amo/containers/TestHome.js b/tests/client/amo/containers/TestHome.js
index 21c65603b29..2cc8ecd01b0 100644
--- a/tests/client/amo/containers/TestHome.js
+++ b/tests/client/amo/containers/TestHome.js
@@ -7,11 +7,19 @@ import {
import { Provider } from 'react-redux';
import createStore from 'amo/store';
-import { getFakeI18nInst } from 'tests/client/helpers';
import Home from 'amo/containers/Home';
+import { getFakeI18nInst } from 'tests/client/helpers';
describe('Home', () => {
+ // function render(props) {
+ // return findRenderedComponentWithType(renderIntoDocument(
+ //
+ //
+ //
+ // ), Home).getWrappedInstance();
+ // }
+
it('renders a heading', () => {
const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
diff --git a/tests/client/amo/containers/TestSearch.js b/tests/client/amo/containers/TestSearch.js
index c1bbb3956aa..adec569276a 100644
--- a/tests/client/amo/containers/TestSearch.js
+++ b/tests/client/amo/containers/TestSearch.js
@@ -16,13 +16,13 @@ describe('Search.mapStateToProps()', () => {
};
it('passes the search state if the URL and state query matches', () => {
- const props = mapStateToProps(state, { location: { query: { q: 'ad-block' } } });
- assert.deepEqual(props, { lang: 'fr-CA', ...state.search });
+ const props = mapStateToProps(state, { location: { query: { q: 'ad-block' } }, params: { application: 'firefox' } });
+ assert.deepEqual(props, { ...state.search });
});
it('does not pass search state if the URL and state query do not match', () => {
const props = mapStateToProps(state, { location: { query: { q: 'more-ads' } } });
- assert.deepEqual(props, { lang: 'fr-CA' });
+ assert.deepEqual(props, {});
});
});
@@ -123,17 +123,31 @@ describe('CurrentSearchPage.loadSearchResultsIfNeeded()', () => {
mockApi
.expects('search')
.once()
- .withArgs({ page, query, api: state.api, auth: false })
+ .withArgs({
+ addonType: undefined,
+ api: state.api,
+ auth: false,
+ category: undefined,
+ page,
+ query,
+ })
.returns(Promise.resolve({ entities, result }));
return loadSearchResultsIfNeeded({ store, location }).then(() => {
mockApi.verify();
assert(
dispatch.firstCall.calledWith(
- searchActions.searchStart(query, page)),
+ searchActions.searchStart({ query, page })),
'searchStart not called');
assert(
dispatch.secondCall.calledWith(
- searchActions.searchLoad({ query, entities, result })),
+ searchActions.searchLoad({
+ addonType: undefined,
+ app: undefined,
+ category: undefined,
+ entities,
+ query,
+ result,
+ })),
'searchLoad not called');
});
});
@@ -152,17 +166,30 @@ describe('CurrentSearchPage.loadSearchResultsIfNeeded()', () => {
mockApi
.expects('search')
.once()
- .withArgs({ page, query, api: state.api, auth: false })
+ .withArgs({
+ addonType: undefined,
+ api: state.api,
+ auth: false,
+ category: undefined,
+ page,
+ query,
+ })
.returns(Promise.reject());
return loadSearchResultsIfNeeded({ store, location }).then(() => {
mockApi.verify();
assert(
dispatch.firstCall.calledWith(
- searchActions.searchStart(query, page)),
+ searchActions.searchStart({ query, page })),
'searchStart not called');
assert(
dispatch.secondCall.calledWith(
- searchActions.searchFail({ page, query })),
+ searchActions.searchFail({
+ addonType: undefined,
+ app: undefined,
+ category: undefined,
+ page,
+ query,
+ })),
'searchFail not called');
});
});
diff --git a/tests/client/amo/test_store.js b/tests/client/amo/test_store.js
index c97620d3ac3..9e919892d56 100644
--- a/tests/client/amo/test_store.js
+++ b/tests/client/amo/test_store.js
@@ -5,7 +5,9 @@ describe('amo createStore', () => {
const store = createStore();
assert.deepEqual(
Object.keys(store.getState()).sort(),
- ['addons', 'api', 'auth', 'reduxAsyncConnect', 'reviews', 'search']);
+ ['addons', 'api', 'auth', 'categories', 'reduxAsyncConnect', 'reviews',
+ 'search']
+ );
});
it('creates an empty store', () => {
diff --git a/tests/client/core/actions/test_categories.js b/tests/client/core/actions/test_categories.js
new file mode 100644
index 00000000000..8df28f8e959
--- /dev/null
+++ b/tests/client/core/actions/test_categories.js
@@ -0,0 +1,48 @@
+import * as actions from 'core/actions/categories';
+
+describe('CATEGORIES_GET', () => {
+ const params = {
+ loading: true,
+ };
+ const action = actions.categoriesGet(params);
+
+ it('sets the type', () => {
+ assert.equal(action.type, 'CATEGORIES_GET');
+ });
+
+ it('sets the query', () => {
+ assert.deepEqual(action.payload, params);
+ });
+});
+
+describe('CATEGORIES_LOAD', () => {
+ const params = {
+ result: { 0: 'foo', 1: 'bar' },
+ loading: false,
+ };
+ const action = actions.categoriesLoad(params);
+
+ it('sets the type', () => {
+ assert.equal(action.type, 'CATEGORIES_LOAD');
+ });
+
+ it('sets the payload', () => {
+ assert.deepEqual(action.payload.loading, false);
+ assert.deepEqual(action.payload.results, ['foo', 'bar']);
+ });
+});
+
+describe('CATEGORIES_FAILED', () => {
+ const params = {
+ loading: false,
+ };
+ const action = actions.categoriesFail(params);
+
+ it('sets the type', () => {
+ assert.equal(action.type, 'CATEGORIES_FAILED');
+ });
+
+ it('sets the payload', () => {
+ assert.deepEqual(action.payload, params);
+ });
+});
diff --git a/tests/client/core/actions/test_search.js b/tests/client/core/actions/test_search.js
index b7ec65aaa23..3069036f319 100644
--- a/tests/client/core/actions/test_search.js
+++ b/tests/client/core/actions/test_search.js
@@ -1,39 +1,59 @@
import * as actions from 'core/actions/search';
describe('SEARCH_STARTED', () => {
- const action = actions.searchStart('foo', 5);
+ const params = {
+ page: 7,
+ addonType: 'theme',
+ app: 'android',
+ category: undefined,
+ query: 'foo',
+ };
+ const action = actions.searchStart(params);
it('sets the type', () => {
assert.equal(action.type, 'SEARCH_STARTED');
});
it('sets the query', () => {
- assert.deepEqual(action.payload, { query: 'foo', page: 5 });
+ assert.deepEqual(action.payload, params);
});
});
describe('SEARCH_LOADED', () => {
- const entities = sinon.stub();
- const result = sinon.stub();
- const action = actions.searchLoad({ query: 'foo', entities, result });
+ const params = {
+ entities: sinon.stub(),
+ result: sinon.stub(),
+ addonType: 'extension',
+ app: 'firefox',
+ category: 'alerts-notifications',
+ query: 'foo',
+ };
+ const action = actions.searchLoad(params);
it('sets the type', () => {
assert.equal(action.type, 'SEARCH_LOADED');
});
it('sets the payload', () => {
- assert.deepEqual(action.payload, { query: 'foo', entities, result });
+ assert.deepEqual(action.payload, params);
});
});
describe('SEARCH_FAILED', () => {
- const action = actions.searchFail({ query: 'foo', page: 25 });
+ const params = {
+ page: 25,
+ addonType: 'extension',
+ app: 'firefox',
+ category: 'alerts-notifications',
+ query: 'foo',
+ };
+ const action = actions.searchFail(params);
it('sets the type', () => {
assert.equal(action.type, 'SEARCH_FAILED');
});
it('sets the payload', () => {
- assert.deepEqual(action.payload, { page: 25, query: 'foo' });
+ assert.deepEqual(action.payload, params);
});
});
diff --git a/tests/client/core/api/test_api.js b/tests/client/core/api/test_api.js
index fee2101658a..e8d9219e1fa 100644
--- a/tests/client/core/api/test_api.js
+++ b/tests/client/core/api/test_api.js
@@ -53,11 +53,12 @@ describe('api', () => {
it('sets the lang, limit, page and query', () => {
// FIXME: This shouldn't fail if the args are in a different order.
mockWindow.expects('fetch')
- .withArgs(`${apiHost}/api/v3/addons/search/?q=foo&page=3&lang=en-US`)
+ .withArgs(
+ `${apiHost}/api/v3/addons/search/?q=foo&page=3&app=android&category=&type=&lang=en-US`)
.once()
.returns(mockResponse());
return api.search({
- api: { lang: 'en-US' },
+ api: { clientApp: 'android', lang: 'en-US' },
auth: true,
query: 'foo',
page: 3,
@@ -81,14 +82,15 @@ describe('api', () => {
});
it('surfaces status and apiURL on Error instance', () => {
- const url = `${apiHost}/api/v3/addons/search/?q=foo&page=3&lang=en-US`;
+ const url =
+ `${apiHost}/api/v3/addons/search/?q=foo&page=3&app=firefox&category=&type=&lang=en-US`;
mockWindow.expects('fetch')
.withArgs(url)
.once()
.returns(mockResponse({ ok: false, status: 401 }));
return api.search({
- api: { lang: 'en-US' },
+ api: { clientApp: 'firefox', lang: 'en-US' },
auth: true,
query: 'foo',
page: 3,
@@ -100,6 +102,36 @@ describe('api', () => {
});
});
+ describe('categories api', () => {
+ function mockResponse() {
+ return Promise.resolve({
+ ok: true,
+ json() {
+ return Promise.resolve({
+ results: [
+ { slug: 'foo' },
+ { slug: 'food' },
+ { slug: 'football' },
+ ],
+ });
+ },
+ });
+ }
+
+ it('sets the addonType, clientApp, and lang', () => {
+ // FIXME: This shouldn't fail if the args are in a different order.
+ mockWindow.expects('fetch')
+ .withArgs(
+ `${apiHost}/api/v3/addons/categories/?lang=en-US`)
+ .once()
+ .returns(mockResponse());
+ return api.categories({
+ api: { clientApp: 'android', lang: 'en-US' },
+ })
+ .then(() => mockWindow.verify());
+ });
+ });
+
describe('search api', () => {
function mockResponse() {
return Promise.resolve({
@@ -119,16 +151,24 @@ describe('api', () => {
it('sets the lang, limit, page and query', () => {
// FIXME: This shouldn't fail if the args are in a different order.
mockWindow.expects('fetch')
- .withArgs(`${apiHost}/api/v3/addons/search/?q=foo&page=3&lang=en-US`)
+ .withArgs(
+ `${apiHost}/api/v3/addons/search/?q=foo&page=3&app=firefox&category=&type=&lang=en-US`)
.once()
.returns(mockResponse());
- return api.search({ api: { lang: 'en-US' }, query: 'foo', page: 3 })
+ return api.search({
+ api: { clientApp: 'firefox', lang: 'en-US' },
+ query: 'foo',
+ page: 3,
+ })
.then(() => mockWindow.verify());
});
it('normalizes the response', () => {
mockWindow.expects('fetch').once().returns(mockResponse());
- return api.search({ query: 'foo' })
+ return api.search({
+ api: { clientApp: 'android', lang: 'en-US' },
+ query: 'foo',
+ })
.then((results) => {
assert.deepEqual(results.result.results, ['foo', 'food', 'football']);
assert.deepEqual(results.entities, {
@@ -162,7 +202,10 @@ describe('api', () => {
{ headers: {}, method: 'GET' })
.once()
.returns(mockResponse());
- return api.fetchAddon({ api: { lang: 'en-US' }, slug: 'foo' })
+ return api.fetchAddon({
+ api: { clientApp: 'android', lang: 'en-US' },
+ slug: 'foo',
+ })
.then(() => mockWindow.verify());
});
@@ -184,7 +227,10 @@ describe('api', () => {
{ headers: {}, method: 'GET' })
.once()
.returns(Promise.resolve({ ok: false }));
- return api.fetchAddon({ api: { lang: 'en-US' }, slug: 'foo' })
+ return api.fetchAddon({
+ api: { clientApp: 'android', lang: 'en-US' },
+ slug: 'foo',
+ })
.then(unexpectedSuccess,
(error) => assert.equal(error.message, 'Error calling API'));
});
@@ -198,7 +244,10 @@ describe('api', () => {
{ headers: { authorization: `Bearer ${token}` }, method: 'GET' })
.once()
.returns(mockResponse());
- return api.fetchAddon({ api: { lang: 'en-US', token }, slug: 'bar' })
+ return api.fetchAddon({
+ api: { clientApp: 'android', lang: 'en-US', token },
+ slug: 'bar',
+ })
.then((results) => {
const foo = { slug: 'foo', name: 'Foo!' };
assert.deepEqual(results.result, 'foo');
@@ -230,7 +279,11 @@ describe('api', () => {
})
.once()
.returns(mockResponse());
- return api.login({ api: { lang: 'en-US' }, code: 'my-code', state: 'my-state' })
+ return api.login({
+ api: { clientApp: 'android', lang: 'en-US' },
+ code: 'my-code',
+ state: 'my-state',
+ })
.then((apiResponse) => {
assert.strictEqual(apiResponse, response);
mockWindow.verify();
@@ -244,7 +297,11 @@ describe('api', () => {
.withArgs(`${apiHost}/api/v3/accounts/login/?config=my-config&lang=fr`)
.once()
.returns(mockResponse());
- return api.login({ api: { lang: 'fr' }, code: 'my-code', state: 'my-state' })
+ return api.login({
+ api: { clientApp: 'android', lang: 'fr' },
+ code: 'my-code',
+ state: 'my-state',
+ })
.then(() => mockWindow.verify());
});
});
@@ -264,7 +321,9 @@ describe('api', () => {
ok: true,
json() { return user; },
}));
- return api.fetchProfile({ api: { lang: 'en-US', token } })
+ return api.fetchProfile({
+ api: { clientApp: 'android', lang: 'en-US', token },
+ })
.then((apiResponse) => {
assert.deepEqual(apiResponse, { entities: { users: { foo: user } }, result: 'foo' });
mockWindow.verify();
diff --git a/tests/client/core/components/TestSearchResults.js b/tests/client/core/components/TestSearchResults.js
index 2ebd8e10270..c24d3095eb0 100644
--- a/tests/client/core/components/TestSearchResults.js
+++ b/tests/client/core/components/TestSearchResults.js
@@ -1,37 +1,73 @@
import React from 'react';
+import ReactDOM from 'react-dom';
import {
renderIntoDocument as render,
findRenderedComponentWithType,
+ findRenderedDOMComponentWithClass,
isDOMComponent,
} from 'react-addons-test-utils';
+import { Provider } from 'react-redux';
import SearchResults from 'core/components/Search/SearchResults';
+import createStore from 'amo/store';
import { getFakeI18nInst } from 'tests/client/helpers';
describe('', () => {
+ class FakeCategoryInfo extends React.Component {
+ render() {
+ return Category!
;
+ }
+ }
+
function renderResults(props) {
+ const initialState = { api: { clientApp: 'android', lang: 'en-GB' } };
+
return findRenderedComponentWithType(render(
-
+
+
+
), SearchResults).getWrappedInstance();
}
- it('renders empty search results container', () => {
+ it('renders prompt for query with no search terms', () => {
const root = renderResults();
const searchResults = root.container;
+ const searchResultsMessage = root.message;
assert.ok(isDOMComponent(searchResults));
- assert.equal(searchResults.childNodes.length, 0);
+ assert.equal(searchResults.childNodes.length, 1);
+ assert.include(searchResultsMessage.firstChild.nodeValue,
+ 'Please enter search terms');
});
- it('renders error when query is an empty string', () => {
+ it('renders prompt for query when query is an empty string', () => {
const root = renderResults({ query: '' });
const searchResultsMessage = root.message;
assert.include(searchResultsMessage.firstChild.nodeValue,
- 'supply a valid search');
+ 'Please enter search terms');
+ });
+
+ it('renders no results when no results and valid category', () => {
+ const root = renderResults({
+ category: 'alerts-updates',
+ count: 0,
+ loading: false,
+ query: '',
+ results: [],
+ });
+ const searchResultsMessage = root.message;
+ // Using textContent here since we want to see the text inside the p.
+ // Since it has dynamic content is wrapped in a span implicitly.
+ assert.include(searchResultsMessage.textContent, 'No results were found');
});
- it('renders error when no results and valid query', () => {
- const root = renderResults({ query: 'test' });
+ it('renders no results when no results and valid query', () => {
+ const root = renderResults({
+ count: 0,
+ loading: false,
+ query: 'no results',
+ results: [],
+ });
const searchResultsMessage = root.message;
// Using textContent here since we want to see the text inside the p.
// Since it has dynamic content is wrapped in a span implicitly.
@@ -77,4 +113,34 @@ describe('', () => {
assert.include(searchResultsMessage.textContent,
'Your search for "test" returned 1 result');
});
+
+ it('renders category info when a category is supplied', () => {
+ const root = renderResults({
+ CategoryInfoComponent: FakeCategoryInfo,
+ category: 'alerts-updates',
+ clientApp: 'firefox',
+ count: 1,
+ results: [
+ { name: 'result 1', slug: '1' },
+ ],
+ });
+ const categoryInfoHeader = ReactDOM.findDOMNode(
+ findRenderedDOMComponentWithClass(root, 'CategoryInfo-header'));
+ assert.include(categoryInfoHeader.textContent, 'Category!');
+ });
+
+ it('uses the CategoryInfo component', () => {
+ const root = renderResults({
+ CategoryInfoComponent: null,
+ category: 'alerts-updates',
+ count: 1,
+ results: [
+ { name: 'result 1', slug: '1' },
+ ],
+ });
+ assert.throws(() => {
+ ReactDOM.findDOMNode(
+ findRenderedDOMComponentWithClass(root, 'CategoryInfo-header'));
+ }, 'Did not find exactly one match');
+ });
});
diff --git a/tests/client/core/reducers/test_categories.js b/tests/client/core/reducers/test_categories.js
new file mode 100644
index 00000000000..48943771506
--- /dev/null
+++ b/tests/client/core/reducers/test_categories.js
@@ -0,0 +1,63 @@
+import categories from 'core/reducers/categories';
+
+describe('categories reducer', () => {
+ const initialState = { categories: [], error: false, loading: true };
+
+ it('defaults to an empty set of categories', () => {
+ const state = categories(undefined, { type: 'unrelated' });
+ assert.deepEqual(state.categories, []);
+ });
+
+ it('defaults to not loading', () => {
+ const { loading } = categories(undefined, { type: 'unrelated' });
+ assert.equal(loading, false);
+ });
+
+ it('defaults to not error', () => {
+ const { error } = categories(undefined, { type: 'unrelated' });
+ assert.equal(error, false);
+ });
+
+ describe('CATEGORIES_GET', () => {
+ it('sets loading', () => {
+ const state = categories(initialState,
+ { type: 'CATEGORIES_GET', payload: { loading: true } });
+ assert.deepEqual(state.categories, []);
+ assert.equal(state.error, false);
+ assert.equal(state.loading, true);
+ });
+ });
+
+ describe('CATEGORIES_LOAD', () => {
+ const results = ['foo', 'bar'];
+ const state = categories(initialState, {
+ type: 'CATEGORIES_LOAD',
+ payload: { results },
+ });
+
+ it('sets the categories', () => {
+ assert.deepEqual(state.categories, ['foo', 'bar']);
+ });
+
+ it('sets loading', () => {
+ const { loading } = state;
+ assert.strictEqual(loading, false);
+ });
+
+ it('sets no error', () => {
+ const { error } = state;
+ assert.deepEqual(error, false);
+ });
+ });
+
+ describe('CATEGORIES_FAILED', () => {
+ it('sets error to be true', () => {
+ const error = true;
+ const loading = false;
+
+ const state = categories(initialState, {
+ type: 'CATEGORIES_FAILED', payload: { error, loading } });
+ assert.deepEqual(state, { categories: [], error, loading });
+ });
+ });
+});
diff --git a/tests/client/core/reducers/test_search.js b/tests/client/core/reducers/test_search.js
index 4800e746922..1f0113fff08 100644
--- a/tests/client/core/reducers/test_search.js
+++ b/tests/client/core/reducers/test_search.js
@@ -1,9 +1,9 @@
import search from 'core/reducers/search';
describe('search reducer', () => {
- it('defaults to a null query', () => {
+ it('defaults to an undefined query', () => {
const { query } = search(undefined, { type: 'unrelated' });
- assert.strictEqual(query, null);
+ assert.strictEqual(query, undefined);
});
it('defaults to not loading', () => {
@@ -94,9 +94,24 @@ describe('search reducer', () => {
it('resets the initialState with page and query', () => {
const page = 5;
const query = 'add-ons';
- const initialState = { foo: 'bar', query: 'hi', page: 100, results: [1, 2, 3] };
- const state = search(initialState, { type: 'SEARCH_FAILED', payload: { page, query } });
- assert.deepEqual(state, { count: 0, loading: false, page, query, results: [] });
+ const initialState = {
+ foo: 'bar',
+ query: 'hi',
+ page: 100,
+ results: [1, 2, 3],
+ };
+ const state = search(initialState, {
+ type: 'SEARCH_FAILED',
+ payload: { page, query },
+ });
+ assert.deepEqual(state, {
+ category: undefined,
+ count: 0,
+ loading: false,
+ page,
+ query,
+ results: [],
+ });
});
});
});
diff --git a/tests/client/core/test_utils.js b/tests/client/core/test_utils.js
index a6b9e6a71f6..324e888d12c 100644
--- a/tests/client/core/test_utils.js
+++ b/tests/client/core/test_utils.js
@@ -4,6 +4,7 @@ import config from 'config';
import { sprintf } from 'jed';
import * as actions from 'core/actions';
+import * as categoriesActions from 'core/actions/categories';
import * as api from 'core/api';
import {
addQueryParams,
@@ -16,6 +17,7 @@ import {
isAllowedOrigin,
isValidClientApp,
loadAddonIfNeeded,
+ loadCategoriesIfNeeded,
nl2br,
} from 'core/utils';
import { unexpectedSuccess } from 'tests/client/helpers';
@@ -317,6 +319,80 @@ 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: () => {
+ return {
+ 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
', () => {
assert.equal(nl2br('\n'), '
');