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 }
+ );
+ });
+});