diff --git a/config/default-disco.js b/config/default-disco.js index 6209cdc70ba..e88add4b145 100644 --- a/config/default-disco.js +++ b/config/default-disco.js @@ -7,6 +7,29 @@ const staticHost = 'https://addons-discovery.cdn.mozilla.net'; module.exports = { + // The keys listed here will be exposed on the client. + // Since by definition client-side code is public these config keys + // must not contain sensitive data. + clientConfigKeys: [ + 'appName', + 'amoCDN', + 'apiHost', + 'apiPath', + 'cookieName', + 'cookieMaxAge', + 'cookieSecure', + 'enableClientConsole', + 'defaultLang', + 'isDeployed', + 'isDevelopment', + 'langs', + 'langMap', + 'rtlLangs', + 'trackingEnabled', + 'trackingId', + 'useUiTour', + ], + staticHost, CSP: { @@ -31,4 +54,8 @@ module.exports = { trackingId: 'UA-36116321-7', enablePostCssLoader: false, + + // If this is false we'll use an in-page + // stand-in for the ui-tour. + useUiTour: false, }; diff --git a/src/disco/components/Addon.js b/src/disco/components/Addon.js index f0fd5fd2211..17462b74cb3 100644 --- a/src/disco/components/Addon.js +++ b/src/disco/components/Addon.js @@ -25,10 +25,12 @@ import { FATAL_ERROR, FATAL_INSTALL_ERROR, FATAL_UNINSTALL_ERROR, + CLOSE_INFO, INSTALL_CATEGORY, INSTALL_ERROR, INSTALL_FAILED, INSTALL_STATE, + SHOW_INFO, START_DOWNLOAD, THEME_INSTALL, THEME_PREVIEW, @@ -273,10 +275,17 @@ export function makeProgressHandler(dispatch, guid) { export function mapDispatchToProps(dispatch, { _tracking = tracking, _addonManager = addonManager, + _config = config, + _dispatchEvent, ...ownProps } = {}) { if (config.get('server')) { return {}; } + + // Set the default here otherwise server code will blow up. + // eslint-disable-next-line no-param-reassign + _dispatchEvent = _dispatchEvent || document.dispatchEvent; + return { setCurrentStatus({ guid, installURL }) { const payload = { guid, url: installURL }; @@ -317,21 +326,34 @@ export function mapDispatchToProps(dispatch, { _tracking = tracking, category: INSTALL_CATEGORY, label: name, }); - document.dispatchEvent(new CustomEvent('mozUITour', { - bubbles: true, - detail: { - action: 'showInfo', - data: { - target: 'appMenu', - icon: iconUrl, - title: i18n.gettext('Installed and added to toolbar'), - text: i18n.sprintf( - i18n.gettext('Click here to access %(name)s any time.'), - { name }), - buttons: [{ label: i18n.gettext('Ok'), callbackID: 'add-on-installed' }], + if (_config.has('useUiTour') && _config.get('useUiTour')) { + _dispatchEvent(new CustomEvent('mozUITour', { + bubbles: true, + detail: { + action: 'showInfo', + data: { + target: 'appMenu', + icon: iconUrl, + title: i18n.gettext('Your add-on is ready'), + text: i18n.sprintf( + i18n.gettext('Now you can access %(name)s from the toolbar.'), + { name }), + buttons: [{ label: i18n.gettext('Ok'), callbackID: 'add-on-installed' }], + }, + }, + })); + } else { + dispatch({ + type: SHOW_INFO, + payload: { + addonName: name, + imageURL: iconUrl, + closeAction: () => { + dispatch({ type: CLOSE_INFO }); + }, }, - }, - })); + }); + } }) .catch((err) => { log.error(err); diff --git a/src/disco/components/InfoDialog.js b/src/disco/components/InfoDialog.js new file mode 100644 index 00000000000..747b0f1b0f9 --- /dev/null +++ b/src/disco/components/InfoDialog.js @@ -0,0 +1,37 @@ +import React, { PropTypes } from 'react'; +import translate from 'core/i18n/translate'; + +import 'disco/css/InfoDialog.scss'; + +export class InfoDialog extends React.Component { + static propTypes = { + addonName: PropTypes.string.isRequired, + closeAction: PropTypes.func.isRequired, + imageURL: PropTypes.string.isRequired, + i18n: PropTypes.object.isRequired, + } + + render() { + const { addonName, closeAction, i18n, imageURL } = this.props; + return ( +
+
+
+ +
+
+

{i18n.gettext('Your add-on is ready')}

+

{i18n.sprintf( + i18n.gettext('Now you can access %(name)s from the toolbar.'), + { name: addonName })}

+
+
+ +
+ ); + } +} + +export default translate()(InfoDialog); diff --git a/src/disco/constants.js b/src/disco/constants.js index f5b10748407..30d994d1489 100644 --- a/src/disco/constants.js +++ b/src/disco/constants.js @@ -108,3 +108,6 @@ export const globalEventStatusMap = { // they will be fired by addons and themes that aren't // necessarily in the disco pane. export const globalEvents = Object.keys(globalEventStatusMap); + +export const SHOW_INFO = 'SHOW_INFO'; +export const CLOSE_INFO = 'CLOSE_INFO'; diff --git a/src/disco/containers/DiscoPane.js b/src/disco/containers/DiscoPane.js index 5de64aa2b72..3bcbf746852 100644 --- a/src/disco/containers/DiscoPane.js +++ b/src/disco/containers/DiscoPane.js @@ -17,6 +17,7 @@ import { } from 'disco/constants'; import Addon from 'disco/components/Addon'; +import InfoDialog from 'disco/components/InfoDialog'; import translate from 'core/i18n/translate'; import tracking from 'core/tracking'; @@ -27,12 +28,14 @@ import videoWebm from 'disco/video/AddOns.webm'; export class DiscoPane extends React.Component { static propTypes = { + AddonComponent: PropTypes.func.isRequred, handleGlobalEvent: PropTypes.func.isRequired, i18n: PropTypes.object.isRequired, + infoDialogData: PropTypes.object, + mozAddonManager: PropTypes.object, results: PropTypes.arrayOf(PropTypes.object), - AddonComponent: PropTypes.func.isRequred, + showInfoDialog: PropTypes.boolean, _addChangeListeners: PropTypes.func, - mozAddonManager: PropTypes.object, _tracking: PropTypes.object, } @@ -65,15 +68,6 @@ export class DiscoPane extends React.Component { }); } - showMoreAddons = () => { - const { _tracking } = this.props; - _tracking.sendEvent({ - action: 'click', - category: NAVIGATION_CATEGORY, - label: 'See More Add-ons', - }); - } - closeVideo = (e) => { const { _tracking } = this.props; e.preventDefault(); @@ -85,8 +79,17 @@ export class DiscoPane extends React.Component { }); } + showMoreAddons = () => { + const { _tracking } = this.props; + _tracking.sendEvent({ + action: 'click', + category: NAVIGATION_CATEGORY, + label: 'See More Add-ons', + }); + } + render() { - const { results, i18n, AddonComponent } = this.props; + const { results, i18n, AddonComponent, showInfoDialog, infoDialogData } = this.props; const { showVideo } = this.state; return ( @@ -122,6 +125,7 @@ export class DiscoPane extends React.Component { {i18n.gettext('See more add-ons!')} + {showInfoDialog === true ? : null} ); } @@ -147,6 +151,8 @@ export function loadDataIfNeeded({ store: { dispatch, getState } }) { export function mapStateToProps(state) { return { results: loadedAddons(state), + showInfoDialog: state.infoDialog.show, + infoDialogData: state.infoDialog.data, }; } diff --git a/src/disco/css/InfoDialog.scss b/src/disco/css/InfoDialog.scss new file mode 100644 index 00000000000..7782317e36d --- /dev/null +++ b/src/disco/css/InfoDialog.scss @@ -0,0 +1,66 @@ +.show-info { + background: #fff; + border-radius: 5px; + overflow: hidden; + border: 1px solid #ccc; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + max-width: 400px; + position: fixed; + right: 10px; + top: 10px; + z-index: 20; + + .info { + display: flex; + flex-direction: row; + } + + .logo { + align-self: stretch; + display: flex; + padding: 20px; + + img { + height: 48px; + width: 48px; + } + } + + .copy { + align-self: stretch; + padding: 20px 20px 20px 0; + + :last-child { + margin: 0; + } + + [dir=rtl] & { + padding: 20px 0 20px 20px; + } + } + + h3 { + margin: 0 0 0.5em; + } + + p { + margin-top: 0; + } + + button { + align-self: stretch; + flex-direction: column; + background: #2ea3ff; + padding: 0.8em; + border: none; + color: #fff; + transition: background 200ms; + + &:focus, + &:hover { + background: #0996f8; + } + } +} diff --git a/src/disco/reducers/infoDialog.js b/src/disco/reducers/infoDialog.js new file mode 100644 index 00000000000..3dbc27f929e --- /dev/null +++ b/src/disco/reducers/infoDialog.js @@ -0,0 +1,17 @@ +import { CLOSE_INFO, SHOW_INFO } from 'disco/constants'; + +export default function infoDialog(state = {}, { type, payload }) { + switch (type) { + case SHOW_INFO: + return { + show: true, + data: payload, + }; + case CLOSE_INFO: + return { + show: false, + }; + default: + return state; + } +} diff --git a/src/disco/store.js b/src/disco/store.js index f61154d1083..5805650a6c1 100644 --- a/src/disco/store.js +++ b/src/disco/store.js @@ -6,10 +6,11 @@ import addons from 'core/reducers/addons'; import api from 'core/reducers/api'; import discoResults from 'disco/reducers/discoResults'; import installations from 'disco/reducers/installations'; +import infoDialog from 'disco/reducers/infoDialog'; export default function createStore(initialState = {}) { return _createStore( - combineReducers({ addons, api, discoResults, installations, reduxAsyncConnect }), + combineReducers({ addons, api, discoResults, installations, infoDialog, reduxAsyncConnect }), initialState, middleware(), ); diff --git a/tests/client/disco/components/TestAddon.js b/tests/client/disco/components/TestAddon.js index d302682322f..4cc5dc498b9 100644 --- a/tests/client/disco/components/TestAddon.js +++ b/tests/client/disco/components/TestAddon.js @@ -13,6 +13,7 @@ import { mapStateToProps, } from 'disco/components/Addon'; import { + CLOSE_INFO, DISABLED, DOWNLOAD_FAILED, DOWNLOAD_PROGRESS, @@ -26,6 +27,7 @@ import { INSTALL_CATEGORY, INSTALL_FAILED, INSTALL_STATE, + SHOW_INFO, START_DOWNLOAD, THEME_INSTALL, THEME_PREVIEW, @@ -577,6 +579,75 @@ describe('', () => { }))); }); + it('should dispatch SHOW_INFO', () => { + const fakeAddonManager = getFakeAddonManagerWrapper(); + const i18n = getFakeI18nInst(); + const dispatch = sinon.spy(); + const iconUrl = 'whatevs'; + const name = 'test-addon'; + + const fakeConfig = { + has: sinon.stub().withArgs('useUiTour').returns(false), + }; + const { install } = mapDispatchToProps( + dispatch, + { + _addonManager: fakeAddonManager, + _config: fakeConfig, + i18n, + iconUrl, + name, + }); + return install({ guid, installURL }) + .then(() => { + assert(dispatch.calledWith({ + type: SHOW_INFO, + payload: { + addonName: 'test-addon', + imageURL: iconUrl, + closeAction: sinon.match.func, + }, + })); + + // Grab the first arg of second call. + const arg = dispatch.getCall(1).args[0]; + // Prove we're looking at the SHOW_INFO dispatch. + assert.equal(arg.type, SHOW_INFO); + + // Test that close action dispatches. + arg.payload.closeAction(); + assert(dispatch.calledWith({ + type: CLOSE_INFO, + })); + }); + }); + + it('should use uiTour', () => { + const fakeAddonManager = getFakeAddonManagerWrapper(); + const i18n = getFakeI18nInst(); + const dispatch = sinon.spy(); + const iconUrl = 'whatevs'; + const name = 'test-addon'; + + const fakeConfig = { + has: sinon.stub().withArgs('useUiTour').returns(true), + get: sinon.stub().withArgs('useUiTour').returns(true), + }; + const fakeDispatchEvent = sinon.stub(); + const { install } = mapDispatchToProps( + dispatch, + { + _addonManager: fakeAddonManager, + _dispatchEvent: fakeDispatchEvent, + _config: fakeConfig, + i18n, + iconUrl, + name, + }); + return install({ guid, installURL }) + .then(() => assert.ok(fakeDispatchEvent.called)); + }); + it('dispatches error when addonManager.install throws', () => { const fakeAddonManager = getFakeAddonManagerWrapper(); fakeAddonManager.install = sinon.stub().returns(Promise.reject()); diff --git a/tests/client/disco/components/TestInfoDialog.js b/tests/client/disco/components/TestInfoDialog.js new file mode 100644 index 00000000000..8f632ec00b8 --- /dev/null +++ b/tests/client/disco/components/TestInfoDialog.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { Simulate, renderIntoDocument } from 'react-addons-test-utils'; +import { findDOMNode } from 'react-dom'; +import { sprintf } from 'jed'; + +import { + InfoDialog, +} from 'disco/components/InfoDialog'; + +import { getFakeI18nInst } from 'tests/client/helpers'; + + +describe('', () => { + let closeAction; + + function renderInfoDialog(props = {}) { + closeAction = sinon.stub(); + const renderProps = { + addonName: 'A Test Add-on', + imageURL: 'https://addons-dev-cdn.allizom.org/whatever', + closeAction, + i18n: getFakeI18nInst(), + ...props, + }; + + renderProps.i18n.sprintf = sprintf; + + return renderIntoDocument( + ); + } + + it('Should render a dialog with aria role', () => { + const dialog = renderInfoDialog(); + const root = findDOMNode(dialog); + assert.equal(root.getAttribute('role'), 'dialog'); + }); + + it('Should render a title', () => { + const dialog = renderInfoDialog(); + const root = findDOMNode(dialog); + assert.equal(root.querySelector('#show-info-title').textContent, 'Your add-on is ready'); + }); + + it('Should render a description containing the add-on name', () => { + const dialog = renderInfoDialog(); + const root = findDOMNode(dialog); + assert.include(root.querySelector('#show-info-description').textContent, 'A Test Add-on'); + }); + + it('should have an img element with a src', () => { + const dialog = renderInfoDialog(); + const root = findDOMNode(dialog); + assert.ok(root.querySelector('img').src, 'https://addons-dev-cdn.allizom.org/whatever'); + }); + + it('should call closeAction func when clicking close', () => { + const dialog = renderInfoDialog(); + const root = findDOMNode(dialog); + Simulate.click(root.querySelector('button')); + assert.ok(closeAction.called, 'closeAction stub was called'); + }); +}); diff --git a/tests/client/disco/containers/TestDiscoPane.js b/tests/client/disco/containers/TestDiscoPane.js index 6061d1b6238..25c85c8121a 100644 --- a/tests/client/disco/containers/TestDiscoPane.js +++ b/tests/client/disco/containers/TestDiscoPane.js @@ -128,14 +128,22 @@ describe('AddonPage', () => { describe('mapStateToProps', () => { it('only sets results', () => { - const props = helpers.mapStateToProps({ discoResults: [] }); - assert.deepEqual(Object.keys(props), ['results']); + const props = helpers.mapStateToProps({ + discoResults: [], + infoDialog: { + show: false, + data: {}, + }, + }); + assert.sameMembers(Object.keys(props), + ['results', 'infoDialogData', 'showInfoDialog']); }); it('sets the results', () => { const props = helpers.mapStateToProps({ addons: { one: { slug: 'one' }, two: { slug: 'two' } }, discoResults: [{ addon: 'two' }], + infoDialog: {}, }); assert.deepEqual(props.results, [{ slug: 'two', addon: 'two' }]); }); diff --git a/tests/client/disco/reducers/testDiscoResults.js b/tests/client/disco/reducers/testDiscoResults.js index 4e7d6181220..dde5f0fda30 100644 --- a/tests/client/disco/reducers/testDiscoResults.js +++ b/tests/client/disco/reducers/testDiscoResults.js @@ -5,7 +5,7 @@ describe('discoResults reducer', () => { assert.deepEqual(discoResults(undefined, { type: 'UNRELATED' }), []); }); - it('setst the state to the results', () => { + it('sets the state to the results', () => { const results = ['foo', 'bar']; assert.strictEqual( discoResults(['baz'], { type: 'DISCO_RESULTS', payload: { results } }), diff --git a/tests/client/disco/reducers/testInfoDialog.js b/tests/client/disco/reducers/testInfoDialog.js new file mode 100644 index 00000000000..3fb2ac61597 --- /dev/null +++ b/tests/client/disco/reducers/testInfoDialog.js @@ -0,0 +1,31 @@ +import infoDialog from 'disco/reducers/infoDialog'; +import { SHOW_INFO, CLOSE_INFO } from 'disco/constants'; + +describe('infoDialog reducer', () => { + it('defaults to an empty object', () => { + assert.deepEqual(infoDialog(undefined, { type: 'UNRELATED' }), {}); + }); + + it('shows a dialog with SHOW_INFO', () => { + const payload = { foo: 'bar' }; + assert.deepEqual( + infoDialog({}, { type: SHOW_INFO, payload }), + { show: true, data: payload } + ); + }); + + it('maintains state with unrelated state changes', () => { + const payload = { foo: 'bar' }; + assert.deepEqual( + infoDialog({ show: true, data: payload }, { type: 'WHATEVS' }), + { show: true, data: payload } + ); + }); + + it('hides a dialog with CLOSE_INFO ', () => { + assert.deepEqual( + infoDialog({}, { type: CLOSE_INFO }), + { show: false } + ); + }); +});