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