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 ( +
+ +
+ ); + } +} + +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 @@ + + + Created with Sketch. + + + + + + + + + + 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'), '
    ');