diff --git a/docs/adding-a-page.md b/docs/adding-a-page.md index d8d4a835e65..1280f7a3d7c 100644 --- a/docs/adding-a-page.md +++ b/docs/adding-a-page.md @@ -250,8 +250,8 @@ import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; import { asyncConnect } from 'redux-async-connect'; +import { loadEntities, setCurrentUser } from 'core/actions'; import { fetchProfile } from 'core/api'; -import { loadEntities, setCurrentUser } from 'search/actions'; class UserPage extends React.Component { static propTypes = { diff --git a/karma.conf.js b/karma.conf.js index 6361fb2c41f..853e9bdfa0b 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -26,6 +26,8 @@ const newWebpackConfig = Object.assign({}, webpackConfigProd, { new webpack.NormalModuleReplacementPlugin(/config$/, 'core/client/config.js'), // Substitutes client only config. new webpack.NormalModuleReplacementPlugin(/core\/logger$/, 'core/client/logger.js'), + // Use the browser's window for window. + new webpack.NormalModuleReplacementPlugin(/core\/window/, 'core/browserWindow.js'), ], devtool: 'inline-source-map', module: { diff --git a/package.json b/package.json index 645b37c8dd5..a2c3e0fe8c0 100644 --- a/package.json +++ b/package.json @@ -123,11 +123,13 @@ "camelcase": "3.0.0", "classnames": "2.2.5", "config": "1.20.4", + "dompurify": "0.8.0", "express": "4.13.4", "extract-text-webpack-plugin": "1.0.1", "helmet": "2.1.0", "isomorphic-fetch": "2.2.1", "jed": "1.1.0", + "jsdom": "9.2.0", "normalize.css": "4.1.1", "normalizr": "2.1.0", "piping": "0.3.2", diff --git a/src/core/actions/index.js b/src/core/actions/index.js index e5404d99f78..00cd2e8b12a 100644 --- a/src/core/actions/index.js +++ b/src/core/actions/index.js @@ -4,3 +4,19 @@ export function setJWT(token) { payload: {token}, }; } + +export function loadEntities(entities) { + return { + type: 'ENTITIES_LOADED', + payload: {entities}, + }; +} + +export function setCurrentUser(username) { + return { + type: 'SET_CURRENT_USER', + payload: { + username, + }, + }; +} diff --git a/src/core/api/index.js b/src/core/api/index.js index 3118e02fb81..5cd8ba23b7d 100644 --- a/src/core/api/index.js +++ b/src/core/api/index.js @@ -7,15 +7,15 @@ import 'isomorphic-fetch'; const API_BASE = `${config.get('apiHost')}${config.get('apiPath')}`; -const addon = new Schema('addons', {idAttribute: 'slug'}); -const user = new Schema('users', {idAttribute: 'username'}); +export const addon = new Schema('addons', {idAttribute: 'slug'}); +export const user = new Schema('users', {idAttribute: 'username'}); function makeQueryString(query) { return url.format({query}); } -function callApi({endpoint, schema, params, auth = false, state = {}, method = 'get', body, - credentials}) { +export function callApi({endpoint, schema, params, auth = false, state = {}, method = 'get', body, + credentials}) { const queryString = makeQueryString(params); const options = { headers: {}, diff --git a/src/core/browserWindow.js b/src/core/browserWindow.js new file mode 100644 index 00000000000..76f1ace4227 --- /dev/null +++ b/src/core/browserWindow.js @@ -0,0 +1 @@ +export default window; diff --git a/src/core/purify.js b/src/core/purify.js new file mode 100644 index 00000000000..32272f99082 --- /dev/null +++ b/src/core/purify.js @@ -0,0 +1,4 @@ +import createDOMPurify from 'dompurify'; +import universalWindow from 'core/window'; + +export default createDOMPurify(universalWindow); diff --git a/src/core/reducers/addons.js b/src/core/reducers/addons.js index 2bf38bd443b..eb52cbbb4ff 100644 --- a/src/core/reducers/addons.js +++ b/src/core/reducers/addons.js @@ -3,7 +3,28 @@ const initialState = {}; export default function addon(state = initialState, action) { const { payload } = action; if (payload && payload.entities && payload.entities.addons) { - return {...state, ...payload.entities.addons}; + const newState = {...state}; + Object.keys(payload.entities.addons).forEach((key) => { + const thisAddon = payload.entities.addons[key]; + if (thisAddon.theme_data) { + newState[key] = { + ...thisAddon, + ...thisAddon.theme_data, + guid: `${thisAddon.id}@personas.mozilla.org`, + }; + delete newState[key].theme_data; + } else { + if (thisAddon.current_version && thisAddon.current_version.files.length > 0) { + newState[key] = { + ...thisAddon, + installURL: thisAddon.current_version.files[0].url, + }; + } else { + newState[key] = thisAddon; + } + } + }); + return newState; } return state; } diff --git a/src/core/window.js b/src/core/window.js new file mode 100644 index 00000000000..2a7e7d8f1cf --- /dev/null +++ b/src/core/window.js @@ -0,0 +1,8 @@ +import { jsdom } from 'jsdom'; + +export default jsdom('', { + features: { + FetchExternalResources: false, // disables resource loading over HTTP / filesystem + ProcessExternalResources: false, // do not execute JS within script blocks + }, +}).defaultView; diff --git a/src/disco/actions.js b/src/disco/actions.js new file mode 100644 index 00000000000..e11968d580b --- /dev/null +++ b/src/disco/actions.js @@ -0,0 +1,8 @@ +export function discoResults(results) { + return { + type: 'DISCO_RESULTS', + payload: { + results, + }, + }; +} diff --git a/src/disco/api.js b/src/disco/api.js new file mode 100644 index 00000000000..a75dca32342 --- /dev/null +++ b/src/disco/api.js @@ -0,0 +1,16 @@ +import { Schema, arrayOf } from 'normalizr'; + +import { addon, callApi } from 'core/api'; + +export const discoResult = new Schema('discoResults', {idAttribute: (result) => result.addon.slug}); +discoResult.addon = addon; + + +export function getDiscoveryAddons({ api }) { + return callApi({ + endpoint: 'discovery', + schema: {results: arrayOf(discoResult)}, + params: {lang: 'en-US'}, + state: api, + }); +} diff --git a/src/disco/components/Addon.js b/src/disco/components/Addon.js index b1d0c3e1a25..426ac402a09 100644 --- a/src/disco/components/Addon.js +++ b/src/disco/components/Addon.js @@ -2,6 +2,7 @@ import classNames from 'classnames'; import React, { PropTypes } from 'react'; import { sprintf } from 'sprintf-js'; import translate from 'core/i18n/translate'; +import purify from 'core/purify'; import themeAction, { getThemeData } from 'disco/themePreview'; @@ -18,26 +19,32 @@ import { import 'disco/css/Addon.scss'; +function sanitizeHTML(text, allowTags = []) { + // TODO: Accept tags to allow and run through dom-purify. + return { + __html: purify.sanitize(text, {ALLOWED_TAGS: allowTags}), + }; +} export class Addon extends React.Component { static propTypes = { accentcolor: PropTypes.string, closeErrorAction: PropTypes.func, + description: PropTypes.string, editorialDescription: PropTypes.string.isRequired, errorMessage: PropTypes.string, footerURL: PropTypes.string, headerURL: PropTypes.string, heading: PropTypes.string.isRequired, - id: PropTypes.string.isRequired, i18n: PropTypes.string.isRequired, - imageURL: PropTypes.string, + iconUrl: PropTypes.string, + id: PropTypes.string.isRequired, + previewURL: PropTypes.string, name: PropTypes.string.isRequired, slug: PropTypes.string.isRequired, status: PropTypes.oneOf(validInstallStates).isRequired, - subHeading: PropTypes.string, textcolor: PropTypes.string, themeAction: PropTypes.func, - themeURL: PropTypes.string, type: PropTypes.oneOf(validAddonTypes).isRequired, } @@ -60,15 +67,15 @@ export class Addon extends React.Component { } getLogo() { - const { imageURL } = this.props; + const { iconUrl } = this.props; if (this.props.type === EXTENSION_TYPE) { - return
; + return
; } return null; } getThemeImage() { - const { i18n, name, themeURL } = this.props; + const { i18n, name, previewURL } = this.props; if (this.props.type === THEME_TYPE) { return ( - {sprintf(i18n.gettext('Preview); + {sprintf(i18n.gettext('Preview); } return null; } getDescription() { - return { __html: this.props.editorialDescription }; + const { i18n, description, type } = this.props; + if (type === THEME_TYPE) { + return ( +

{i18n.gettext('Hover over the image to preview')}

+ ); + } + return ( +
+ ); } handleClick = (e) => { @@ -99,10 +117,10 @@ export class Addon extends React.Component { } render() { - const { heading, slug, subHeading, type } = this.props; + const { heading, slug, type } = this.props; if (!validAddonTypes.includes(type)) { - throw new Error('Invalid addon type'); + throw new Error(`Invalid addon type "${type}"`); } const addonClasses = classNames('addon', { @@ -116,11 +134,11 @@ export class Addon extends React.Component {
{this.getError()}
-

{heading} {subHeading ? - {subHeading} : null}

-

+

+ {this.getDescription()}

diff --git a/src/disco/constants.js b/src/disco/constants.js index 214535513f4..c810d4d3109 100644 --- a/src/disco/constants.js +++ b/src/disco/constants.js @@ -18,7 +18,7 @@ export const validInstallStates = [ // Add-on types. export const EXTENSION_TYPE = 'extension'; -export const THEME_TYPE = 'theme'; +export const THEME_TYPE = 'persona'; // These types are not used. // export const DICT_TYPE = 'dictionary'; // export const SEARCH_TYPE = 'search'; diff --git a/src/disco/containers/DiscoPane.js b/src/disco/containers/DiscoPane.js index 23f28f5bd41..6d3f95ef03a 100644 --- a/src/disco/containers/DiscoPane.js +++ b/src/disco/containers/DiscoPane.js @@ -5,6 +5,10 @@ import { connect } from 'react-redux'; import { asyncConnect } from 'redux-async-connect'; import { camelCaseProps } from 'core/utils'; +import { getDiscoveryAddons } from 'disco/api'; +import { discoResults } from 'disco/actions'; +import { loadEntities } from 'core/actions'; + import Addon from 'disco/components/Addon'; import translate from 'core/i18n/translate'; @@ -13,7 +17,7 @@ import videoMp4 from 'disco/video/AddOns.mp4'; import videoWebm from 'disco/video/AddOns.webm'; -class DiscoPane extends React.Component { +export class DiscoPane extends React.Component { static propTypes = { i18n: PropTypes.object.isRequired, results: PropTypes.arrayOf(PropTypes.object), @@ -72,15 +76,26 @@ class DiscoPane extends React.Component { } } -function loadDataIfNeeded() { - /* istanbul ignore next */ - return Promise.resolve(); +function loadedAddons(state) { + return state.discoResults.map((result) => ({...result, ...state.addons[result.addon]})); +} + +export function loadDataIfNeeded({ store: { dispatch, getState }}) { + const state = getState(); + const addons = loadedAddons(state); + if (addons.length > 0) { + return Promise.resolve(); + } + return getDiscoveryAddons({api: state.api}) + .then(({ entities, result }) => { + dispatch(loadEntities(entities)); + dispatch(discoResults(result.results.map((r) => entities.discoResults[r]))); + }); } -function mapStateToProps(state) { - const { addons } = state; +export function mapStateToProps(state) { return { - results: [addons['japanese-tattoo'], addons['awesome-screenshot-capture-']], + results: loadedAddons(state), }; } diff --git a/src/disco/css/Addon.scss b/src/disco/css/Addon.scss index 57d9c23238d..d6dcb2469d8 100644 --- a/src/disco/css/Addon.scss +++ b/src/disco/css/Addon.scss @@ -11,6 +11,16 @@ $addon-padding: 20px; line-height: 1.5; margin-top: 20px; + blockquote { + margin: 0; + } + + .editorial-description { + color: $secondary-font-color; + font-size: 12px; + margin: 0.5em 0; + } + &.theme { display: block; @@ -49,6 +59,19 @@ $addon-padding: 20px; } } + .heading { + color: $primary-font-color; + font-size: 18px; + font-weight: medium; + margin: 0; + + span { + color: $secondary-font-color; + font-size: 14px; + font-weight: normal; + } + } + .content { display: flex; align-items: center; @@ -88,25 +111,6 @@ $addon-padding: 20px; padding: 30px 20px; flex-grow: 1; - .heading { - color: $primary-font-color; - font-size: 18px; - font-weight: medium; - margin: 0; - } - - .sub-heading { - color: $secondary-font-color; - font-size: 14px; - font-weight: normal; - } - - .editorial-description { - color: $secondary-font-color; - font-size: 12px; - margin: 0.5em 0; - } - // Remove the bottom margin of the last element. & :last-child { margin-bottom: 0; diff --git a/src/disco/fakeData.js b/src/disco/fakeData.js deleted file mode 100644 index 20adc6a5b77..00000000000 --- a/src/disco/fakeData.js +++ /dev/null @@ -1,40 +0,0 @@ -import config from 'config'; - -const amoCDN = config.get('amoCDN'); -const amoHost = config.get('apiHost'); - -export default { - addons: { - 'japanese-tattoo': { - editorial_description: 'Hover over the image to preview', - heading: 'Japanese Tattoo by MaDonna', - id: '18781', - guid: '18781@personas.mozilla.org', - name: 'Japanese Tattoo', - slug: 'japanese-tattoo', - sub_heading: null, - type: 'theme', - url: `${amoHost}/en-US/firefox/addon/japanese-tattoo/`, - installURL: 'https://addons.mozilla.org/en-US/firefox/addon/10900/', - themeURL: `${amoCDN}/user-media/addons/18781/preview_large.jpg?1239806327`, - headerURL: `${amoCDN}/user-media/addons/18781/personare.jpg?1239806327`, - footerURL: `${amoCDN}/user-media/addons/18781/persona2re.jpg?1239806327`, - textcolor: '#000000', - accentcolor: '#ffffff', - }, - 'awesome-screenshot-capture-': { - editorial_description: '“The best. Very easy to use.” — meetdak', - heading: 'Take screenshots', - sub_heading: 'with Awesome Screenshot Plus', - id: 727, - guid: 'jid0-GXjLLfbCoAx0LcltEdFrEkQdQPI@jetpack', - imageURL: `${amoCDN}/user-media/addon_icons/287/287841-64.png?modified=1353989650`, - installURL: 'https://addons.mozilla.org/firefox/downloads/latest/287841/' + - 'addon-287841-latest.xpi?src=dp-btn-primary', - name: 'Awesome Screenshot Plus - Capture, Annotate & More', - slug: 'awesome-screenshot-capture-', - type: 'extension', - url: `${amoHost}/en-US/firefox/addon/awesome-screenshot-capture-/`, - }, - }, -}; diff --git a/src/disco/reducers/discoResults.js b/src/disco/reducers/discoResults.js new file mode 100644 index 00000000000..4fa2aa18434 --- /dev/null +++ b/src/disco/reducers/discoResults.js @@ -0,0 +1,6 @@ +export default function discoResults(state = [], { type, payload }) { + if (type === 'DISCO_RESULTS') { + return payload.results; + } + return state; +} diff --git a/src/disco/store.js b/src/disco/store.js index 369425023db..23bb9032a52 100644 --- a/src/disco/store.js +++ b/src/disco/store.js @@ -3,14 +3,13 @@ import { reducer as reduxAsyncConnect } from 'redux-async-connect'; import { middleware } from 'core/store'; import addons from 'core/reducers/addons'; -import fakeData from 'disco/fakeData'; +import discoResults from 'disco/reducers/discoResults'; import installations from 'disco/reducers/installations'; export default function createStore(initialState = {}) { - const state = {...initialState, ...fakeData}; return _createStore( - combineReducers({addons, installations, reduxAsyncConnect}), - state, + combineReducers({addons, discoResults, installations, reduxAsyncConnect}), + initialState, middleware(), ); } diff --git a/src/search/actions/index.js b/src/search/actions/index.js index 1a96c5e3d27..5b0c348ec81 100644 --- a/src/search/actions/index.js +++ b/src/search/actions/index.js @@ -18,19 +18,3 @@ export function searchFail({ page, query }) { payload: { page, query }, }; } - -export function loadEntities(entities) { - return { - type: 'ENTITIES_LOADED', - payload: {entities}, - }; -} - -export function setCurrentUser(username) { - return { - type: 'SET_CURRENT_USER', - payload: { - username, - }, - }; -} diff --git a/src/search/containers/AddonPage/index.js b/src/search/containers/AddonPage/index.js index 33deb146f31..02287ddceba 100644 --- a/src/search/containers/AddonPage/index.js +++ b/src/search/containers/AddonPage/index.js @@ -2,7 +2,7 @@ import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; import { asyncConnect } from 'redux-async-connect'; import { fetchAddon } from 'core/api'; -import { loadEntities } from 'search/actions'; +import { loadEntities } from 'core/actions'; import { gettext as _ } from 'core/utils'; import NotFound from 'core/components/NotFound'; diff --git a/src/search/containers/UserPage/index.js b/src/search/containers/UserPage/index.js index d5541933f51..a9a730c01d8 100644 --- a/src/search/containers/UserPage/index.js +++ b/src/search/containers/UserPage/index.js @@ -2,8 +2,8 @@ import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; import { asyncConnect } from 'redux-async-connect'; +import { loadEntities, setCurrentUser } from 'core/actions'; import { fetchProfile } from 'core/api'; -import { loadEntities, setCurrentUser } from 'search/actions'; import './styles.scss'; diff --git a/tests/client/core/actions/test_index.js b/tests/client/core/actions/test_index.js index d5025fb4174..4dcd530f989 100644 --- a/tests/client/core/actions/test_index.js +++ b/tests/client/core/actions/test_index.js @@ -7,3 +7,16 @@ describe('core actions setJWT', () => { {type: 'SET_JWT', payload: {token: 'my.amo.token'}}); }); }); + +describe('ENTITIES_LOADED', () => { + const entities = sinon.stub(); + const action = actions.loadEntities(entities); + + it('sets the type', () => { + assert.equal(action.type, 'ENTITIES_LOADED'); + }); + + it('sets the payload', () => { + assert.deepEqual(action.payload, {entities}); + }); +}); diff --git a/tests/client/core/reducers/test_addons.js b/tests/client/core/reducers/test_addons.js index d46fdb20192..fadcf4a4976 100644 --- a/tests/client/core/reducers/test_addons.js +++ b/tests/client/core/reducers/test_addons.js @@ -23,4 +23,47 @@ describe('addon reducer', () => { }); assert.deepEqual(state, {foo: {slug: 'foo'}, bar: {slug: 'bar'}, baz: {slug: 'baz'}}); }); + + it('pulls down the install URL from the file', () => { + const fileOne = {url: 'https://a.m.o/download.xpi'}; + const fileTwo = {file: 'data'}; + const addon = { + slug: 'installable', + current_version: { + files: [fileOne, fileTwo], + }, + }; + assert.deepEqual( + addons(undefined, {payload: {entities: {addons: {installable: addon}}}}), + { + installable: { + ...addon, + installURL: 'https://a.m.o/download.xpi', + }, + }); + }); + + it('flattens theme data', () => { + const state = addons(originalState, { + payload: { + entities: { + addons: { + baz: {slug: 'baz', id: 42, theme_data: {theme_thing: 'some-data'}}, + }, + }, + }, + }); + assert.deepEqual( + state, + { + foo: {slug: 'foo'}, + bar: {slug: 'bar'}, + baz: { + id: 42, + slug: 'baz', + theme_thing: 'some-data', + guid: '42@personas.mozilla.org', + }, + }); + }); }); diff --git a/tests/client/disco/components/TestAddon.js b/tests/client/disco/components/TestAddon.js index 925f29649f4..083a2b003a6 100644 --- a/tests/client/disco/components/TestAddon.js +++ b/tests/client/disco/components/TestAddon.js @@ -8,7 +8,7 @@ import { findDOMNode } from 'react-dom'; import { Provider } from 'react-redux'; import { createStore } from 'redux'; import Addon from 'disco/components/Addon'; -import { ERROR, THEME_PREVIEW, THEME_RESET_PREVIEW } from 'disco/constants'; +import { ERROR, THEME_PREVIEW, THEME_RESET_PREVIEW, THEME_TYPE } from 'disco/constants'; import { stubAddonManager, getFakeI18nInst } from 'tests/client/helpers'; import I18nProvider from 'core/i18n/Provider'; @@ -17,8 +17,7 @@ const result = { type: 'extension', heading: 'test-heading', slug: 'test-slug', - subHeading: 'test-sub-heading', - editorialDescription: 'test-editorial-description', + description: 'test-editorial-description', }; const store = createStore((s) => s, {installations: {}, addons: {}}); @@ -65,17 +64,26 @@ describe('', () => { }); it('renders the editorial description', () => { - assert.equal(root.refs['editorial-description'].textContent, 'test-editorial-description'); + assert.equal(root.refs.editorialDescription.textContent, 'test-editorial-description'); }); - it('renders the sub-heading', () => { - assert.equal(root.refs['sub-heading'].textContent, 'test-sub-heading'); + it('purifies the heading', () => { + root = renderAddon({ + ...result, + heading: 'Hey! This is an add-on', + }); + assert.include(root.refs.heading.innerHTML, 'Hey! This is an add-on'); }); - it("doesn't render the subheading when not present", () => { - const data = {...result, subHeading: undefined}; - root = renderAddon(data); - assert.notEqual(root.refs.heading.textContent, 'test-sub-heading'); + it('purifies the editorial description', () => { + root = renderAddon({ + ...result, + description: '
This is an add-on!
' + + 'Reviewed by a person', + }); + assert.equal( + root.refs.editorialDescription.innerHTML, + '
This is an add-on!
Reviewed by a person'); }); it('does render a logo for an extension', () => { @@ -100,7 +108,7 @@ describe('', () => { let root; beforeEach(() => { - const data = {...result, type: 'theme'}; + const data = {...result, type: THEME_TYPE}; root = renderAddon(data); }); @@ -121,7 +129,7 @@ describe('', () => { beforeEach(() => { themeAction = sinon.stub(); - const data = {...result, type: 'theme', themeAction}; + const data = {...result, type: THEME_TYPE, themeAction}; root = renderAddon(data); themeImage = findDOMNode(root).querySelector('.theme-image'); }); diff --git a/tests/client/disco/containers/TestDiscoPane.js b/tests/client/disco/containers/TestDiscoPane.js index 407d8017699..2d7824ad373 100644 --- a/tests/client/disco/containers/TestDiscoPane.js +++ b/tests/client/disco/containers/TestDiscoPane.js @@ -2,11 +2,18 @@ import React from 'react'; import { Simulate, renderIntoDocument } from 'react-addons-test-utils'; import { findDOMNode } from 'react-dom'; import { Provider } from 'react-redux'; +import { discoResults } from 'disco/actions'; +import * as discoApi from 'disco/api'; import createStore from 'disco/store'; -import DiscoPane from 'disco/containers/DiscoPane'; +import { EXTENSION_TYPE } from 'disco/constants'; +import * as helpers from 'disco/containers/DiscoPane'; import { stubAddonManager, getFakeI18nInst } from 'tests/client/helpers'; +import { loadEntities } from 'core/actions'; import I18nProvider from 'core/i18n/Provider'; +// Use DiscoPane that isn't wrapped in asyncConnect. +const { DiscoPane } = helpers; + describe('AddonPage', () => { beforeEach(() => { @@ -14,23 +21,25 @@ describe('AddonPage', () => { }); function render() { + const store = createStore({ + addons: {foo: {type: EXTENSION_TYPE}}, + discoResults: [{addon: 'foo'}], + }); + const results = [{addon: 'foo', type: EXTENSION_TYPE}]; + const i18n = getFakeI18nInst(); + // We need the providers for i18n and since InstallButton will pull data from the store. return findDOMNode(renderIntoDocument( - - - + + + )); } describe('rendered fields', () => { - let root; - - beforeEach(() => { - root = render(); - }); - it('renders an addon', () => { + const root = render(); assert.ok(root.querySelector('.addon')); }); }); @@ -49,4 +58,53 @@ describe('AddonPage', () => { assert.notOk(root.querySelector('.show-video')); }); }); + + describe('loadDataIfNeeded', () => { + it('does nothing if there are loaded results', () => { + const store = { + getState() { + return {addons: {foo: {}}, discoResults: [{addon: 'foo'}]}; + }, + }; + const getAddons = sinon.stub(discoApi, 'getDiscoveryAddons'); + return helpers.loadDataIfNeeded({store}) + .then(() => assert.notOk(getAddons.called)); + }); + + it('loads the addons if there are none', () => { + const api = {the: 'config'}; + const dispatch = sinon.spy(); + const store = { + dispatch, + getState() { + return {addons: {}, api, discoResults: []}; + }, + }; + const entities = {addons: {foo: {slug: 'foo'}}, discoResults: {foo: {addon: 'foo'}}}; + const result = {results: ['foo']}; + const getAddons = sinon.stub(discoApi, 'getDiscoveryAddons') + .returns(Promise.resolve({entities, result})); + return helpers.loadDataIfNeeded({store}) + .then(() => { + assert.ok(getAddons.calledWith({api})); + assert.ok(dispatch.calledWith(loadEntities(entities))); + assert.ok(dispatch.calledWith(discoResults([{addon: 'foo'}]))); + }); + }); + }); + + describe('mapStateToProps', () => { + it('only sets results', () => { + const props = helpers.mapStateToProps({discoResults: []}); + assert.deepEqual(Object.keys(props), ['results']); + }); + + it('sets the results', () => { + const props = helpers.mapStateToProps({ + addons: {one: {slug: 'one'}, two: {slug: 'two'}}, + discoResults: [{addon: 'two'}], + }); + assert.deepEqual(props.results, [{slug: 'two', addon: 'two'}]); + }); + }); }); diff --git a/tests/client/disco/containers/TestInstallButton.js b/tests/client/disco/containers/TestInstallButton.js index b085899590b..d882caf3eac 100644 --- a/tests/client/disco/containers/TestInstallButton.js +++ b/tests/client/disco/containers/TestInstallButton.js @@ -216,7 +216,7 @@ describe('', () => { }); it('sets the status to INSTALLED when an installed theme is found', () => { - stubAddonManager({getAddon: Promise.resolve({type: 'theme', isEnabled: true})}); + stubAddonManager({getAddon: Promise.resolve({type: THEME_TYPE, isEnabled: true})}); const dispatch = sinon.spy(); const guid = '@foo'; const slug = 'foo'; @@ -232,7 +232,7 @@ describe('', () => { }); it('sets the status to UNINSTALLED when an uninstalled theme is found', () => { - stubAddonManager({getAddon: Promise.resolve({type: 'theme', isEnabled: false})}); + stubAddonManager({getAddon: Promise.resolve({type: THEME_TYPE, isEnabled: false})}); const dispatch = sinon.spy(); const guid = '@foo'; const slug = 'foo'; diff --git a/tests/client/disco/reducers/testDiscoResults.js b/tests/client/disco/reducers/testDiscoResults.js new file mode 100644 index 00000000000..570c01dd290 --- /dev/null +++ b/tests/client/disco/reducers/testDiscoResults.js @@ -0,0 +1,14 @@ +import discoResults from 'disco/reducers/discoResults'; + +describe('discoResults reducer', () => { + it('defaults to an empty array', () => { + assert.deepEqual(discoResults(undefined, {type: 'UNRELATED'}), []); + }); + + it('setst the state to the results', () => { + const results = ['foo', 'bar']; + assert.strictEqual( + discoResults(['baz'], {type: 'DISCO_RESULTS', payload: {results}}), + results); + }); +}); diff --git a/tests/client/disco/test_api.js b/tests/client/disco/test_api.js new file mode 100644 index 00000000000..e13674dc112 --- /dev/null +++ b/tests/client/disco/test_api.js @@ -0,0 +1,36 @@ +import { arrayOf, normalize } from 'normalizr'; + +import { discoResult, getDiscoveryAddons } from 'disco/api'; +import * as coreApi from 'core/api'; + +describe('disco api', () => { + describe('getDiscoveryAddons', () => { + it('calls the API', () => { + const callApi = sinon.stub(coreApi, 'callApi'); + const api = {some: 'apiconfig'}; + getDiscoveryAddons({api}); + assert.ok(callApi.calledWith({ + endpoint: 'discovery', + schema: {results: arrayOf(discoResult)}, + params: {lang: 'en-US'}, + state: api, + })); + }); + }); + + describe('discoResult', () => { + it("uses the addon's slug as an id", () => { + const normalized = normalize({addon: {slug: 'foo'}}, discoResult); + assert.deepEqual( + normalized, + { + entities: { + addons: {foo: {slug: 'foo'}}, + discoResults: {foo: {addon: 'foo'}}, + }, + result: 'foo', + }, + sinon.format(normalized.entities)); + }); + }); +}); diff --git a/tests/client/init.js b/tests/client/init.js index 833752c1bd2..0297b34afd8 100644 --- a/tests/client/init.js +++ b/tests/client/init.js @@ -1,6 +1,7 @@ const realSinon = sinon; window.sinon = realSinon.sandbox.create(); window.sinon.createStubInstance = realSinon.createStubInstance; +window.sinon.format = realSinon.format; afterEach(() => { window.sinon.restore(); diff --git a/tests/client/search/TestUserPage.js b/tests/client/search/TestUserPage.js index be5a06a6dae..5f0ee0950cf 100644 --- a/tests/client/search/TestUserPage.js +++ b/tests/client/search/TestUserPage.js @@ -2,8 +2,8 @@ import React from 'react'; import { renderIntoDocument } from 'react-addons-test-utils'; import { findDOMNode } from 'react-dom'; +import { loadEntities, setCurrentUser } from 'core/actions'; import * as api from 'core/api'; -import { loadEntities, setCurrentUser } from 'search/actions'; import { UserPage, mapStateToProps, loadProfileIfNeeded } from 'search/containers/UserPage'; describe('', () => { diff --git a/tests/client/search/actions/test_index.js b/tests/client/search/actions/test_index.js index 044b16622a3..21b32c92356 100644 --- a/tests/client/search/actions/test_index.js +++ b/tests/client/search/actions/test_index.js @@ -37,16 +37,3 @@ describe('SEARCH_FAILED', () => { assert.deepEqual(action.payload, {page: 25, query: 'foo'}); }); }); - -describe('ENTITIES_LOADED', () => { - const entities = sinon.stub(); - const action = actions.loadEntities(entities); - - it('sets the type', () => { - assert.equal(action.type, 'ENTITIES_LOADED'); - }); - - it('sets the payload', () => { - assert.deepEqual(action.payload, {entities}); - }); -}); diff --git a/tests/client/search/containers/TestAddonPage.js b/tests/client/search/containers/TestAddonPage.js index 547197f22d1..d036f3880f5 100644 --- a/tests/client/search/containers/TestAddonPage.js +++ b/tests/client/search/containers/TestAddonPage.js @@ -4,8 +4,8 @@ import { findDOMNode } from 'react-dom'; import { Provider } from 'react-redux'; import AddonPage, { findAddon, loadAddonIfNeeded } from 'search/containers/AddonPage'; import createStore from 'search/store'; +import * as actions from 'core/actions'; import * as api from 'core/api'; -import * as actions from 'search/actions'; import { stubAddonManager, unexpectedSuccess } from 'tests/client/helpers'; describe('AddonPage', () => { diff --git a/webpack.dev.config.babel.js b/webpack.dev.config.babel.js index 8f1b5daf195..6cbd9802010 100644 --- a/webpack.dev.config.babel.js +++ b/webpack.dev.config.babel.js @@ -93,6 +93,8 @@ export default Object.assign({}, webpackConfig, { new webpack.NormalModuleReplacementPlugin(/config$/, 'core/client/config.js'), // Substitutes client only config. new webpack.NormalModuleReplacementPlugin(/core\/logger$/, 'core/client/logger.js'), + // Use the browser's window for window. + new webpack.NormalModuleReplacementPlugin(/core\/window/, 'core/browserWindow.js'), new webpack.HotModuleReplacementPlugin(), new webpack.IgnorePlugin(/webpack-stats\.json$/), webpackIsomorphicToolsPlugin.development(), diff --git a/webpack.prod.config.babel.js b/webpack.prod.config.babel.js index 0504f7a29fb..a2a8718a86a 100644 --- a/webpack.prod.config.babel.js +++ b/webpack.prod.config.babel.js @@ -67,6 +67,8 @@ export default { new webpack.NormalModuleReplacementPlugin(/config$/, 'core/client/config.js'), // Substitutes client only config. new webpack.NormalModuleReplacementPlugin(/core\/logger$/, 'core/client/logger.js'), + // Use the browser's window for window. + new webpack.NormalModuleReplacementPlugin(/core\/window/, 'core/browserWindow.js'), // This allow us to exclude locales for other apps being built. new webpack.ContextReplacementPlugin( /locale$/,