From e734e21ce580d3853bbcf82f418f026e1e15f6f9 Mon Sep 17 00:00:00 2001 From: Stuart Colville Date: Fri, 6 May 2016 19:22:36 +0100 Subject: [PATCH] Add theme preview --- src/disco/components/Addon.js | 66 ++++++++++++++-- src/disco/constants.js | 19 +++++ src/disco/css/Addon.scss | 14 +++- src/disco/css/inc/vars.scss | 1 + src/disco/fakeData.js | 6 +- src/disco/themePreview.js | 10 +++ tests/client/disco/TestThemePreview.js | 29 +++++++ tests/client/disco/components/TestAddon.js | 88 ++++++++++++++++++---- 8 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 src/disco/css/inc/vars.scss create mode 100644 src/disco/themePreview.js create mode 100644 tests/client/disco/TestThemePreview.js diff --git a/src/disco/components/Addon.js b/src/disco/components/Addon.js index 80cab9843bd..b439b1c536c 100644 --- a/src/disco/components/Addon.js +++ b/src/disco/components/Addon.js @@ -1,34 +1,66 @@ import React, { PropTypes } from 'react'; import classNames from 'classnames'; +import themeAction from 'disco/themePreview'; +import { gettext as _ } from 'core/utils'; import InstallButton from './InstallButton'; +import { + validAddonTypes, + EXTENSION_TYPE, + THEME_TYPE, + THEME_PREVIEW, + THEME_RESET_PREVIEW, +} from 'disco/constants'; import 'disco/css/Addon.scss'; export default class Addon extends React.Component { static propTypes = { - id: PropTypes.number.isRequired, - type: PropTypes.string.isRequired, + accentcolor: PropTypes.string, + editorialDescription: PropTypes.string.isRequired, + footerURL: PropTypes.string, + headerURL: PropTypes.string, heading: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, subHeading: PropTypes.string, - editorialDescription: PropTypes.string.isRequired, + textcolor: PropTypes.string, + type: PropTypes.oneOf(validAddonTypes).isRequired, + themeAction: PropTypes.func, + } + + static defaultProps = { + // Defaults themeAction to the imported func. + themeAction, + } + + getBrowserThemeData() { + const { id, name, headerURL, footerURL, textcolor, accentcolor } = this.props; + return JSON.stringify({id, name, headerURL, footerURL, textcolor, accentcolor}); } getLogo() { const { id } = this.props; const imageURL = `https://addons-dev-cdn.allizom.org/user-media/addon_icons/0/${id}-64.png?modified=1388632826`; - if (this.props.type === 'Extension') { + if (this.props.type === EXTENSION_TYPE) { return
; } return null; } getThemeImage() { - const { id } = this.props; + const { id, name } = this.props; const themeURL = `https://addons-dev-cdn.allizom.org/user-media/addons/${id}/preview_large.jpg?1239806327`; - if (this.props.type === 'Theme') { - return ; + if (this.props.type === THEME_TYPE) { + return ( + {_(`Preview); } return null; } @@ -37,11 +69,29 @@ export default class Addon extends React.Component { return { __html: this.props.editorialDescription }; } + handleClick = (e) => { + e.preventDefault(); + } + + previewTheme = (e) => { + this.props.themeAction(e.currentTarget, THEME_PREVIEW); + } + + resetPreviewTheme = (e) => { + this.props.themeAction(e.currentTarget, THEME_RESET_PREVIEW); + } + render() { const { heading, subHeading, type } = this.props; + + if (validAddonTypes.indexOf(type) === -1) { + throw new Error('Invalid addon type'); + } + const addonClasses = classNames('addon', { - theme: type === 'Theme', + theme: type === THEME_TYPE, }); + return (
{this.getThemeImage()} diff --git a/src/disco/constants.js b/src/disco/constants.js index 91059fff6bb..6031a7c7ff0 100644 --- a/src/disco/constants.js +++ b/src/disco/constants.js @@ -1,6 +1,25 @@ +// Addon States. export const DOWNLOADING = 'downloading'; export const INSTALLED = 'installed'; export const INSTALLING = 'installing'; export const UNINSTALLED = 'uninstalled'; export const UNINSTALLING = 'uninstalling'; export const UNKNOWN = 'unknown'; + +// Add-on types. +export const THEME_TYPE = 'Theme'; +export const EXTENSION_TYPE = 'Extension'; +export const validAddonTypes = [ + THEME_TYPE, + EXTENSION_TYPE, +]; + +// Theme preview actions. +export const THEME_INSTALL = 'InstallBrowserTheme'; +export const THEME_PREVIEW = 'PreviewBrowserTheme'; +export const THEME_RESET_PREVIEW = 'ResetBrowserThemePreview'; +export const validThemeActions = [ + THEME_INSTALL, + THEME_PREVIEW, + THEME_RESET_PREVIEW, +]; diff --git a/src/disco/css/Addon.scss b/src/disco/css/Addon.scss index a9432a2860e..c41a142a1a2 100644 --- a/src/disco/css/Addon.scss +++ b/src/disco/css/Addon.scss @@ -1,3 +1,6 @@ +@import "~disco/css/inc/vars"; +@import "~core/css/inc/mixins"; + $addon-padding: 20px; $primary-text-color: #000; $secondary-text-color: #6a6a6a; @@ -5,11 +8,18 @@ $secondary-text-color: #6a6a6a; .addon { background: #fff; margin-top: 20px; - overflow: hidden; .theme-image { display: block; - width: 100%; + + img { + display: block; + } + + &:hover, + &:focus { + @include focus(); + } } .install-button { diff --git a/src/disco/css/inc/vars.scss b/src/disco/css/inc/vars.scss new file mode 100644 index 00000000000..bb1e607325e --- /dev/null +++ b/src/disco/css/inc/vars.scss @@ -0,0 +1 @@ +$focus-outline-color: #0096dc; diff --git a/src/disco/fakeData.js b/src/disco/fakeData.js index 50d8dcb7d8e..23aef4b866e 100644 --- a/src/disco/fakeData.js +++ b/src/disco/fakeData.js @@ -2,11 +2,15 @@ export default { results: [{ editorial_description: 'Hover over the image to preview', heading: 'Japanese Tattoo by MaDonna', - id: 18781, + id: '18781', name: 'Japanese Tattoo', sub_heading: null, type: 'Theme', url: 'https://addons-dev.allizom.org/en-US/firefox/addon/japanese-tattoo/', + headerURL: 'https://addons-dev-cdn.allizom.org/user-media/addons/18781/personare.jpg?1239806327', + footerURL: 'https://addons-dev-cdn.allizom.org/user-media/addons/18781/persona2re.jpg?1239806327', + textcolor: '#000000', + accentcolor: '#ffffff', }, { editorial_description: '“This add-on is amazing.” — Someone', heading: 'Something something', diff --git a/src/disco/themePreview.js b/src/disco/themePreview.js new file mode 100644 index 00000000000..eb4c5a84d35 --- /dev/null +++ b/src/disco/themePreview.js @@ -0,0 +1,10 @@ +import { validThemeActions } from 'disco/constants'; + +export default function themeAction(node, action, _doc = document) { + if (validThemeActions.indexOf(action) === -1) { + throw new Error('Invalid theme action requested'); + } + const event = _doc.createEvent('Events'); + event.initEvent(action, true, false); + node.dispatchEvent(event); +} diff --git a/tests/client/disco/TestThemePreview.js b/tests/client/disco/TestThemePreview.js new file mode 100644 index 00000000000..4d8f6c99be2 --- /dev/null +++ b/tests/client/disco/TestThemePreview.js @@ -0,0 +1,29 @@ +import themeAction from 'disco/themePreview'; +import { THEME_PREVIEW } from 'disco/constants'; + + +describe('Theme Preview Lib', () => { + it('throws for invalid action', () => { + assert.throws(() => { + themeAction(null, 'whatever'); + }, Error, 'Invalid theme action requested'); + }); + + it('sets-up the event for previews', () => { + const fakeNode = { + dispatchEvent: sinon.stub(), + }; + const fakeEvent = { + initEvent: sinon.stub(), + }; + const fakeDoc = { + createEvent: sinon.stub(), + }; + fakeDoc.createEvent.returns(fakeEvent); + themeAction(fakeNode, THEME_PREVIEW, fakeDoc); + assert.ok(fakeDoc.createEvent.calledWith('Events'), 'Should call createEvent'); + assert.ok(fakeEvent.initEvent.calledWith(THEME_PREVIEW, true, false), 'Should call initEvent'); + assert.ok(fakeNode.dispatchEvent.calledWith(fakeEvent), 'should call dispatchEvent'); + }); +}); + diff --git a/tests/client/disco/components/TestAddon.js b/tests/client/disco/components/TestAddon.js index 149839890a8..621f025783e 100644 --- a/tests/client/disco/components/TestAddon.js +++ b/tests/client/disco/components/TestAddon.js @@ -1,20 +1,24 @@ import React from 'react'; -import { renderIntoDocument } from 'react-addons-test-utils'; +import { renderIntoDocument, Simulate } from 'react-addons-test-utils'; import { findDOMNode } from 'react-dom'; import Addon from 'disco/components/Addon'; -describe('', () => { +import { THEME_PREVIEW, THEME_RESET_PREVIEW } from 'disco/constants'; + + +const result = { + id: 'test-id', + type: 'Extension', + heading: 'test-heading', + subHeading: 'test-sub-heading', + editorialDescription: 'test-editorial-description', +}; + +describe('', () => { let root; - const result = { - id: 'test-id', - type: 'Extension', - heading: 'test-heading', - subHeading: 'test-sub-heading', - editorialDescription: 'test-editorial-description', - }; beforeEach(() => { - root = renderIntoDocument(); + root = renderIntoDocument(); }); it('renders the heading', () => { @@ -30,7 +34,7 @@ describe('', () => { }); it("doesn't render the subheading when not present", () => { - const data = { ...result, subHeading: undefined }; + const data = {...result, subHeading: undefined}; root = renderIntoDocument(); assert.notEqual(root.refs.heading.textContent, 'test-sub-heading'); }); @@ -43,15 +47,69 @@ describe('', () => { assert.equal(findDOMNode(root).querySelector('.theme-image'), null); }); - it('does render the theme image for a theme', () => { - const data = { ...result, type: 'Theme' }; + it('throws on invalid add-on type', () => { + assert.include(root.refs.heading.textContent, 'test-heading'); + const data = {...result, type: 'Whatever'}; + assert.throws(() => { + renderIntoDocument(); + }, Error, 'Invalid addon type'); + }); +}); + + +describe('', () => { + let root; + + beforeEach(() => { + const data = {...result, type: 'Theme'}; root = renderIntoDocument(); + }); + + it('does render the theme image for a theme', () => { assert.ok(findDOMNode(root).querySelector('.theme-image')); }); it("doesn't render the logo for a theme", () => { - const data = { ...result, type: 'Theme' }; - root = renderIntoDocument(); assert.notOk(findDOMNode(root).querySelector('.logo')); }); }); + + +describe('Theme Previews', () => { + let root; + let themeImage; + let themeAction; + + beforeEach(() => { + themeAction = sinon.stub(); + const data = {...result, type: 'Theme', themeAction}; + root = renderIntoDocument(); + themeImage = findDOMNode(root).querySelector('.theme-image'); + }); + + it('runs theme preview onMouseOver on theme image', () => { + Simulate.mouseOver(themeImage); + assert.ok(themeAction.calledWith(themeImage, THEME_PREVIEW)); + }); + + it('resets theme preview onMouseOut on theme image', () => { + Simulate.mouseOut(themeImage); + assert.ok(themeAction.calledWith(themeImage, THEME_RESET_PREVIEW)); + }); + + it('runs theme preview onFocus on theme image', () => { + Simulate.focus(themeImage); + assert.ok(themeAction.calledWith(themeImage, THEME_PREVIEW)); + }); + + it('resets theme preview onBlur on theme image', () => { + Simulate.blur(themeImage); + assert.ok(themeAction.calledWith(themeImage, THEME_RESET_PREVIEW)); + }); + + it('runs preventDefault onClick', () => { + const preventDefault = sinon.stub(); + Simulate.click(themeImage, {preventDefault}); + assert.ok(preventDefault.called); + }); +});