diff --git a/frontend/src/app/components/ExperimentEolDialog.js b/frontend/src/app/components/ExperimentEolDialog.js new file mode 100644 index 0000000000..0b0e41c062 --- /dev/null +++ b/frontend/src/app/components/ExperimentEolDialog.js @@ -0,0 +1,43 @@ +import React from 'react'; + +export default class ExperimentEolDialog extends React.Component { + + render() { + const { title } = this.props; + return ( +
+
+
+

Disable Experiment?

+
+
+ +
+

The {title} experiment has ended. Once you uninstall it you won't be able to re-install it through Test Pilot again.

+
+
+ + this.cancel(e)} data-l10n-id="retireCancelButton" className="cancel modal-escape" href="">Cancel +
+
+
+
+ ); + } + + proceed(e) { + e.preventDefault(); + this.props.onSubmit(e); + } + + cancel(e) { + e.preventDefault(); + this.props.onCancel(); + } +} + +ExperimentEolDialog.propTypes = { + title: React.PropTypes.string, + onCancel: React.PropTypes.func, + onSubmit: React.PropTypes.func +}; diff --git a/frontend/src/app/components/ExperimentRowCard.js b/frontend/src/app/components/ExperimentRowCard.js index 4ee3a9426d..852091446e 100644 --- a/frontend/src/app/components/ExperimentRowCard.js +++ b/frontend/src/app/components/ExperimentRowCard.js @@ -10,12 +10,12 @@ const MAX_JUST_UPDATED_PERIOD = 2 * ONE_WEEK; export default class ExperimentRowCard extends React.Component { render() { - const { hasAddon, experiment, enabled, isExperimentCompleted } = this.props; + const { hasAddon, experiment, enabled, isAfterCompletedDate } = this.props; const { description, thumbnail } = experiment; const installation_count = (experiment.installation_count) ? experiment.installation_count : 0; const title = experiment.short_title || experiment.title; - const isCompleted = isExperimentCompleted(experiment); + const isCompleted = isAfterCompletedDate(experiment); // TODO: #1138 Replace this highly hackly hook so that the subtitle comes from the model const subtitle = (experiment.title === 'No More 404s') ? @@ -161,5 +161,5 @@ ExperimentRowCard.propTypes = { getExperimentLastSeen: React.PropTypes.func.isRequired, sendToGA: React.PropTypes.func.isRequired, navigateTo: React.PropTypes.func.isRequired, - isExperimentCompleted: React.PropTypes.func + isAfterCompletedDate: React.PropTypes.func }; diff --git a/frontend/src/app/containers/App.js b/frontend/src/app/containers/App.js index b4ee66315a..1f6f01ad9f 100644 --- a/frontend/src/app/containers/App.js +++ b/frontend/src/app/containers/App.js @@ -7,7 +7,7 @@ import { push as routerPush } from 'react-router-redux'; import cookies from 'js-cookie'; import Clipboard from 'clipboard'; -import { getInstalled, isExperimentEnabled, isExperimentCompleted, isInstalledLoaded } from '../reducers/addon'; +import { getInstalled, isExperimentEnabled, isAfterCompletedDate, isInstalledLoaded } from '../reducers/addon'; import { getExperimentBySlug, isExperimentsLoaded } from '../reducers/experiments'; import experimentSelector from '../selectors/experiment'; import { uninstallAddon, installAddon, enableExperiment, disableExperiment, pollAddon } from '../lib/addon'; @@ -132,7 +132,7 @@ export default connect( isDev: state.browser.isDev, isExperimentEnabled: experiment => isExperimentEnabled(state.addon, experiment), - isExperimentCompleted, + isAfterCompletedDate, isFirefox: state.browser.isFirefox, isMinFirefox: state.browser.isMinFirefox, routing: state.routing diff --git a/frontend/src/app/containers/ExperimentPage.js b/frontend/src/app/containers/ExperimentPage.js index 9845476423..edadfb9c24 100644 --- a/frontend/src/app/containers/ExperimentPage.js +++ b/frontend/src/app/containers/ExperimentPage.js @@ -9,6 +9,7 @@ import LoadingPage from './LoadingPage'; import NotFoundPage from './NotFoundPage'; import ExperimentDisableDialog from '../components/ExperimentDisableDialog'; +import ExperimentEolDialog from '../components/ExperimentEolDialog'; import ExperimentTourDialog from '../components/ExperimentTourDialog'; import MainInstallButton from '../components/MainInstallButton'; import ExperimentCardList from '../components/ExperimentCardList'; @@ -54,7 +55,8 @@ export class ExperimentDetail extends React.Component { showPreFeedbackDialog: false, changeHeaderOn: 125, stickyHeaderSiblingHeight: 0, - privacyScrollOffset: 15 + privacyScrollOffset: 15, + showEolDialog: false }; // HACK: Set this as a plain object property, so we don't trigger crazy @@ -155,8 +157,9 @@ export class ExperimentDetail extends React.Component { } const { enabled, useStickyHeader, highlightMeasurementPanel, - showDisableDialog, showTourDialog, isEnabling, isDisabling, - progressButtonWidth, showPreFeedbackDialog, stickyHeaderSiblingHeight } = this.state; + showDisableDialog, showTourDialog, + showPreFeedbackDialog, showEolDialog, + stickyHeaderSiblingHeight } = this.state; const { title, version, contribute_url, bug_report_url, discourse_url, introduction, measurements, privacy_notice_url, changelog_url, @@ -168,11 +171,8 @@ export class ExperimentDetail extends React.Component { // TODO: #1138 - add optional subtitles the right way const subtitle = (title === 'No More 404s') ? 'Powered by the Wayback Machine' : ''; - const installation_count = (experiment.installation_count) ? experiment.installation_count.toLocaleString() : '0'; const surveyURL = buildSurveyURL('givefeedback', title, installed, survey_url); const modified = formatDate(experiment.modified); - const completedDate = experiment.completed ? formatDate(experiment.completed) : null; - const validVersion = this.isValidVersion(min_release); let statusType = null; if (experiment.error) { @@ -200,6 +200,15 @@ export class ExperimentDetail extends React.Component { surveyURL={surveyURL} onCancel={() => this.setState({ showPreFeedbackDialog: false })} />} + {showEolDialog && + this.setState({ showEolDialog: false })} + onSubmit={e => { + this.setState({ showEolDialog: false }); + this.renderUninstallSurvey(e); + }} />} + {!hasAddon &&
@@ -235,12 +244,7 @@ export class ExperimentDetail extends React.Component {

{title}

{subtitle}

- {hasAddon && validVersion &&
- {!enabled && this.highlightPrivacy(e)} data-hook="highlight-privacy" className="highlight-privacy" data-l10n-id="highlightPrivacy">Your privacy} - {enabled && this.handleFeedback(e)} data-l10n-id="giveFeedback" data-hook="feedback" id="feedback-button" className="button default" href={surveyURL}>Give Feedback} - {enabled && } - {!enabled && } -
} + { this.renderExperimentControls() } { this.renderMinimumVersionNotice(title, hasAddon, min_release) } @@ -258,7 +262,7 @@ export class ExperimentDetail extends React.Component {
- { this.renderInstallationCount(installation_count, title) } + { this.renderInstallationCount() }
{!hasAddon &&
{!!introduction &&
@@ -323,12 +327,7 @@ export class ExperimentDetail extends React.Component {
{this.renderIncompatibleAddons()} - {completedDate &&
-
- This experiment is ending on {completedDate}.

- After then you will still be able to use {title} but we will no longer be providing updates or support.
-
-
} + {this.renderEolBlock()} {hasAddon &&
{!!introduction &&
@@ -438,16 +437,26 @@ export class ExperimentDetail extends React.Component { // which has been sending telemetry pings via installs from dev // TODO: figure out a non-hack way to toggle user counts when we have // telemetry data coming in from prod - renderInstallationCount(installation_count, title) { - if (installation_count <= 100) { + renderInstallationCount() { + const { experiment, isAfterCompletedDate } = this.props; + const { completed, title, installation_count } = experiment; + + if (isAfterCompletedDate(experiment)) { + const completedDate = formatDate(completed); + return ( + Experiment End Date: {completedDate} + ); + } + if (!installation_count || installation_count <= 100) { return ( Just launched! ); } + const installCount = installation_count.toLocaleString(); return ( - There are {installation_count} - people trying {title} right now! + There are {installCount} + people trying {title} right now! ); } @@ -463,6 +472,59 @@ export class ExperimentDetail extends React.Component { return null; } + renderExperimentControls() { + const { enabled, isEnabling, isDisabling, progressButtonWidth } = this.state; + const { experiment, installed, isAfterCompletedDate, hasAddon } = this.props; + const { title, min_release, survey_url } = experiment; + const validVersion = this.isValidVersion(min_release); + const surveyURL = buildSurveyURL('givefeedback', title, installed, survey_url); + + if (!hasAddon || !validVersion) { + return null; + } + if (isAfterCompletedDate(experiment)) { + if (enabled) { + return ( +
+ +
+ ); + } + return null; + } + if (enabled) { + return ( +
+ this.handleFeedback(e)} data-l10n-id="giveFeedback" id="feedback-button" className="button default" href={surveyURL}>Give Feedback + +
+ ); + } + return ( +
+ this.highlightPrivacy(e)} className="highlight-privacy" data-l10n-id="highlightPrivacy">Your privacy + +
+ ); + } + + renderEolBlock() { + const { experiment, isAfterCompletedDate } = this.props; + if (!experiment.completed || isAfterCompletedDate(experiment)) { return null; } + + const title = experiment.title; + const completedDate = formatDate(experiment.completed); + + return ( +
+
+ This experiment is ending on {completedDate}.

+ After then you will still be able to use {title} but we will no longer be providing updates or support. +
+
+ ); + } + // scrollOffset lets us scroll to the top of the highlight box shadow animation highlightPrivacy(evt) { const { getElementOffsetHeight, getElementY, setScrollY } = this.props; @@ -592,6 +654,7 @@ ExperimentDetail.propTypes = { installed: React.PropTypes.object, installedAddons: React.PropTypes.array, navigateTo: React.PropTypes.func, + isAfterCompletedDate: React.PropTypes.func, isExperimentEnabled: React.PropTypes.func, requireRestart: React.PropTypes.func, sendToGA: React.PropTypes.func, diff --git a/frontend/src/app/containers/HomePageNoAddon.js b/frontend/src/app/containers/HomePageNoAddon.js index b0e62332da..6c498a51d4 100644 --- a/frontend/src/app/containers/HomePageNoAddon.js +++ b/frontend/src/app/containers/HomePageNoAddon.js @@ -9,11 +9,11 @@ import View from '../components/View'; export default class HomePageNoAddon extends React.Component { render() { - const { experiments, isExperimentCompleted } = this.props; + const { experiments, isAfterCompletedDate } = this.props; if (experiments.length === 0) { return ; } - const currentExperiments = experiments.filter(x => !isExperimentCompleted(x)); + const currentExperiments = experiments.filter(x => !isAfterCompletedDate(x)); return (
@@ -78,5 +78,5 @@ HomePageNoAddon.propTypes = { hasAddon: React.PropTypes.bool, isFirefox: React.PropTypes.bool, experiments: React.PropTypes.array, - isExperimentCompleted: React.PropTypes.func + isAfterCompletedDate: React.PropTypes.func }; diff --git a/frontend/src/app/containers/HomePageWithAddon.js b/frontend/src/app/containers/HomePageWithAddon.js index ef15807fa7..8e8886e801 100644 --- a/frontend/src/app/containers/HomePageWithAddon.js +++ b/frontend/src/app/containers/HomePageWithAddon.js @@ -18,7 +18,7 @@ export default class HomePageWithAddon extends React.Component { } render() { - const { experiments, getCookie, removeCookie, getWindowLocation, isExperimentCompleted } = this.props; + const { experiments, getCookie, removeCookie, getWindowLocation, isAfterCompletedDate } = this.props; if (experiments.length === 0) { return ; } @@ -30,8 +30,8 @@ export default class HomePageWithAddon extends React.Component { showEmailDialog = true; } const { showPastExperiments } = this.state; - const currentExperiments = experiments.filter(x => !isExperimentCompleted(x)); - const pastExperiments = experiments.filter(isExperimentCompleted); + const currentExperiments = experiments.filter(x => !isAfterCompletedDate(x)); + const pastExperiments = experiments.filter(isAfterCompletedDate); return ( @@ -79,5 +79,5 @@ HomePageWithAddon.propTypes = { uninstallAddon: React.PropTypes.func, sendToGA: React.PropTypes.func, openWindow: React.PropTypes.func, - isExperimentCompleted: React.PropTypes.func + isAfterCompletedDate: React.PropTypes.func }; diff --git a/frontend/src/app/reducers/addon.js b/frontend/src/app/reducers/addon.js index 838eb03530..6027be8d52 100644 --- a/frontend/src/app/reducers/addon.js +++ b/frontend/src/app/reducers/addon.js @@ -36,7 +36,7 @@ export const getInstalled = (state) => state.installed; export const isExperimentEnabled = (state, experiment) => !!(experiment && experiment.addon_id in state.installed); -export const isExperimentCompleted = (experiment) => +export const isAfterCompletedDate = (experiment) => ((new Date(experiment.completed)).getTime() < Date.now()); export const isInstalledLoaded = (state) => state.installedLoaded; diff --git a/frontend/src/styles/modules/_experiment-details.scss b/frontend/src/styles/modules/_experiment-details.scss index fdd7d3958c..b4adc5f4c4 100644 --- a/frontend/src/styles/modules/_experiment-details.scss +++ b/frontend/src/styles/modules/_experiment-details.scss @@ -246,6 +246,29 @@ padding: 10px; } +.completed-block { + background: $error-red-background; + border: 1px solid $error-red-border; + border-radius: 3px; + margin-bottom: $grid-unit; + padding: $grid-unit; + + h3, + p { + font-size: $font-unit * (7 / 6); + margin: 0; + } + + h3 { + font-weight: bold; + } + + p { + line-height: $line-unit; + padding-top: 10px; + } +} + .details-sections { section { diff --git a/frontend/test/app/components/ExperimentEolDialog-test.js b/frontend/test/app/components/ExperimentEolDialog-test.js new file mode 100644 index 0000000000..db3ea2b39a --- /dev/null +++ b/frontend/test/app/components/ExperimentEolDialog-test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { shallow, mount } from 'enzyme'; + +import ExperimentEolDialog from '../../../src/app/components/ExperimentEolDialog'; + +describe('app/components/ExperimentEolDialog', () => { + let props, mockClickEvent, subject; + beforeEach(function() { + props = { + onSubmit: sinon.spy(), + onCancel: sinon.spy() + }; + mockClickEvent = { + preventDefault: sinon.spy() + }; + subject = shallow(); + }); + + const findByL10nID = id => subject.findWhere(el => id === el.props()['data-l10n-id']); + + it('should display expected content', () => { + expect(subject.find('#retire-dialog-modal')).to.have.property('length', 1); + }); + + it('calls onCancel when the cancel button is clicked', () => { + subject.find('.modal-actions a.cancel').simulate('click', mockClickEvent); + expect(props.onCancel.called).to.be.true; + }); + + it('calls onSubmit when the disable button is clicked', () => { + findByL10nID('disableExperiment').simulate('click', mockClickEvent); + expect(props.onSubmit.called).to.be.true; + }); + +}); diff --git a/frontend/test/app/components/ExperimentRowCard-test.js b/frontend/test/app/components/ExperimentRowCard-test.js index 81f04fa3dc..883082f3db 100644 --- a/frontend/test/app/components/ExperimentRowCard-test.js +++ b/frontend/test/app/components/ExperimentRowCard-test.js @@ -27,7 +27,7 @@ describe('app/components/ExperimentRowCard', () => { navigateTo: sinon.spy(), sendToGA: sinon.spy(), getExperimentLastSeen: sinon.spy(), - isExperimentCompleted: sinon.spy() + isAfterCompletedDate: sinon.spy() }; subject = shallow(); }); @@ -165,7 +165,7 @@ describe('app/components/ExperimentRowCard', () => { experiment: { ...mockExperiment, completed: moment().subtract(1, 'days').utc() }, - isExperimentCompleted: () => true + isAfterCompletedDate: () => true }); expect(findByL10nID('experimentCardLearnMore')).to.have.property('length', 1); expect(findByL10nID('participantCount')).to.have.property('length', 0); diff --git a/frontend/test/app/containers/ExperimentPage-test.js b/frontend/test/app/containers/ExperimentPage-test.js index 4f2ace7018..46f3eb22e5 100644 --- a/frontend/test/app/containers/ExperimentPage-test.js +++ b/frontend/test/app/containers/ExperimentPage-test.js @@ -75,6 +75,7 @@ describe('app/components/ExperimentPage:ExperimentDetail', () => { params: {}, uninstallAddon: sinon.spy(), navigateTo: sinon.spy(), + isAfterCompletedDate: sinon.stub().returns(false), isExperimentEnabled: sinon.spy(), requireRestart: sinon.spy(), sendToGA: sinon.spy(), @@ -445,6 +446,42 @@ describe('app/components/ExperimentPage:ExperimentDetail', () => { }); + describe('with a completed experiment', () => { + beforeEach(() => { + subject.setProps({ + experiment: Object.assign({}, mockExperiment, { completed: '2016-10-01' }), + isAfterCompletedDate: sinon.stub().returns(true) + }); + }); + + it('does not render controls', () => { + expect(subject.find('.experiment-controls').length).to.equal(0); + }); + + it('displays the end date instead of install count', () => { + expect(findByL10nID('completedDateLabel').length).to.equal(1); + expect(findByL10nID('userCountContainer').length).to.equal(0); + expect(findByL10nID('userCountContainerAlt').length).to.equal(0); + }); + + describe('with experiment enabled', () => { + beforeEach(() => { + subject.setProps({ isExperimentEnabled: experiment => true }); + }); + + it('only renders the disable button control', () => { + expect(findByL10nID('giveFeedback').length).to.equal(0); + expect(findByL10nID('disableExperiment').length).to.equal(1); + expect(subject.find('#uninstall-button').hasClass('warning')).to.equal(true); + }); + + it('shows a modal dialog when the disable button is clicked', () => { + expect(subject.state('showEolDialog')).to.equal(false); + subject.find('#uninstall-button').simulate('click', mockClickEvent); + expect(subject.state('showEolDialog')).to.equal(true); + }); + }); + }); }); }); diff --git a/frontend/test/app/containers/HomePageNoAddon-test.js b/frontend/test/app/containers/HomePageNoAddon-test.js index 8599f706e0..c64b23b1c3 100644 --- a/frontend/test/app/containers/HomePageNoAddon-test.js +++ b/frontend/test/app/containers/HomePageNoAddon-test.js @@ -15,7 +15,7 @@ describe('app/containers/HomePageNoAddon', () => { isFirefox: false, uninstallAddon: sinon.spy(), sendToGA: sinon.spy(), - isExperimentCompleted: sinon.spy(x => !!x.completed) + isAfterCompletedDate: sinon.spy(x => !!x.completed) }; subject = shallow(); }); diff --git a/frontend/test/app/containers/HomePageWithAddon-test.js b/frontend/test/app/containers/HomePageWithAddon-test.js index ee3c49c76d..0cba2abab8 100644 --- a/frontend/test/app/containers/HomePageWithAddon-test.js +++ b/frontend/test/app/containers/HomePageWithAddon-test.js @@ -20,7 +20,7 @@ describe('app/containers/HomePageWithAddon', () => { subscribeToBasket: sinon.spy(), sendToGA: sinon.spy(), openWindow: sinon.spy(), - isExperimentCompleted: sinon.spy(x => !!x.completed) + isAfterCompletedDate: sinon.spy(x => !!x.completed) }; subject = shallow(); }); diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 1af3d49cfd..07674c527c 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -88,6 +88,7 @@ otherExperiments = Try out these experiments as well giveFeedback = Give Feedback +disableHeader = Disable Experiment? disableExperiment = Disable {$title} disableExperimentTransition = Disabling... enableExperiment = Enable {$title} @@ -161,6 +162,8 @@ shareEmail = E-mail shareCopy = Copy eolMessage = This experiment is ending on {$completedDate}.

After then you will still be able to use {$title} but we will no longer be providing updates or support. +eolDisableMessage = The {$title} experiment has ended. Once you uninstall it you won't be able to re-install it through Test Pilot again. +completedDateLabel = Experiment End Date: {$completedDate} incompatibleHeader = This experiment may not be compatible with add-ons you have installed. incompatibleSubheader = We recommend disabling these add-ons before activating this experiment: