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 deleted file mode 100644 index 225cc6e5fa5..00000000000 Binary files a/assets/.DS_Store and /dev/null differ diff --git a/src/amo/components/Categories.js b/src/amo/components/Categories.js new file mode 100644 index 00000000000..e947198e74b --- /dev/null +++ b/src/amo/components/Categories.js @@ -0,0 +1,71 @@ +import React, { PropTypes } from 'react'; +import { compose } from 'redux'; + +import Link from 'amo/components/Link'; +import translate from 'core/i18n/translate'; + +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 default compose( + translate({ withRef: true }), +)(CategoriesBase); diff --git a/src/amo/components/Categories.scss b/src/amo/components/Categories.scss new file mode 100644 index 00000000000..b48e1fd5d58 --- /dev/null +++ b/src/amo/components/Categories.scss @@ -0,0 +1,21 @@ +.Categories-list { + margin: 0; + padding: 0; +} + +.Categories-list-item { + 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%; +} + +.Categories-link { + 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/SearchPage.js b/src/amo/components/SearchPage.js index b2f15761108..0447a611f9d 100644 --- a/src/amo/components/SearchPage.js +++ b/src/amo/components/SearchPage.js @@ -1,43 +1,51 @@ 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, + hasSearchParams: PropTypes.bool.isRequired, loading: PropTypes.bool.isRequired, page: PropTypes.number, results: PropTypes.array, 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, hasSearchParams, 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..f4703365203 --- /dev/null +++ b/src/amo/containers/CategoriesPage.js @@ -0,0 +1,24 @@ +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 function mapStateToProps(state, ownProps) { + return { + addonType: ownProps.params.addonType.replace(/s$/, ''), + clientApp: state.api.clientApp, + ...state.categories, + }; +} + +export default compose( + asyncConnect([{ + deferred: true, + key: 'CategoriesPage', + promise: loadCategoriesIfNeeded, + }]), + connect(mapStateToProps), +)(Categories); diff --git a/src/amo/containers/Home.js b/src/amo/containers/Home.js index 0663af42a56..8f00d1be4e0 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')} @@ -37,7 +38,7 @@ export class HomePageBase extends React.Component {
  • {i18n.gettext('Sporty')}
  • {i18n.gettext('Mystical')}
  • - + {i18n.gettext('Browse all themes')} 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..376bfa12651 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,20 @@ 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 }) { // 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: api.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..80ec0ddfa0d 100644 --- a/src/core/components/Search/SearchResults.js +++ b/src/core/components/Search/SearchResults.js @@ -10,31 +10,45 @@ 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, + hasSearchParams: PropTypes.bool.isRequired, 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, + hasSearchParams, i18n, loading, query, results, } = this.props; - let searchResults; - let messageText; let hideMessageText = false; + let messageText; + let resultHeader; + let searchResults; + + if (category && category.length && CategoryInfoComponent) { + resultHeader = ( + + ); + } - if (query && count > 0) { + if (hasSearchParams && count > 0) { hideMessageText = true; messageText = i18n.sprintf( i18n.ngettext( @@ -52,23 +66,32 @@ class SearchResults extends React.Component { ))} ); - } else if (query && loading) { + } else if (hasSearchParams && loading) { messageText = i18n.gettext('Searching...'); - } else if (query && results.length === 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 && count === 0) { + if (query) { + messageText = i18n.sprintf( + i18n.gettext('No results were found for "%(query)s".'), { query }); + } else if (hasSearchParams) { + // 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 { + messageText = i18n.gettext( + 'Please enter a search term to search Mozilla Add-ons.'); + } } - const message = messageText ? + const message = (

    { this.message = ref; }} className={classNames({ 'visually-hidden': hideMessageText, - 'SearchReuslts-message': !hideMessageText, - })}>{messageText}

    : null; + 'SearchResults-message': !hideMessageText, + })}>{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..4ffe06ad649 100644 --- a/src/core/containers/SearchPage.js +++ b/src/core/containers/SearchPage.js @@ -1,30 +1,57 @@ 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 hasSearchParams = Object.keys(queryStringMap).some((queryKey) => { + return location.query[queryKey] !== undefined && + location.query[queryKey].length; + }); + const searchParamsMatch = Object.keys(queryStringMap).some((queryKey) => { + return location.query[queryKey] !== undefined && + location.query[queryKey] === state.search[queryStringMap[queryKey]]; + }); + + if (searchParamsMatch) { + return { hasSearchParams, ...state.search }; } - return { lang }; + + return { hasSearchParams }; } -function performSearch({ dispatch, page, query, api, auth = false }) { - if (!query) { - 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 }))); +function performSearch({ + addonType, api, auth = false, category, dispatch, page, query, +}) { + dispatch(searchStart({ addonType, category, page, query })); + return search({ addonType, api, auth, category, page, query }) + .then((response) => { + return dispatch(searchLoad({ + addonType, + category, + page, + query, + ...response, + })); + }) + .catch(() => { + return dispatch(searchFail({ addonType, category, page, query })); + }); } -export function isLoaded({ page, query, state }) { - return state.query === query && state.page === page && !state.loading; +export function isLoaded({ addonType, category, page, query, state }) { + return state.addonType === addonType && state.category === category && + state.page === page && state.query === query && !state.loading; } export function parsePage(page) { @@ -32,19 +59,43 @@ export function parsePage(page) { return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed; } -export function loadSearchResultsIfNeeded({ store: { dispatch, getState }, location }) { +export function loadSearchResultsIfNeeded({ + location, store: { dispatch, getState }, +}) { + 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..6353d4225c1 --- /dev/null +++ b/tests/client/amo/components/TestCategeories.js @@ -0,0 +1,120 @@ +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 baseProps = { + clientApp: 'android', + categories, + }; + const initialState = { + api: { clientApp: 'android', lang: 'fr' }, + categories: { ...categories }, + }; + + return findDOMNode(findRenderedComponentWithType(renderIntoDocument( + + + + ), Categories)); + } + + it('renders Categories', () => { + const root = render({ + addonType: 'extension', + 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..64d8941fd26 100644 --- a/tests/client/amo/components/TestSearchPage.js +++ b/tests/client/amo/components/TestSearchPage.js @@ -1,44 +1,54 @@ 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(), + hasSearchParams: true, 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.hasSearchParams, props.hasSearchParams); + 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', 'hasSearchParams', 'loading', 'results', 'query'].sort() + ); }); it('renders a Paginate', () => { @@ -51,16 +61,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..8721c8515d6 --- /dev/null +++ b/tests/client/amo/containers/TestCategoriesPage.js @@ -0,0 +1,20 @@ +import { mapStateToProps } from 'amo/containers/CategoriesPage'; + + +describe('', () => { + it('maps state to props', () => { + const props = mapStateToProps({ + api: { clientApp: 'android', lang: 'pt' }, + categories: { categories: [], loading: true }, + }, { + params: { addonType: 'theme' }, + }); + + assert.deepEqual(props, { + addonType: 'theme', + categories: [], + clientApp: 'android', + loading: true, + }); + }); +}); diff --git a/tests/client/amo/containers/TestHome.js b/tests/client/amo/containers/TestHome.js index 21c65603b29..facfa1d2a43 100644 --- a/tests/client/amo/containers/TestHome.js +++ b/tests/client/amo/containers/TestHome.js @@ -7,25 +7,31 @@ 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', () => { - it('renders a heading', () => { + function render(props) { const initialState = { api: { clientApp: 'android', lang: 'en-GB' } }; - const root = findRenderedComponentWithType(renderIntoDocument( + return findDOMNode(findRenderedComponentWithType(renderIntoDocument( - + - ), Home).getWrappedInstance(); - const rootNode = findDOMNode(root); + ), Home).getWrappedInstance()); + } + + it('renders a heading', () => { + const root = render(); const content = [ 'What do you want Firefox to do?', 'How do you want Firefox to look?', ]; - Array.from(rootNode.querySelectorAll('.HomePage-subheading')) - .map((el, index) => assert.equal(el.textContent, content[index])); + + Array.from(root.querySelectorAll('.HomePage-subheading')) + .forEach((element, index) => { + assert.equal(element.textContent, content[index]); + }); }); }); diff --git a/tests/client/amo/containers/TestSearch.js b/tests/client/amo/containers/TestSearch.js index c1bbb3956aa..565a6649077 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, { hasSearchParams: true, ...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, { hasSearchParams: true }); }); }); @@ -30,6 +30,7 @@ describe('Search.isLoaded()', () => { const state = { page: 2, query: 'ad-block', + hasSearchParams: true, loading: false, results: [{ slug: 'ab', name: 'ad-block' }], }; @@ -86,22 +87,10 @@ describe('CurrentSearchPage.parsePage()', () => { }); describe('CurrentSearchPage.loadSearchResultsIfNeeded()', () => { - it('does not dispatch on undefined query', () => { - const dispatchSpy = sinon.spy(); - const state = { loading: false }; - const store = { - dispatch: dispatchSpy, - getState: () => ({ search: state }), - }; - const location = { query: { page: undefined, q: undefined } }; - loadSearchResultsIfNeeded({ store, location }); - assert.notOk(dispatchSpy.called); - }); - it('returns right away when loaded', () => { const page = 10; const query = 'no ads'; - const state = { loading: false, page, query }; + const state = { hasSearchParams: true, loading: false, page, query }; const store = { dispatch: sinon.spy(), getState: () => ({ search: state }) }; const location = { query: { page, q: query } }; assert.strictEqual(loadSearchResultsIfNeeded({ store, location }), true); @@ -112,7 +101,12 @@ describe('CurrentSearchPage.loadSearchResultsIfNeeded()', () => { const query = 'no ads'; const state = { api: { token: 'a.jwt.token' }, - search: { loading: false, page, query: 'old query' }, + search: { + hasSearchParams: true, + loading: false, + page, + query: 'old query', + }, }; const dispatch = sinon.spy(); const store = { dispatch, getState: () => state }; @@ -123,17 +117,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'); }); }); @@ -143,7 +151,12 @@ describe('CurrentSearchPage.loadSearchResultsIfNeeded()', () => { const query = 'no ads'; const state = { api: {}, - search: { loading: false, page, query: 'old query' }, + search: { + hasSearchParams: true, + loading: false, + page, + query: 'old query', + }, }; const dispatch = sinon.spy(); const store = { dispatch, getState: () => state }; @@ -152,17 +165,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..f29e1984e13 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, @@ -67,7 +68,11 @@ describe('api', () => { it('normalizes the response', () => { mockWindow.expects('fetch').once().returns(mockResponse()); - return api.search({ auth: true, query: 'foo' }) + return api.search({ + api: { clientApp: 'android', lang: 'en-US' }, + auth: true, + query: 'foo', + }) .then((results) => { assert.deepEqual(results.result.results, ['foo', 'food', 'football']); assert.deepEqual(results.entities, { @@ -81,14 +86,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 +106,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 +155,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 +206,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 +231,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 +248,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 +283,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 +301,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 +325,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..97c4dd0a01a 100644 --- a/tests/client/core/components/TestSearchResults.js +++ b/tests/client/core/components/TestSearchResults.js @@ -1,37 +1,75 @@ 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 a search term'); }); - it('renders error when query is an empty string', () => { - const root = renderResults({ query: '' }); + it('renders prompt for query when query is an empty string', () => { + const root = renderResults({ hasSearchParams: false, query: '' }); const searchResultsMessage = root.message; assert.include(searchResultsMessage.firstChild.nodeValue, - 'supply a valid search'); + 'Please enter a search term'); }); - it('renders error when no results and valid query', () => { - const root = renderResults({ query: 'test' }); + it('renders no results when no results and valid category', () => { + const root = renderResults({ + category: 'alerts-updates', + count: 0, + hasSearchParams: true, + 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 no results when no results and valid query', () => { + const root = renderResults({ + count: 0, + hasSearchParams: true, + 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. @@ -40,8 +78,9 @@ describe('', () => { it('renders a loading message when loading', () => { const root = renderResults({ - query: 'test', + hasSearchParams: true, loading: true, + query: 'test', }); const searchResultsMessage = root.message; assert.equal(searchResultsMessage.textContent, 'Searching...'); @@ -50,6 +89,7 @@ describe('', () => { it('renders search results when supplied', () => { const root = renderResults({ count: 5, + hasSearchParams: true, query: 'test', results: [ { name: 'result 1', slug: '1' }, @@ -68,6 +108,7 @@ describe('', () => { it('renders search results in the singular', () => { const root = renderResults({ count: 1, + hasSearchParams: true, query: 'test', results: [ { name: 'result 1', slug: '1' }, @@ -77,4 +118,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'), '
    ');