diff --git a/src/disco/components/Addon.js b/src/disco/components/Addon.js index 8bd873fea2d..d57fc671452 100644 --- a/src/disco/components/Addon.js +++ b/src/disco/components/Addon.js @@ -1,20 +1,29 @@ import classNames from 'classnames'; import { sprintf } from 'jed'; import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; import translate from 'core/i18n/translate'; import purify from 'core/purify'; +import config from 'config'; import themeAction, { getThemeData } from 'disco/themePreview'; +import tracking from 'core/tracking'; +import { AddonManager } from 'disco/addonManager'; -import InstallButton from 'disco/containers/InstallButton'; +import InstallButton from 'disco/components/InstallButton'; import { validAddonTypes, validInstallStates, ERROR, EXTENSION_TYPE, + INSTALL_CATEGORY, + INSTALLED, + THEME_INSTALL, THEME_TYPE, THEME_PREVIEW, THEME_RESET_PREVIEW, + UNINSTALL_CATEGORY, + UNINSTALLED, } from 'disco/constants'; import 'disco/css/Addon.scss'; @@ -40,8 +49,10 @@ export class Addon extends React.Component { i18n: PropTypes.string.isRequired, iconUrl: PropTypes.string, id: PropTypes.string.isRequired, + installURL: PropTypes.string, previewURL: PropTypes.string, name: PropTypes.string.isRequired, + setInitialStatus: PropTypes.func.isRequired, status: PropTypes.oneOf(validInstallStates).isRequired, textcolor: PropTypes.string, themeAction: PropTypes.func, @@ -53,6 +64,11 @@ export class Addon extends React.Component { themeAction, } + componentDidMount() { + const { guid, installURL, setInitialStatus } = this.props; + setInitialStatus({guid, installURL}); + } + getBrowserThemeData() { return JSON.stringify(getThemeData(this.props)); } @@ -117,7 +133,7 @@ export class Addon extends React.Component { } render() { - const { guid, heading, type } = this.props; + const { heading, type } = this.props; if (!validAddonTypes.includes(type)) { throw new Error(`Invalid addon type "${type}"`); @@ -141,7 +157,7 @@ export class Addon extends React.Component { {this.getDescription()}
- +
@@ -149,4 +165,75 @@ export class Addon extends React.Component { } } -export default translate({withRef: true})(Addon); +export function mapStateToProps(state, ownProps) { + const installation = state.installations[ownProps.guid] || {}; + const addon = state.addons[ownProps.guid] || {}; + return {...installation, ...addon}; +} + +export function makeProgressHandler(dispatch, guid) { + return (addonInstall) => { + if (addonInstall.state === 'STATE_DOWNLOADING') { + const downloadProgress = parseInt( + 100 * addonInstall.progress / addonInstall.maxProgress, 10); + dispatch({type: 'DOWNLOAD_PROGRESS', payload: {guid, downloadProgress}}); + } else if (addonInstall.state === 'STATE_INSTALLING') { + dispatch({type: 'START_INSTALL', payload: {guid}}); + } else if (addonInstall.state === 'STATE_INSTALLED') { + dispatch({type: 'INSTALL_COMPLETE', payload: {guid}}); + } + }; +} + +export function mapDispatchToProps(dispatch, { _tracking = tracking } = {}) { + if (config.get('server')) { + return {}; + } + return { + setInitialStatus({ guid, installURL }) { + const addonManager = new AddonManager(guid, installURL); + const payload = {guid, url: installURL}; + return addonManager.getAddon() + .then( + (addon) => { + const status = addon.type === THEME_TYPE && !addon.isEnabled ? UNINSTALLED : INSTALLED; + dispatch({type: 'INSTALL_STATE', payload: {...payload, status}}); + }, + () => dispatch({type: 'INSTALL_STATE', payload: {...payload, status: UNINSTALLED}})); + }, + + install({ guid, installURL, name }) { + const addonManager = new AddonManager(guid, installURL, makeProgressHandler(dispatch, guid)); + dispatch({type: 'START_DOWNLOAD', payload: {guid}}); + _tracking.sendEvent({action: 'addon', category: INSTALL_CATEGORY, label: name}); + return addonManager.install(); + }, + + installTheme(node, guid, name, _themeAction = themeAction) { + _themeAction(node, THEME_INSTALL); + _tracking.sendEvent({action: 'theme', category: INSTALL_CATEGORY, label: name}); + return new Promise((resolve) => { + setTimeout(() => { + dispatch({type: 'INSTALL_STATE', payload: {guid, status: INSTALLED}}); + resolve(); + }, 250); + }); + }, + + uninstall({ guid, installURL, name, type }) { + const addonManager = new AddonManager(guid, installURL); + dispatch({type: 'START_UNINSTALL', payload: {guid}}); + const action = { + [EXTENSION_TYPE]: 'addon', + [THEME_TYPE]: 'theme', + }[type] || 'invalid'; + _tracking.sendEvent({action, category: UNINSTALL_CATEGORY, label: name}); + return addonManager.uninstall() + .then(() => dispatch({type: 'UNINSTALL_COMPLETE', payload: {guid}})); + }, + }; +} + +export default connect( + mapStateToProps, mapDispatchToProps, undefined, {withRef: true} +)(translate({withRef: true})(Addon)); diff --git a/src/disco/components/InstallButton.js b/src/disco/components/InstallButton.js new file mode 100644 index 00000000000..9c8a67dcc01 --- /dev/null +++ b/src/disco/components/InstallButton.js @@ -0,0 +1,85 @@ +import React, { PropTypes } from 'react'; +import translate from 'core/i18n/translate'; + +import { + DOWNLOADING, + INSTALLED, + THEME_TYPE, + UNINSTALLED, + UNKNOWN, + validAddonTypes, + validInstallStates as validStates, +} from 'disco/constants'; +import { getThemeData } from 'disco/themePreview'; + +import 'disco/css/InstallButton.scss'; + +export class InstallButton extends React.Component { + static propTypes = { + handleChange: PropTypes.func, + guid: PropTypes.string.isRequired, + install: PropTypes.func.isRequired, + installTheme: PropTypes.func.isRequired, + i18n: PropTypes.object.isRequired, + installURL: PropTypes.string, + name: PropTypes.string.isRequired, + uninstall: PropTypes.func.isRequired, + url: PropTypes.string, + downloadProgress: PropTypes.number, + slug: PropTypes.string.isRequired, + status: PropTypes.oneOf(validStates), + type: PropTypes.oneOf(validAddonTypes), + } + + static defaultProps = { + status: UNKNOWN, + downloadProgress: 0, + } + + handleClick = (e) => { + e.preventDefault(); + const { guid, install, installURL, name, status, installTheme, type, uninstall } = this.props; + if (type === THEME_TYPE && status === UNINSTALLED) { + installTheme(this.refs.themeData, guid, name); + } else if (status === UNINSTALLED) { + install({ guid, installURL, name }); + } else if (status === INSTALLED) { + uninstall({ guid, installURL, name, type }); + } + } + + render() { + const { downloadProgress, i18n, slug, status } = this.props; + + if (!validStates.includes(status)) { + throw new Error('Invalid add-on status'); + } + + const isInstalled = status === INSTALLED; + const isDisabled = status === UNKNOWN; + const isDownloading = status === DOWNLOADING; + const switchClasses = `switch ${status}`; + const identifier = `install-button-${slug}`; + + return ( +
+ + +
+ ); + } +} + +export default translate()(InstallButton); diff --git a/src/disco/containers/DiscoPane.js b/src/disco/containers/DiscoPane.js index f8f77e71f27..1cb8af263d3 100644 --- a/src/disco/containers/DiscoPane.js +++ b/src/disco/containers/DiscoPane.js @@ -70,7 +70,7 @@ export class DiscoPane extends React.Component { - {results.map((item, i) => )} + {results.map((item) => )}
{i18n.gettext('See more add-ons!')} diff --git a/src/disco/containers/InstallButton.js b/src/disco/containers/InstallButton.js deleted file mode 100644 index 09cc58094c6..00000000000 --- a/src/disco/containers/InstallButton.js +++ /dev/null @@ -1,168 +0,0 @@ -import React, { PropTypes } from 'react'; -import { connect } from 'react-redux'; -import translate from 'core/i18n/translate'; -import tracking from 'core/tracking'; - -import config from 'config'; -import { AddonManager } from 'disco/addonManager'; -import { - DOWNLOADING, - EXTENSION_TYPE, - INSTALLED, - INSTALL_CATEGORY, - THEME_INSTALL, - THEME_TYPE, - UNINSTALL_CATEGORY, - UNINSTALLED, - UNKNOWN, - validAddonTypes, - validInstallStates as validStates, -} from 'disco/constants'; -import themeAction, { getThemeData } from 'disco/themePreview'; - -import 'disco/css/InstallButton.scss'; - -export class InstallButton extends React.Component { - static propTypes = { - handleChange: PropTypes.func, - guid: PropTypes.string.isRequired, - install: PropTypes.func.isRequired, - installTheme: PropTypes.func.isRequired, - i18n: PropTypes.object.isRequired, - installURL: PropTypes.string, - name: PropTypes.string.isRequired, - uninstall: PropTypes.func.isRequired, - url: PropTypes.string, - downloadProgress: PropTypes.number, - setInitialStatus: PropTypes.func.isRequired, - slug: PropTypes.string.isRequired, - status: PropTypes.oneOf(validStates), - type: PropTypes.oneOf(validAddonTypes), - } - - static defaultProps = { - status: UNKNOWN, - downloadProgress: 0, - } - - componentDidMount() { - const { guid, installURL, setInitialStatus } = this.props; - setInitialStatus({guid, installURL}); - } - - handleClick = (e) => { - e.preventDefault(); - const { guid, install, installURL, name, status, installTheme, type, uninstall } = this.props; - if (type === THEME_TYPE && status === UNINSTALLED) { - installTheme(this.refs.themeData, guid, name); - } else if (status === UNINSTALLED) { - install({ guid, installURL, name }); - } else if (status === INSTALLED) { - uninstall({ guid, installURL, name, type }); - } - } - - render() { - const { downloadProgress, i18n, slug, status } = this.props; - - if (!validStates.includes(status)) { - throw new Error('Invalid add-on status'); - } - - const isInstalled = status === INSTALLED; - const isDisabled = status === UNKNOWN; - const isDownloading = status === DOWNLOADING; - const switchClasses = `switch ${status}`; - const identifier = `install-button-${slug}`; - - return ( -
- - -
- ); - } -} - -export function mapStateToProps(state, ownProps) { - const installation = state.installations[ownProps.guid] || {}; - const addon = state.addons[ownProps.guid] || {}; - return {...installation, ...addon}; -} - -export function makeProgressHandler(dispatch, guid) { - return (addonInstall) => { - if (addonInstall.state === 'STATE_DOWNLOADING') { - const downloadProgress = parseInt( - 100 * addonInstall.progress / addonInstall.maxProgress, 10); - dispatch({type: 'DOWNLOAD_PROGRESS', payload: {guid, downloadProgress}}); - } else if (addonInstall.state === 'STATE_INSTALLING') { - dispatch({type: 'START_INSTALL', payload: {guid}}); - } else if (addonInstall.state === 'STATE_INSTALLED') { - dispatch({type: 'INSTALL_COMPLETE', payload: {guid}}); - } - }; -} - -export function mapDispatchToProps(dispatch, { _tracking = tracking } = {}) { - if (config.get('server')) { - return {}; - } - return { - setInitialStatus({ guid, installURL }) { - const addonManager = new AddonManager(guid, installURL); - const payload = {guid, url: installURL}; - return addonManager.getAddon() - .then( - (addon) => { - const status = addon.type === THEME_TYPE && !addon.isEnabled ? UNINSTALLED : INSTALLED; - dispatch({type: 'INSTALL_STATE', payload: {...payload, status}}); - }, - () => dispatch({type: 'INSTALL_STATE', payload: {...payload, status: UNINSTALLED}})); - }, - - install({ guid, installURL, name }) { - const addonManager = new AddonManager(guid, installURL, makeProgressHandler(dispatch, guid)); - dispatch({type: 'START_DOWNLOAD', payload: {guid}}); - _tracking.sendEvent({action: 'addon', category: INSTALL_CATEGORY, label: name}); - return addonManager.install(); - }, - - installTheme(node, guid, name, _themeAction = themeAction) { - _themeAction(node, THEME_INSTALL); - _tracking.sendEvent({action: 'theme', category: INSTALL_CATEGORY, label: name}); - return new Promise((resolve) => { - setTimeout(() => { - dispatch({type: 'INSTALL_STATE', payload: {guid, status: INSTALLED}}); - resolve(); - }, 250); - }); - }, - - uninstall({ guid, installURL, name, type }) { - const addonManager = new AddonManager(guid, installURL); - dispatch({type: 'START_UNINSTALL', payload: {guid}}); - const action = { - [EXTENSION_TYPE]: 'addon', - [THEME_TYPE]: 'theme', - }[type] || 'invalid'; - _tracking.sendEvent({action, category: UNINSTALL_CATEGORY, label: name}); - return addonManager.uninstall() - .then(() => dispatch({type: 'UNINSTALL_COMPLETE', payload: {guid}})); - }, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(translate()(InstallButton)); diff --git a/tests/client/disco/components/TestAddon.js b/tests/client/disco/components/TestAddon.js index 083a2b003a6..0c887cec2a8 100644 --- a/tests/client/disco/components/TestAddon.js +++ b/tests/client/disco/components/TestAddon.js @@ -7,8 +7,24 @@ import { 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, THEME_TYPE } from 'disco/constants'; +import config from 'config'; +import Addon, { + makeProgressHandler, + mapDispatchToProps, + mapStateToProps, +} from 'disco/components/Addon'; +import * as addonManager from 'disco/addonManager'; +import { + ERROR, + INSTALL_CATEGORY, + INSTALLED, + THEME_INSTALL, + THEME_PREVIEW, + THEME_RESET_PREVIEW, + THEME_TYPE, + UNINSTALL_CATEGORY, + UNINSTALLED, +} from 'disco/constants'; import { stubAddonManager, getFakeI18nInst } from 'tests/client/helpers'; import I18nProvider from 'core/i18n/Provider'; @@ -29,18 +45,15 @@ function renderAddon(data) { - ), Addon).getWrappedInstance(); + ), Addon).getWrappedInstance().getWrappedInstance(); } describe('', () => { - beforeEach(() => { - stubAddonManager(); - }); - describe('', () => { let root; beforeEach(() => { + stubAddonManager(); root = renderAddon(result); }); @@ -108,6 +121,7 @@ describe('', () => { let root; beforeEach(() => { + stubAddonManager(); const data = {...result, type: THEME_TYPE}; root = renderAddon(data); }); @@ -128,6 +142,7 @@ describe('', () => { let themeAction; beforeEach(() => { + stubAddonManager(); themeAction = sinon.stub(); const data = {...result, type: THEME_TYPE, themeAction}; root = renderAddon(data); @@ -160,4 +175,296 @@ describe('', () => { assert.ok(preventDefault.called); }); }); + + describe('mapStateToProps', () => { + it('pulls the installation data from the state', () => { + stubAddonManager(); + const addon = { + guid: 'foo@addon', + downloadProgress: 75, + }; + assert.deepEqual( + mapStateToProps({ + installations: {foo: {some: 'data'}, 'foo@addon': addon}, + addons: {'foo@addon': {addonProp: 'addonValue'}}, + }, {guid: 'foo@addon'}), + {guid: 'foo@addon', downloadProgress: 75, addonProp: 'addonValue'}); + }); + }); + + describe('makeProgressHandler', () => { + it('sets the download progress on STATE_DOWNLOADING', () => { + const dispatch = sinon.spy(); + const guid = 'foo@addon'; + const handler = makeProgressHandler(dispatch, guid); + handler({state: 'STATE_DOWNLOADING', progress: 300, maxProgress: 990}); + assert(dispatch.calledWith({ + type: 'DOWNLOAD_PROGRESS', + payload: {downloadProgress: 30, guid}, + })); + }); + + it('sets status to installing on STATE_INSTALLING', () => { + const dispatch = sinon.spy(); + const guid = 'foo@my-addon'; + const handler = makeProgressHandler(dispatch, guid); + handler({state: 'STATE_INSTALLING'}); + assert(dispatch.calledWith({ + type: 'START_INSTALL', + payload: {guid}, + })); + }); + + it('sets status to installed on STATE_INSTALLED', () => { + const dispatch = sinon.spy(); + const guid = '{my-addon}'; + const handler = makeProgressHandler(dispatch, guid); + handler({state: 'STATE_INSTALLED'}); + assert(dispatch.calledWith({ + type: 'INSTALL_COMPLETE', + payload: {guid}, + })); + }); + }); + + describe('setInitialStatus', () => { + it('sets the status to INSTALLED when add-on found', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const guid = '@foo'; + const installURL = 'http://the.url'; + const { setInitialStatus } = mapDispatchToProps(dispatch); + return setInitialStatus({guid, installURL}) + .then(() => { + assert(dispatch.calledWith({ + type: 'INSTALL_STATE', + payload: {guid, status: INSTALLED, url: installURL}, + })); + }); + }); + + it('sets the status to INSTALLED when an installed theme is found', () => { + stubAddonManager({getAddon: Promise.resolve({type: THEME_TYPE, isEnabled: true})}); + const dispatch = sinon.spy(); + const guid = '@foo'; + const installURL = 'http://the.url'; + const { setInitialStatus } = mapDispatchToProps(dispatch); + return setInitialStatus({guid, installURL}) + .then(() => { + assert(dispatch.calledWith({ + type: 'INSTALL_STATE', + payload: {guid, status: INSTALLED, url: installURL}, + })); + }); + }); + + it('sets the status to UNINSTALLED when an uninstalled theme is found', () => { + stubAddonManager({getAddon: Promise.resolve({type: THEME_TYPE, isEnabled: false})}); + const dispatch = sinon.spy(); + const guid = '@foo'; + const installURL = 'http://the.url'; + const { setInitialStatus } = mapDispatchToProps(dispatch); + return setInitialStatus({guid, installURL}) + .then(() => { + assert(dispatch.calledWith({ + type: 'INSTALL_STATE', + payload: {guid, status: UNINSTALLED, url: installURL}, + })); + }); + }); + + it('sets the status to UNINSTALLED when not found', () => { + stubAddonManager({getAddon: Promise.reject()}); + const dispatch = sinon.spy(); + const guid = '@foo'; + const installURL = 'http://the.url'; + const { setInitialStatus } = mapDispatchToProps(dispatch); + return setInitialStatus({guid, installURL}) + .then(() => { + assert(dispatch.calledWith({ + type: 'INSTALL_STATE', + payload: {guid, status: UNINSTALLED, url: installURL}, + })); + }); + }); + }); + + describe('install', () => { + const guid = '@install'; + const installURL = 'https://mysite.com/download.xpi'; + + it('installs the addon on a new AddonManager', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const { install } = mapDispatchToProps(dispatch); + return install({guid, installURL}) + .then(() => { + assert(addonManager.AddonManager.calledWithNew, 'new AddonManager() called'); + assert(addonManager.AddonManager.calledWith(guid, installURL, sinon.match.func)); + }); + }); + + it('tracks an addon inntall', () => { + stubAddonManager(); + const name = 'hai-addon'; + const type = 'extension'; + const dispatch = sinon.spy(); + const fakeTracking = { + sendEvent: sinon.spy(), + }; + const { install } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); + return install({guid, installURL, name, type}) + .then(() => { + assert(fakeTracking.sendEvent.calledWith({ + action: 'addon', + category: INSTALL_CATEGORY, + label: 'hai-addon', + })); + }); + }); + + + it('should dispatch START_DOWNLOAD', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const { install } = mapDispatchToProps(dispatch); + return install({guid, installURL}) + .then(() => assert(dispatch.calledWith({ + type: 'START_DOWNLOAD', + payload: {guid}, + }))); + }); + }); + + describe('uninstall', () => { + const guid = '@uninstall'; + const installURL = 'https://mysite.com/download.xpi'; + + it('prepares the addon on a new AddonManager', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const { uninstall } = mapDispatchToProps(dispatch); + return uninstall({guid, installURL}) + .then(() => { + assert(addonManager.AddonManager.calledWithNew, 'new AddonManager() called'); + assert(addonManager.AddonManager.calledWith(guid, installURL)); + }); + }); + + it('tracks an addon uninstall', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const name = 'whatevs'; + const type = 'extension'; + const fakeTracking = { + sendEvent: sinon.spy(), + }; + const { uninstall } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); + return uninstall({guid, installURL, name, type}) + .then(() => { + assert.ok(fakeTracking.sendEvent.calledWith({ + action: 'addon', + category: UNINSTALL_CATEGORY, + label: 'whatevs', + }), 'correctly called'); + }); + }); + + it('tracks a theme uninstall', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const name = 'whatevs'; + const type = 'persona'; + const fakeTracking = { + sendEvent: sinon.spy(), + }; + const { uninstall } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); + return uninstall({guid, installURL, name, type}) + .then(() => { + assert(fakeTracking.sendEvent.calledWith({ + action: 'theme', + category: UNINSTALL_CATEGORY, + label: 'whatevs', + })); + }); + }); + + it('tracks a unknown type uninstall', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const name = 'whatevs'; + const type = 'foo'; + const fakeTracking = { + sendEvent: sinon.spy(), + }; + const { uninstall } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); + return uninstall({guid, installURL, name, type}) + .then(() => { + assert(fakeTracking.sendEvent.calledWith({ + action: 'invalid', + category: UNINSTALL_CATEGORY, + label: 'whatevs', + })); + }); + }); + + it('should dispatch START_UNINSTALL', () => { + stubAddonManager(); + const dispatch = sinon.spy(); + const { uninstall } = mapDispatchToProps(dispatch); + return uninstall({guid, installURL}) + .then(() => assert(dispatch.calledWith({ + type: 'START_UNINSTALL', + payload: {guid}, + }))); + }); + }); + + describe('installTheme', () => { + it('installs the theme', () => { + const name = 'hai-theme'; + const guid = '{install-theme}'; + const node = sinon.stub(); + const spyThemeAction = sinon.spy(); + const dispatch = sinon.spy(); + const { installTheme } = mapDispatchToProps(dispatch); + return installTheme(node, guid, name, spyThemeAction) + .then(() => { + assert(spyThemeAction.calledWith(node, THEME_INSTALL)); + assert(dispatch.calledWith({ + type: 'INSTALL_STATE', + payload: {guid, status: INSTALLED}, + })); + }); + }); + + it('tracks a theme install', () => { + const name = 'hai-theme'; + const guid = '{install-theme}'; + const node = sinon.stub(); + const dispatch = sinon.spy(); + const spyThemeAction = sinon.spy(); + const fakeTracking = { + sendEvent: sinon.spy(), + }; + const { installTheme } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); + return installTheme(node, guid, name, spyThemeAction) + .then(() => { + assert(fakeTracking.sendEvent.calledWith({ + action: 'theme', + category: INSTALL_CATEGORY, + label: 'hai-theme', + })); + }); + }); + }); + + describe('mapDispatchToProps', () => { + it('is empty when there is no navigator', () => { + const configStub = sinon.stub(config, 'get').returns(true); + assert.deepEqual(mapDispatchToProps(sinon.spy()), {}); + assert(configStub.calledOnce); + assert(configStub.calledWith('server')); + }); + }); }); diff --git a/tests/client/disco/components/TestInstallButton.js b/tests/client/disco/components/TestInstallButton.js new file mode 100644 index 00000000000..22063fbc47b --- /dev/null +++ b/tests/client/disco/components/TestInstallButton.js @@ -0,0 +1,136 @@ +import React from 'react'; +import { Simulate, renderIntoDocument } from 'react-addons-test-utils'; +import { findDOMNode } from 'react-dom'; + +import { + InstallButton, +} from 'disco/components/InstallButton'; +import { + DOWNLOADING, + INSTALLED, + INSTALLING, + THEME_TYPE, + UNINSTALLED, + UNINSTALLING, + UNKNOWN, +} from 'disco/constants'; +import { getFakeI18nInst } from 'tests/client/helpers'; + + +describe('', () => { + function renderButton(props = {}) { + const renderProps = { + dispatch: sinon.spy(), + install: sinon.spy(), + installTheme: sinon.spy(), + uninstall: sinon.spy(), + i18n: getFakeI18nInst(), + ...props, + }; + + return renderIntoDocument( + ); + } + + it('should be disabled if isDisabled status is UNKNOWN', () => { + const button = renderButton({status: UNKNOWN}); + const root = findDOMNode(button); + const checkbox = root.querySelector('input[type=checkbox]'); + assert.equal(checkbox.hasAttribute('disabled'), true); + assert.ok(root.classList.contains('unknown')); + }); + + it('should reflect UNINSTALLED status', () => { + const button = renderButton({status: UNINSTALLED}); + const root = findDOMNode(button); + const checkbox = root.querySelector('input[type=checkbox]'); + assert.equal(checkbox.hasAttribute('disabled'), false); + assert.ok(root.classList.contains('uninstalled')); + }); + + it('should reflect INSTALLED status', () => { + const button = renderButton({status: INSTALLED}); + const root = findDOMNode(button); + const checkbox = root.querySelector('input[type=checkbox]'); + assert.equal(checkbox.checked, true, 'checked is true'); + assert.ok(root.classList.contains('installed')); + }); + + it('should reflect download downloadProgress', () => { + const button = renderButton({status: DOWNLOADING, downloadProgress: 50}); + const root = findDOMNode(button); + assert.ok(root.classList.contains('downloading')); + assert.equal(root.getAttribute('data-download-progress'), 50); + }); + + it('should reflect installation', () => { + const button = renderButton({status: INSTALLING}); + const root = findDOMNode(button); + assert.ok(root.classList.contains('installing')); + }); + + it('should reflect uninstallation', () => { + const button = renderButton({status: UNINSTALLING}); + const root = findDOMNode(button); + assert.ok(root.classList.contains('uninstalling')); + }); + + it('should not call anything on click when neither installed or uninstalled', () => { + const install = sinon.stub(); + const uninstall = sinon.stub(); + const button = renderButton({status: DOWNLOADING, install, uninstall}); + const root = findDOMNode(button); + Simulate.click(root); + assert.ok(!install.called); + assert.ok(!uninstall.called); + }); + + it('should associate the label and input with id and for attributes', () => { + const button = renderButton({status: UNINSTALLED, slug: 'foo'}); + const root = findDOMNode(button); + assert.equal(root.querySelector('input').getAttribute('id'), + 'install-button-foo', 'id is set'); + assert.equal(root.querySelector('label').getAttribute('for'), + 'install-button-foo', 'for attribute matches id'); + }); + + it('should throw on bogus status', () => { + assert.throws(() => { + renderButton({status: 'BOGUS'}); + }, Error, 'Invalid add-on status'); + }); + + it('should call installTheme function on click when uninstalled theme', () => { + const installTheme = sinon.spy(); + const guid = 'test-guid'; + const name = 'hai'; + const button = renderButton({installTheme, type: THEME_TYPE, guid, name, status: UNINSTALLED}); + const themeData = button.refs.themeData; + const root = findDOMNode(button); + Simulate.click(root); + assert(installTheme.calledWith(themeData, guid, name)); + }); + + it('should call install function on click when uninstalled', () => { + const guid = '@foo'; + const name = 'hai'; + const install = sinon.spy(); + const installURL = 'https://my.url/download'; + const button = renderButton({guid, install, installURL, name, status: UNINSTALLED}); + const root = findDOMNode(button); + Simulate.click(root); + assert(install.calledWith({guid, installURL, name})); + }); + + it('should call uninstall function on click when installed', () => { + const guid = '@foo'; + const installURL = 'https://my.url/download'; + const name = 'hai'; + const type = 'whatevs'; + const uninstall = sinon.spy(); + const button = renderButton({guid, installURL, name, status: INSTALLED, type, uninstall}); + const root = findDOMNode(button); + Simulate.click(root); + assert(uninstall.calledWith({guid, installURL, name, type})); + }); +}); diff --git a/tests/client/disco/containers/TestInstallButton.js b/tests/client/disco/containers/TestInstallButton.js deleted file mode 100644 index d1aee96a86e..00000000000 --- a/tests/client/disco/containers/TestInstallButton.js +++ /dev/null @@ -1,443 +0,0 @@ -import React from 'react'; -import { Simulate, renderIntoDocument } from 'react-addons-test-utils'; -import { findDOMNode } from 'react-dom'; - -import * as addonManager from 'disco/addonManager'; -import { - InstallButton, - makeProgressHandler, - mapDispatchToProps, - mapStateToProps, -} from 'disco/containers/InstallButton'; -import { - DOWNLOADING, - INSTALL_CATEGORY, - INSTALLED, - INSTALLING, - THEME_INSTALL, - THEME_TYPE, - UNINSTALLED, - UNINSTALL_CATEGORY, - UNINSTALLING, - UNKNOWN, -} from 'disco/constants'; -import { stubAddonManager, getFakeI18nInst } from 'tests/client/helpers'; -import config from 'config'; - - -describe('', () => { - function renderButton(props = {}) { - const renderProps = { - dispatch: sinon.spy(), - setInitialStatus: sinon.spy(), - install: sinon.spy(), - installTheme: sinon.spy(), - uninstall: sinon.spy(), - i18n: getFakeI18nInst(), - ...props, - }; - - return renderIntoDocument( - ); - } - - it('should be disabled if isDisabled status is UNKNOWN', () => { - const button = renderButton({status: UNKNOWN}); - const root = findDOMNode(button); - const checkbox = root.querySelector('input[type=checkbox]'); - assert.equal(checkbox.hasAttribute('disabled'), true); - assert.ok(root.classList.contains('unknown')); - }); - - it('should reflect UNINSTALLED status', () => { - const button = renderButton({status: UNINSTALLED}); - const root = findDOMNode(button); - const checkbox = root.querySelector('input[type=checkbox]'); - assert.equal(checkbox.hasAttribute('disabled'), false); - assert.ok(root.classList.contains('uninstalled')); - }); - - it('should reflect INSTALLED status', () => { - const button = renderButton({status: INSTALLED}); - const root = findDOMNode(button); - const checkbox = root.querySelector('input[type=checkbox]'); - assert.equal(checkbox.checked, true, 'checked is true'); - assert.ok(root.classList.contains('installed')); - }); - - it('should reflect download downloadProgress', () => { - const button = renderButton({status: DOWNLOADING, downloadProgress: 50}); - const root = findDOMNode(button); - assert.ok(root.classList.contains('downloading')); - assert.equal(root.getAttribute('data-download-progress'), 50); - }); - - it('should reflect installation', () => { - const button = renderButton({status: INSTALLING}); - const root = findDOMNode(button); - assert.ok(root.classList.contains('installing')); - }); - - it('should reflect uninstallation', () => { - const button = renderButton({status: UNINSTALLING}); - const root = findDOMNode(button); - assert.ok(root.classList.contains('uninstalling')); - }); - - it('should not call anything on click when neither installed or uninstalled', () => { - const install = sinon.stub(); - const uninstall = sinon.stub(); - const button = renderButton({status: DOWNLOADING, install, uninstall}); - const root = findDOMNode(button); - Simulate.click(root); - assert.ok(!install.called); - assert.ok(!uninstall.called); - }); - - it('should associate the label and input with id and for attributes', () => { - const button = renderButton({status: UNINSTALLED, slug: 'foo'}); - const root = findDOMNode(button); - assert.equal(root.querySelector('input').getAttribute('id'), - 'install-button-foo', 'id is set'); - assert.equal(root.querySelector('label').getAttribute('for'), - 'install-button-foo', 'for attribute matches id'); - }); - - it('should throw on bogus status', () => { - assert.throws(() => { - renderButton({status: 'BOGUS'}); - }, Error, 'Invalid add-on status'); - }); - - it('should call installTheme function on click when uninstalled theme', () => { - const installTheme = sinon.spy(); - const guid = 'test-guid'; - const name = 'hai'; - const button = renderButton({installTheme, type: THEME_TYPE, guid, name, status: UNINSTALLED}); - const themeData = button.refs.themeData; - const root = findDOMNode(button); - Simulate.click(root); - assert(installTheme.calledWith(themeData, guid, name)); - }); - - it('should call install function on click when uninstalled', () => { - const guid = '@foo'; - const name = 'hai'; - const install = sinon.spy(); - const installURL = 'https://my.url/download'; - const button = renderButton({guid, install, installURL, name, status: UNINSTALLED}); - const root = findDOMNode(button); - Simulate.click(root); - assert(install.calledWith({guid, installURL, name})); - }); - - it('should call uninstall function on click when installed', () => { - const guid = '@foo'; - const installURL = 'https://my.url/download'; - const name = 'hai'; - const type = 'whatevs'; - const uninstall = sinon.spy(); - const button = renderButton({guid, installURL, name, status: INSTALLED, type, uninstall}); - const root = findDOMNode(button); - Simulate.click(root); - assert(uninstall.calledWith({guid, installURL, name, type})); - }); - - it('should call setInitialStatus in componentDidMount', () => { - const guid = '@foo'; - const installURL = 'http://the.url'; - const setInitialStatus = sinon.spy(); - renderButton({guid, installURL, setInitialStatus, status: UNKNOWN}); - assert(setInitialStatus.calledWith({guid, installURL})); - }); - - describe('mapStateToProps', () => { - it('pulls the installation data from the state', () => { - const addon = { - guid: 'foo@addon', - downloadProgress: 75, - }; - assert.deepEqual( - mapStateToProps({ - installations: {foo: {some: 'data'}, 'foo@addon': addon}, - addons: {'foo@addon': {addonProp: 'addonValue'}}, - }, {guid: 'foo@addon'}), - {guid: 'foo@addon', downloadProgress: 75, addonProp: 'addonValue'}); - }); - }); - - describe('makeProgressHandler', () => { - it('sets the download progress on STATE_DOWNLOADING', () => { - const dispatch = sinon.spy(); - const guid = 'foo@addon'; - const handler = makeProgressHandler(dispatch, guid); - handler({state: 'STATE_DOWNLOADING', progress: 300, maxProgress: 990}); - assert(dispatch.calledWith({ - type: 'DOWNLOAD_PROGRESS', - payload: {downloadProgress: 30, guid}, - })); - }); - - it('sets status to installing on STATE_INSTALLING', () => { - const dispatch = sinon.spy(); - const guid = 'foo@my-addon'; - const handler = makeProgressHandler(dispatch, guid); - handler({state: 'STATE_INSTALLING'}); - assert(dispatch.calledWith({ - type: 'START_INSTALL', - payload: {guid}, - })); - }); - - it('sets status to installed on STATE_INSTALLED', () => { - const dispatch = sinon.spy(); - const guid = '{my-addon}'; - const handler = makeProgressHandler(dispatch, guid); - handler({state: 'STATE_INSTALLED'}); - assert(dispatch.calledWith({ - type: 'INSTALL_COMPLETE', - payload: {guid}, - })); - }); - }); - - describe('setInitialStatus', () => { - it('sets the status to INSTALLED when add-on found', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const guid = '@foo'; - const installURL = 'http://the.url'; - const { setInitialStatus } = mapDispatchToProps(dispatch); - return setInitialStatus({guid, installURL}) - .then(() => { - assert(dispatch.calledWith({ - type: 'INSTALL_STATE', - payload: {guid, status: INSTALLED, url: installURL}, - })); - }); - }); - - it('sets the status to INSTALLED when an installed theme is found', () => { - stubAddonManager({getAddon: Promise.resolve({type: THEME_TYPE, isEnabled: true})}); - const dispatch = sinon.spy(); - const guid = '@foo'; - const installURL = 'http://the.url'; - const { setInitialStatus } = mapDispatchToProps(dispatch); - return setInitialStatus({guid, installURL}) - .then(() => { - assert(dispatch.calledWith({ - type: 'INSTALL_STATE', - payload: {guid, status: INSTALLED, url: installURL}, - })); - }); - }); - - it('sets the status to UNINSTALLED when an uninstalled theme is found', () => { - stubAddonManager({getAddon: Promise.resolve({type: THEME_TYPE, isEnabled: false})}); - const dispatch = sinon.spy(); - const guid = '@foo'; - const installURL = 'http://the.url'; - const { setInitialStatus } = mapDispatchToProps(dispatch); - return setInitialStatus({guid, installURL}) - .then(() => { - assert(dispatch.calledWith({ - type: 'INSTALL_STATE', - payload: {guid, status: UNINSTALLED, url: installURL}, - })); - }); - }); - - it('sets the status to UNINSTALLED when not found', () => { - stubAddonManager({getAddon: Promise.reject()}); - const dispatch = sinon.spy(); - const guid = '@foo'; - const installURL = 'http://the.url'; - const { setInitialStatus } = mapDispatchToProps(dispatch); - return setInitialStatus({guid, installURL}) - .then(() => { - assert(dispatch.calledWith({ - type: 'INSTALL_STATE', - payload: {guid, status: UNINSTALLED, url: installURL}, - })); - }); - }); - }); - - describe('install', () => { - const guid = '@install'; - const installURL = 'https://mysite.com/download.xpi'; - - it('installs the addon on a new AddonManager', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const { install } = mapDispatchToProps(dispatch); - return install({guid, installURL}) - .then(() => { - assert(addonManager.AddonManager.calledWithNew, 'new AddonManager() called'); - assert(addonManager.AddonManager.calledWith(guid, installURL, sinon.match.func)); - }); - }); - - it('tracks an addon inntall', () => { - stubAddonManager(); - const name = 'hai-addon'; - const type = 'extension'; - const dispatch = sinon.spy(); - const fakeTracking = { - sendEvent: sinon.spy(), - }; - const { install } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); - return install({guid, installURL, name, type}) - .then(() => { - assert(fakeTracking.sendEvent.calledWith({ - action: 'addon', - category: INSTALL_CATEGORY, - label: 'hai-addon', - })); - }); - }); - - it('should dispatch START_DOWNLOAD', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const { install } = mapDispatchToProps(dispatch); - return install({guid, installURL}) - .then(() => assert(dispatch.calledWith({ - type: 'START_DOWNLOAD', - payload: {guid}, - }))); - }); - }); - - describe('uninstall', () => { - const guid = '@uninstall'; - const installURL = 'https://mysite.com/download.xpi'; - - it('prepares the addon on a new AddonManager', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const { uninstall } = mapDispatchToProps(dispatch); - return uninstall({guid, installURL}) - .then(() => { - assert(addonManager.AddonManager.calledWithNew, 'new AddonManager() called'); - assert(addonManager.AddonManager.calledWith(guid, installURL)); - }); - }); - - it('tracks an addon uninstall', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const name = 'whatevs'; - const type = 'extension'; - const fakeTracking = { - sendEvent: sinon.spy(), - }; - const { uninstall } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); - return uninstall({guid, installURL, name, type}) - .then(() => { - assert.ok(fakeTracking.sendEvent.calledWith({ - action: 'addon', - category: UNINSTALL_CATEGORY, - label: 'whatevs', - }), 'correctly called'); - }); - }); - - it('tracks a theme uninstall', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const name = 'whatevs'; - const type = 'persona'; - const fakeTracking = { - sendEvent: sinon.spy(), - }; - const { uninstall } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); - return uninstall({guid, installURL, name, type}) - .then(() => { - assert(fakeTracking.sendEvent.calledWith({ - action: 'theme', - category: UNINSTALL_CATEGORY, - label: 'whatevs', - })); - }); - }); - - it('tracks a unknown type uninstall', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const name = 'whatevs'; - const type = 'foo'; - const fakeTracking = { - sendEvent: sinon.spy(), - }; - const { uninstall } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); - return uninstall({guid, installURL, name, type}) - .then(() => { - assert(fakeTracking.sendEvent.calledWith({ - action: 'invalid', - category: UNINSTALL_CATEGORY, - label: 'whatevs', - })); - }); - }); - - it('should dispatch START_UNINSTALL', () => { - stubAddonManager(); - const dispatch = sinon.spy(); - const { uninstall } = mapDispatchToProps(dispatch); - return uninstall({guid, installURL}) - .then(() => assert(dispatch.calledWith({ - type: 'START_UNINSTALL', - payload: {guid}, - }))); - }); - }); - - describe('installTheme', () => { - it('installs the theme', () => { - const name = 'hai-theme'; - const guid = '{install-theme}'; - const node = sinon.stub(); - const spyThemeAction = sinon.spy(); - const dispatch = sinon.spy(); - const { installTheme } = mapDispatchToProps(dispatch); - return installTheme(node, guid, name, spyThemeAction) - .then(() => { - assert(spyThemeAction.calledWith(node, THEME_INSTALL)); - assert(dispatch.calledWith({ - type: 'INSTALL_STATE', - payload: {guid, status: INSTALLED}, - })); - }); - }); - - it('tracks a theme install', () => { - const name = 'hai-theme'; - const guid = '{install-theme}'; - const node = sinon.stub(); - const dispatch = sinon.spy(); - const spyThemeAction = sinon.spy(); - const fakeTracking = { - sendEvent: sinon.spy(), - }; - const { installTheme } = mapDispatchToProps(dispatch, {_tracking: fakeTracking}); - return installTheme(node, guid, name, spyThemeAction) - .then(() => { - assert(fakeTracking.sendEvent.calledWith({ - action: 'theme', - category: INSTALL_CATEGORY, - label: 'hai-theme', - })); - }); - }); - }); - - describe('mapDispatchToProps', () => { - it('is empty when there is no navigator', () => { - const configStub = sinon.stub(config, 'get').returns(true); - assert.deepEqual(mapDispatchToProps(sinon.spy()), {}); - assert(configStub.calledOnce); - assert(configStub.calledWith('server')); - }); - }); -});