diff --git a/src/disco/components/Addon.js b/src/disco/components/Addon.js
index fc9890ef4ca..eb2636c4367 100644
--- a/src/disco/components/Addon.js
+++ b/src/disco/components/Addon.js
@@ -8,12 +8,9 @@ import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { connect } from 'react-redux';
import { compose } from 'redux';
-import { sanitizeHTML } from 'core/utils';
-import translate from 'core/i18n/translate';
-import themeAction from 'core/themePreview';
-import tracking, { getAction } from 'core/tracking';
-import InstallButton from 'core/components/InstallButton';
+import AddonCompatibilityError from 'disco/components/AddonCompatibilityError';
import HoverIntent from 'core/components/HoverIntent';
+import InstallButton from 'core/components/InstallButton';
import {
CLICK_CATEGORY,
DOWNLOAD_FAILED,
@@ -28,17 +25,27 @@ import {
validAddonTypes,
validInstallStates,
} from 'core/constants';
+import translate from 'core/i18n/translate';
import { withInstallHelpers } from 'core/installAddon';
+import themeAction from 'core/themePreview';
+import tracking, { getAction } from 'core/tracking';
+import {
+ getClientCompatibility as _getClientCompatibility,
+ sanitizeHTML,
+} from 'core/utils';
import 'disco/css/Addon.scss';
+
export class AddonBase extends React.Component {
static propTypes = {
addon: PropTypes.object.isRequired,
+ clientApp: PropTypes.string.isRequired,
description: PropTypes.string,
error: PropTypes.string,
heading: PropTypes.string.isRequired,
getBrowserThemeData: PropTypes.func.isRequired,
+ getClientCompatibility: PropTypes.func,
i18n: PropTypes.object.isRequired,
iconUrl: PropTypes.string,
installTheme: PropTypes.func.isRequired,
@@ -50,13 +57,15 @@ export class AddonBase extends React.Component {
setCurrentStatus: PropTypes.func.isRequired,
status: PropTypes.oneOf(validInstallStates).isRequired,
type: PropTypes.oneOf(validAddonTypes).isRequired,
+ userAgentInfo: PropTypes.object.isRequired,
_tracking: PropTypes.object,
}
static defaultProps = {
+ getClientCompatibility: _getClientCompatibility,
+ needsRestart: false,
// Defaults themeAction to the imported func.
themeAction,
- needsRestart: false,
_tracking: tracking,
}
@@ -117,14 +126,17 @@ export class AddonBase extends React.Component {
const { i18n, description, type } = this.props;
if (type === ADDON_TYPE_THEME) {
return (
-
{i18n.gettext('Hover over the image to preview')}
+
+ {i18n.gettext('Hover over the image to preview')}
+
);
}
return (
{ this.editorialDescription = ref; }}
className="editorial-description"
- dangerouslySetInnerHTML={sanitizeHTML(description, ['blockquote', 'cite'])}
+ dangerouslySetInnerHTML={
+ sanitizeHTML(description, ['blockquote', 'cite'])
+ }
/>
);
}
@@ -188,7 +200,14 @@ export class AddonBase extends React.Component {
}
render() {
- const { heading, type } = this.props;
+ const {
+ addon,
+ clientApp,
+ getClientCompatibility,
+ heading,
+ type,
+ userAgentInfo,
+ } = this.props;
if (!validAddonTypes.includes(type)) {
throw new Error(`Invalid addon type "${type}"`);
@@ -199,6 +218,9 @@ export class AddonBase extends React.Component {
extension: type === ADDON_TYPE_EXTENSION,
});
+ const { compatible, minVersion, reason } = getClientCompatibility({
+ addon, clientApp, userAgentInfo });
+
return (
// Disabling this is fine since the onClick is just being used to delegate
// click events bubbling from the link within the header.
@@ -218,14 +240,23 @@ export class AddonBase extends React.Component {
{ this.heading = ref; }}
className="heading"
dangerouslySetInnerHTML={sanitizeHTML(heading, ['a', 'span'])}
/>
{this.getDescription()}
-
+
+ {!compatible ? (
+
+ ) : null}
);
}
@@ -238,6 +269,8 @@ export function mapStateToProps(state, ownProps) {
addon,
...addon,
...installation,
+ clientApp: state.api.clientApp,
+ userAgentInfo: state.api.userAgentInfo,
};
}
diff --git a/src/disco/components/AddonCompatibilityError/index.js b/src/disco/components/AddonCompatibilityError/index.js
new file mode 100644
index 00000000000..6293b5e7cbc
--- /dev/null
+++ b/src/disco/components/AddonCompatibilityError/index.js
@@ -0,0 +1,68 @@
+/* eslint-disable react/no-danger */
+import React, { PropTypes } from 'react';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
+
+import {
+ INCOMPATIBLE_FIREFOX_FOR_IOS,
+ INCOMPATIBLE_UNDER_MIN_VERSION,
+} from 'core/constants';
+import translate from 'core/i18n/translate';
+import { sanitizeHTML } from 'core/utils';
+
+import './style.scss';
+
+
+// Messages in the disco pane are a bit less specific as we don't care about
+// non-Firefox clients and the copy space is limited.
+export class AddonCompatibilityErrorBase extends React.Component {
+ static propTypes = {
+ i18n: PropTypes.object.isRequired,
+ minVersion: PropTypes.string.isRequired,
+ reason: PropTypes.string.isRequired,
+ }
+
+ render() {
+ const {
+ i18n,
+ minVersion,
+ reason,
+ } = this.props;
+ let message;
+
+ if (typeof reason === 'undefined') {
+ throw new Error('AddonCompatibilityError requires a "reason" prop');
+ }
+ if (typeof minVersion === 'undefined') {
+ throw new Error('minVersion is required; it cannot be undefined');
+ }
+
+ if (reason === INCOMPATIBLE_FIREFOX_FOR_IOS) {
+ message = i18n.gettext(
+ 'Firefox for iOS does not currently support add-ons.');
+ } else if (reason === INCOMPATIBLE_UNDER_MIN_VERSION) {
+ message = i18n.gettext(
+ 'This add-on does not support your version of Firefox.');
+ } else {
+ // Unknown reasons are fine on the Disco Pane because we don't
+ // care about non-FF clients.
+ message = i18n.gettext('This add-on does not support your browser.');
+ }
+
+ return (
+
+ );
+ }
+}
+
+export function mapStateToProps(state) {
+ return { lang: state.api.lang };
+}
+
+export default compose(
+ connect(mapStateToProps),
+ translate({ withRef: true }),
+)(AddonCompatibilityErrorBase);
diff --git a/src/disco/components/AddonCompatibilityError/style.scss b/src/disco/components/AddonCompatibilityError/style.scss
new file mode 100644
index 00000000000..52f0cd7c8da
--- /dev/null
+++ b/src/disco/components/AddonCompatibilityError/style.scss
@@ -0,0 +1,16 @@
+@import "~ui/css/vars";
+
+.AddonCompatibilityError {
+ background: $report-base-color;
+ font-size: 12px;
+ margin: 0;
+ padding: 5px;
+ text-align: center;
+ width: 100%;
+
+ &,
+ a,
+ a:link {
+ color: $white;
+ }
+}
diff --git a/src/disco/css/Addon.scss b/src/disco/css/Addon.scss
index a192a71ecfc..5c90f6b484d 100644
--- a/src/disco/css/Addon.scss
+++ b/src/disco/css/Addon.scss
@@ -9,7 +9,7 @@ $addon-padding: 20px;
border-radius: 3px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
display: flex;
- flex-direction: row;
+ flex-flow: row wrap;
line-height: 1.5;
margin-top: 20px;
@@ -53,6 +53,11 @@ $addon-padding: 20px;
}
}
+ .InstallButton,
+ .InstallButton--disabled {
+ margin: 0 20px 10px;
+ }
+
&:not(.theme) .Addon-install-button {
> .switch {
float: right;
@@ -68,6 +73,7 @@ $addon-padding: 20px;
align-self: stretch;
display: flex;
padding: 0 10px;
+ width: 94px;
img {
display: block;
@@ -151,6 +157,7 @@ $addon-padding: 20px;
&:not(.theme) .content {
flex-direction: column;
align-items: left;
+ width: calc(100% - 94px);
}
@include respond-to(large) {
diff --git a/src/disco/css/App.scss b/src/disco/css/App.scss
index 4166936b2a8..a1a725f7f09 100644
--- a/src/disco/css/App.scss
+++ b/src/disco/css/App.scss
@@ -248,7 +248,3 @@ header {
}
}
}
-
-.InstallButton {
- @include padding-end(20px);
-}
diff --git a/src/ui/css/vars.scss b/src/ui/css/vars.scss
index cff26f1c6c7..eb8e378eac8 100644
--- a/src/ui/css/vars.scss
+++ b/src/ui/css/vars.scss
@@ -103,3 +103,9 @@ $font-size-m: 18px;
$font-size-l: 24px;
$font-size-xl: 36px;
$font-size-xxl: 42px;
+
+// Border radius sizes
+// Border-Radius Variables
+$border-radius-default: 9px;
+$border-radius-s: 4px;
+$border-radius-m: 8px;
diff --git a/tests/unit/disco/components/TestAddon.js b/tests/unit/disco/components/TestAddon.js
index 358b42a8bba..39bdff2fd70 100644
--- a/tests/unit/disco/components/TestAddon.js
+++ b/tests/unit/disco/components/TestAddon.js
@@ -1,18 +1,15 @@
+import { shallow } from 'enzyme';
import React from 'react';
import {
findRenderedComponentWithType,
renderIntoDocument,
Simulate,
} from 'react-addons-test-utils';
-import {
- findDOMNode,
-} from 'react-dom';
+import { findDOMNode } from 'react-dom';
+import { Provider } from 'react-redux';
-import translate from 'core/i18n/translate';
-import {
- AddonBase,
- mapStateToProps,
-} from 'disco/components/Addon';
+import I18nProvider from 'core/i18n/Provider';
+import Addon, { AddonBase, mapStateToProps } from 'disco/components/Addon';
import HoverIntent from 'core/components/HoverIntent';
import {
ADDON_TYPE_EXTENSION,
@@ -40,20 +37,22 @@ const result = {
};
function renderAddon({ setCurrentStatus = sinon.stub(), ...props }) {
- const MyAddon = translate({ withRef: true })(AddonBase);
const getBrowserThemeData = () => '{"theme":"data"}';
const { store } = createStore({ api: signedInApiState });
return findRenderedComponentWithType(renderIntoDocument(
-
- ), MyAddon).getWrappedInstance();
+
+
+ ({ compatible: true, reason: null })}
+ hasAddonManager
+ setCurrentStatus={setCurrentStatus}
+ {...props}
+ />
+
+
+ ), Addon);
}
describe('', () => {
@@ -143,22 +142,31 @@ describe('', () => {
const data = { ...result, needsRestart: true, status: UNINSTALLING };
const root = renderAddon({ addon: data, ...data });
const restart = findDOMNode(root).querySelector('.notification.restart');
- expect(restart.querySelector('p').textContent).toEqual('This add-on will be uninstalled after you restart Firefox.');
+
+ expect(restart.querySelector('p').textContent).toEqual(
+ 'This add-on will be uninstalled after you restart Firefox.');
});
it('does not normally render a restart notification', () => {
const root = renderAddon({ addon: result, ...result });
- expect(findDOMNode(root).querySelector('.notification.restart')).toBeFalsy();
+
+ expect(findDOMNode(root).querySelector('.notification.restart'))
+ .toBeFalsy();
});
it('renders the heading', () => {
const root = renderAddon({ addon: result, ...result });
- expect(root.heading.textContent).toContain('test-heading');
+
+ expect(findDOMNode(root).querySelector('.heading').textContent)
+ .toContain('test-heading');
});
it('renders the editorial description', () => {
const root = renderAddon({ addon: result, ...result });
- expect(root.editorialDescription.textContent).toEqual('test-editorial-description');
+
+ expect(
+ findDOMNode(root).querySelector('.editorial-description').textContent
+ ).toContain('test-editorial-description');
});
it('purifies the heading', () => {
@@ -167,7 +175,9 @@ describe('', () => {
heading: 'Hey! This is an add-on',
};
const root = renderAddon({ addon: data, ...data });
- expect(root.heading.innerHTML).toContain('Hey! This is an add-on');
+
+ expect(findDOMNode(root).querySelector('.heading').innerHTML)
+ .toContain('Hey! This is an add-on');
});
it('purifies the heading with a link and adds link attrs', () => {
@@ -176,7 +186,8 @@ describe('', () => {
heading: 'This is an add-on/span>',
};
const root = renderAddon({ addon: data, ...data });
- const link = root.heading.querySelector('a');
+ const link = findDOMNode(root).querySelector('.heading a');
+
expect(link.getAttribute('rel')).toEqual('noopener noreferrer');
expect(link.getAttribute('target')).toEqual('_blank');
});
@@ -187,7 +198,8 @@ describe('', () => {
heading: 'This is an add-on/span>',
};
const root = renderAddon({ addon: data, ...data });
- const link = root.heading.querySelector('a');
+ const link = findDOMNode(root).querySelector('.heading a');
+
expect(link.getAttribute('href')).toEqual(null);
});
@@ -198,24 +210,31 @@ describe('', () => {
'Reviewed by a person',
};
const root = renderAddon({ addon: data, ...data });
- expect(root.editorialDescription.innerHTML).toEqual(
+
+ expect(
+ findDOMNode(root).querySelector('.editorial-description').innerHTML
+ ).toEqual(
'This is an add-on!
Reviewed by a person'
);
});
it('does render a logo for an extension', () => {
const root = renderAddon({ addon: result, ...result });
+
expect(findDOMNode(root).querySelector('.logo')).toBeTruthy();
});
it("doesn't render a theme image for an extension", () => {
const root = renderAddon({ addon: result, ...result });
+
expect(findDOMNode(root).querySelector('.theme-image')).toEqual(null);
});
it('throws on invalid add-on type', () => {
const root = renderAddon({ addon: result, ...result });
- expect(root.heading.textContent).toContain('test-heading');
+ expect(findDOMNode(root).querySelector('.heading').textContent)
+ .toContain('test-heading');
+
const data = { ...result, type: 'Whatever' };
expect(() => {
renderAddon({ addon: data, ...data });
@@ -238,12 +257,34 @@ describe('', () => {
// We click the heading providing the link nodeName to emulate
// bubbling.
Simulate.click(heading, { target: { nodeName: 'A' } });
+
expect(fakeTracking.sendEvent.calledWith({
action: TRACKING_TYPE_EXTENSION,
category: CLICK_CATEGORY,
label: 'foo',
})).toBeTruthy();
});
+
+ it('disables incompatible add-ons', () => {
+ const { store } = createStore();
+ const root = renderAddon({
+ addon: {
+ ...result,
+ current_version: {},
+ },
+ ...result,
+ getClientCompatibility: () => ({
+ compatible: false,
+ maxVersion: '4000000.0',
+ minVersion: '400000.0',
+ reason: 'WHATEVER',
+ }),
+ store,
+ });
+ expect(
+ findDOMNode(root).querySelector('.AddonCompatibilityError').textContent
+ ).toEqual('This add-on does not support your browser.');
+ });
});
@@ -308,19 +349,28 @@ describe('', () => {
it('calls installTheme on click', () => {
const installTheme = sinon.stub();
- const data = {
- ...result,
- addon: sinon.stub(),
- type: ADDON_TYPE_THEME,
- status: UNINSTALLED,
+ const props = {
+ addon: result,
+ clientApp: signedInApiState.clientApp,
+ getBrowserThemeData: () => '{"theme":"data"}',
+ getClientCompatibility: () => ({ compatible: true, reason: null }),
+ hasAddonManager: true,
+ i18n: getFakeI18nInst(),
installTheme,
+ setCurrentStatus: sinon.stub(),
+ status: UNINSTALLED,
+ type: ADDON_TYPE_THEME,
+ userAgentInfo: signedInApiState.userAgentInfo,
};
- root = renderAddon(data);
- themeImage = findDOMNode(root).querySelector('.theme-image');
- const preventDefault = sinon.spy();
- Simulate.click(themeImage, { preventDefault });
- expect(preventDefault.called).toBeTruthy();
- expect(installTheme.calledWith(themeImage, data.addon)).toBeTruthy();
+ const shallowRoot = shallow();
+ themeImage = shallowRoot.find('.theme-image');
+
+ const preventDefault = sinon.stub();
+ const fakeEvent = { currentTarget: themeImage, preventDefault };
+ themeImage.simulate('click', fakeEvent);
+
+ sinon.assert.called(preventDefault);
+ sinon.assert.calledWith(installTheme, themeImage, props.addon);
});
});
@@ -331,6 +381,7 @@ describe('', () => {
downloadProgress: 75,
};
const props = mapStateToProps({
+ api: signedInApiState,
installations: { foo: { some: 'data' }, 'foo@addon': addon },
addons: { 'foo@addon': { addonProp: 'addonValue' } },
}, { guid: 'foo@addon' });
@@ -341,15 +392,23 @@ describe('', () => {
guid: 'foo@addon',
downloadProgress: 75,
addonProp: 'addonValue',
+ clientApp: signedInApiState.clientApp,
+ userAgentInfo: signedInApiState.userAgentInfo,
});
});
it('handles missing data', () => {
const props = mapStateToProps({
+ api: signedInApiState,
installations: {},
addons: {},
}, { guid: 'nope@addon' });
- expect(props).toEqual({ addon: {} });
+
+ expect(props).toEqual({
+ addon: {},
+ clientApp: null,
+ userAgentInfo: signedInApiState.userAgentInfo,
+ });
});
});
});
diff --git a/tests/unit/disco/components/TestAddonCompatibilityError.js b/tests/unit/disco/components/TestAddonCompatibilityError.js
new file mode 100644
index 00000000000..0b62232bc62
--- /dev/null
+++ b/tests/unit/disco/components/TestAddonCompatibilityError.js
@@ -0,0 +1,73 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+
+import {
+ AddonCompatibilityErrorBase,
+ mapStateToProps,
+} from 'disco/components/AddonCompatibilityError';
+import {
+ INCOMPATIBLE_FIREFOX_FOR_IOS,
+ INCOMPATIBLE_NOT_FIREFOX,
+ INCOMPATIBLE_UNDER_MIN_VERSION,
+} from 'core/constants';
+import { dispatchClientMetadata } from 'tests/unit/amo/helpers';
+import { getFakeI18nInst } from 'tests/unit/helpers';
+
+
+describe('AddonCompatibilityError', () => {
+ function render({ ...props }) {
+ const { store } = dispatchClientMetadata();
+
+ return shallow(
+
+ );
+ }
+
+ it('renders a notice for old versions of Firefox', () => {
+ const root = render({
+ minVersion: '11.0',
+ reason: INCOMPATIBLE_UNDER_MIN_VERSION,
+ userAgentInfo: { browser: { name: 'Firefox', version: '8.0' }, os: {} },
+ });
+ const content = root.find('.AddonCompatibilityError').render();
+
+ expect(content.text()).toEqual(
+ 'This add-on does not support your version of Firefox.');
+ });
+
+ it('renders a notice for iOS users', () => {
+ const root = render({
+ reason: INCOMPATIBLE_FIREFOX_FOR_IOS,
+ userAgentInfo: { browser: { name: 'Firefox' }, os: { name: 'iOS' } },
+ });
+ const content = root.find('.AddonCompatibilityError').render();
+
+ expect(content.text()).toContain(
+ 'Firefox for iOS does not currently support add-ons.');
+ });
+
+ it('renders a generic message when reason code not known', () => {
+ const root = render({ reason: 'fake reason' });
+ const content = root.find('.AddonCompatibilityError').render();
+
+ expect(content.text()).toContain(
+ 'This add-on does not support your browser.');
+ });
+
+ it('throws an error if no reason is supplied', () => {
+ expect(() => {
+ render({ minVersion: '11.0' });
+ }).toThrowError('AddonCompatibilityError requires a "reason" prop');
+ });
+
+ it('throws an error if minVersion is missing', () => {
+ expect(() => {
+ render({ minVersion: undefined, reason: INCOMPATIBLE_NOT_FIREFOX });
+ }).toThrowError('minVersion is required; it cannot be undefined');
+ });
+});