Large diffs are not rendered by default.

@@ -2,7 +2,7 @@
"title": "Test Pilot",
"name": "testpilot-addon",
"id": "@testpilot-addon",
"version": "3.0.4",
"version": "3.0.6",
"private": true,
"description": "Test Pilot is a privacy-sensitive user research program focused on getting new features into Firefox faster.",
"repository": "mozilla/testpilot",
@@ -13,13 +13,14 @@
"author": "Mozilla (https://mozilla.org/)",
"license": "MPL-2.0",
"devDependencies": {
"cross-env": "5.0.1",
"eslint": "4.15.0",
"eslint-plugin-import": "2.8.0",
"fs-extra": "^6.0.0",
"onchange": "^4.0.0",
"shx": "^0.2.2",
"web-ext": "^2.6.0"
"babel-eslint": "9.0.0",
"cross-env": "5.2.0",
"eslint": "5.7.0",
"eslint-plugin-import": "2.14.0",
"fs-extra": "7.0.0",
"onchange": "4.1.0",
"shx": "0.3.2",
"web-ext": "2.9.1"
},
"scripts": {
"start": "onchange -p -v \"*.js\" -- npm run package",
@@ -1,5 +1,4 @@
#!/usr/bin/env node
const globby = require('globby');
const YAML = require("yamljs");
const ContentTransformerPlugin = require(__dirname + "/../frontend/lib/content-transformer-plugin");
const buildJSON = require(__dirname + "/../frontend/lib/content/json");
@@ -22,7 +22,7 @@ geckodriver --version
sudo apt-get install python-pip python-dev build-essential
sudo pip install --upgrade pip

sudo pip install tox mozdownload mozinstall
sudo pip install tox mozdownload mozinstall==1.15

mkdir -p ~/project/firefox-downloads/
find ~/project/firefox-downloads/ -type f -mtime +90 -delete
@@ -31,4 +31,4 @@ mozdownload --version latest-beta --destination ~/project/firefox-downloads/fire
mozdownload --version latest --type daily --destination ~/project/firefox-downloads/firefox_nightly/

# Dependencies for firefox
sudo apt-get install -y libgtk3.0-cil-dev libasound2 libasound2 libdbus-glib-1-2 libdbus-1-3
sudo apt-get update && sudo apt-get install -y libgtk3.0-cil-dev libasound2 libasound2 libdbus-glib-1-2 libdbus-1-3

This file was deleted.

@@ -12,4 +12,5 @@ mozinstall $(ls -t /home/ubuntu/firefox-downloads/firefox_dev/*.tar.bz2 | head -
firefox --version
export PYTEST_ADDOPTS=--html=integration-test-results/ui-test-dev.html
export SKIP_INSTALL_TEST=True
npm start &
tox -e ui-tests
@@ -12,4 +12,5 @@ fi
mozinstall $(ls -t firefox-downloads/firefox_nightly/*.tar.bz2 | head -1)
firefox --version
export PYTEST_ADDOPTS=--html=integration-test-results/ui-test-nightly.html
npm start &
tox -e ui-tests
@@ -13,4 +13,5 @@ mozinstall $(ls -t firefox-downloads/firefox/*.tar.bz2 | head -1)
firefox --version
export PYTEST_ADDOPTS=--html=integration-test-results/ui-test-release.html
export SKIP_INSTALL_TEST=True
npm start &
tox -e ui-tests
@@ -1,5 +1,4 @@
#!/usr/bin/env node
const globby = require('globby');
const YAML = require("yamljs");
const ContentTransformerPlugin = require(__dirname + "/../frontend/lib/content-transformer-plugin");
const buildL10N = require(__dirname + "/../frontend/lib/content/l10n");
50 circle.yml 100644 → 100755
@@ -2,7 +2,7 @@ version: 2.0
notify:
branches:
only:
- master
- eol
- l10n
jobs:
build:
@@ -13,11 +13,13 @@ jobs:
- checkout
- restore_cache:
keys:
- node-modules-
- v2-npm-deps-{{ checksum "package.json" }}-{{ checksum "addon/package.json" }}
- v2-npm-deps-{{ checksum "package.json" }}-
- v2-npm-deps-
- run:
name: Setup env
command: |
curl -L -o ~/bin/jq https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 && chmod +x ~/bin/jq
curl -L --create-dirs -o ~/bin/jq https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 && chmod +x ~/bin/jq
./bin/circleci/install-node-dependencies.sh
- run:
name: Build addon and frontend
@@ -36,8 +38,9 @@ jobs:
paths:
- ./*
- save_cache:
key: node-modules-
key: v2-npm-deps-{{ checksum "package.json" }}-{{ checksum "addon/package.json" }}
paths:
- node_modules
- addon/node_modules
unit_test:
docker:
@@ -66,9 +69,9 @@ jobs:
at: .
- restore_cache:
keys:
- integration-test-{{ checksum "frontend/test/ui/requirements/flake8.txt" }}
- integration-test-{{ checksum "frontend/test/ui/requirements/requirements.txt" }}
- integration-test-
- v2-integration-test-{{ checksum "frontend/test/ui/requirements/requirements.txt" }}-{{ checksum "frontend/test/ui/requirements/flake8.txt" }}
- v2-integration-test-{{ checksum "frontend/test/ui/requirements/requirements.txt" }}-
- v2-integration-test-
- run:
name: Set hosts
command: |
@@ -77,6 +80,11 @@ jobs:
- run:
name: Install dependencies
command: ./bin/circleci/install-test-dependencies.sh
- run:
name: Build addon
command: ENVIRONMENT_TITLE="local" ENVIRONMENT_URL="https://example.com:8000/" ./bin/circleci/build-addon.sh
- store_artifacts:
path: ~/project/addon/addon.xpi
- run:
name: Flake8
command: ./bin/circleci/flake8.sh
@@ -90,15 +98,7 @@ jobs:
- store_artifacts:
path: ~/project/integration-test-results
- save_cache:
key: integration-test-{{ checksum "frontend/test/ui/requirements/flake8.txt" }}
paths:
- frontend/test/ui/requirements/flake8.txt
- save_cache:
key: integration-test-{{ checksum "frontend/test/ui/requirements/requirements.txt" }}
paths:
- frontend/test/ui/requirements/requirements.txt
- save_cache:
key: integration-test-
key: v2-integration-test-{{ checksum "frontend/test/ui/requirements/requirements.txt" }}-{{ checksum "frontend/test/ui/requirements/flake8.txt" }}
paths:
- .tox
static_deploy:
@@ -116,7 +116,7 @@ jobs:
- run:
name: Static deployment
command: |
TESTPILOT_BUCKET=testpilot.dev.mozaws.net ./bin/circleci/do-exclusively.sh --branch master ./bin/deploy.sh dev
TESTPILOT_BUCKET=testpilot.dev.mozaws.net ./bin/circleci/do-exclusively.sh --branch eol ./bin/deploy.sh dev
./bin/circleci/invalidate-cloudfront-cache.sh E2ERG47PHCWD0Z
storybook_deploy:
docker:
@@ -165,20 +165,20 @@ workflows:
branches:
ignore:
- l10n
- storybook_deploy:
requires:
- build
filters:
branches:
ignore:
- l10n
# - storybook_deploy:
# requires:
# - build
# filters:
# branches:
# ignore:
# - l10n
- static_deploy:
requires:
- unit_test
- integration_test
filters:
branches:
only: master
only: eol
- l10n_deploy:
requires:
- unit_test
@@ -0,0 +1,61 @@
addon_id: support@laserlike.com
bug_report_url: "https://github.com/mozilla/advance/issues"
completed: '2018-10-17T06:00:00.000000Z'
contributors:
-
avatar: /static/images/experiments/advance/avatars/advance-avatar.png
display_name: Laserlike
created: "2018-08-07T13:00:00.00Z"
description: "See where the web takes you. Advance notes the webpage you’re on and recommends the next thing you’ll want to read."
details:
-
copy: "Advance shows Read Next suggestions in your browser's sidebar."
image: /static/images/experiments/advance/details/detail1.jpg
copy_l10nsuffix: "launch"
-
copy: "The sidebar's For You section shows content recommended for you based on your recent browsing history."
image: /static/images/experiments/advance/details/detail2.jpg
copy_l10nsuffix: "launch"
-
copy: "Open a new tab, and the Advance sidebar updates with new For You recommendations."
image: /static/images/experiments/advance/details/detail3.jpg
copy_l10nsuffix: "launch"
discourse_url: "https://discourse.mozilla-community.org/c/test-pilot/advance"
gradient_start: "#007991"
gradient_stop: "#78ffd6"
id: 18
image_facebook: /static/images/experiments/advance/social/advance-facebook.png
image_twitter: /static/images/experiments/advance/social/advance-twitter.png
introduction: "Advance delivers real-time recommendations to your Firefox sidebar while you browse. Advance uses your current browsing to suggest related news and similar pages to read next, and uses your browsing history to create a personalized feed of quality content."
launch_date: "2018-08-07T13:00:00.00Z"
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Advance privacy policy</modal-link>.
legal_notice_l10nsuffix: withlinks
locale_grantlist:
- en
locales:
- en
measurements:
- "Sensitive Data: After installation, Laserlike will receive your web browsing history. No data is sent if you are in private browsing or pause mode, the experiment expires, or you disable it. Laserlike also receives your IP addresses, dates/timestamps, and time spent on webpages. This data is used to index URLs publicly visible on the web."
- "Controls: The settings allow you to request what data Laserlike receives about you from this experiment. You can also delete cookies, web browsing history, and related Laserlike account information."
- "Technical and Interaction Data: Both Mozilla and Laserlike will receive clickthrough rates and time spent on recommended content; data on how you interact with the sidebar and experiment; and technical data about your OS, browser, locale."
order: 1
platforms:
- addon
privacy_notice_url: "https://github.com/mozilla/advance/blob/master/metrics.md"
privacy_preamble: "The Advance Test Pilot experiment is a collaboration between Laserlike and Mozilla."
slug: advance
subtitle: "Powered by Laserlike"
thumbnail: /static/images/experiments/advance/icon/thumbnail.png
title: Advance
tour_steps:
-
copy: "Keep recommended content close at hand in a sidebar."
image: /static/images/experiments/advance/tour/tour1.jpg
-
copy: "Or access suggestions when you want from the Advance toolbar icon."
copy_l10nsuffix: "launch"
image: /static/images/experiments/advance/tour/tour2.jpg
-
copy: "You can always give us feedback or disable Advance from Test Pilot."
image: /static/images/experiments/advance/tour/tour3.jpg
xpi_url: "https://testpilot.firefox.com/files/support@laserlike.com/latest"
@@ -19,7 +19,8 @@ changelog_url: 'https://github.com/mozilla/FirefoxColor/releases'
contribute_url: 'https://github.com/mozilla/FirefoxColor'
bug_report_url: 'https://github.com/mozilla/FirefoxColor/issues'
discourse_url: 'https://discourse.mozilla-community.org/c/test-pilot/firefox-color'
legal_notice: By proceeding, you agree to the <a>terms</a> and <a>privacy</a> policies of Test Pilot and the <a>Firefox Color privacy policy</a>.
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Firefox Color privacy policy</modal-link>.
legal_notice_l10nsuffix: withlinks
privacy_notice_url: 'https://github.com/mozilla/FirefoxColor/blob/master/docs/metrics.md'
measurements:
- >
@@ -2,8 +2,7 @@ dev: true
id: 999
title: 'Dev Example'
slug: dev-example
platforms: ['ios', 'web']
ios_url: https://itunes.apple.com/us/app/firefox-lockbox/id1314000270?ls=1&mt=8
platforms: ['addon']
thumbnail: /static/images/check.png
description: >
This is an example experiment that you should never see in production.
@@ -25,7 +24,8 @@ changelog_url: 'https://github.com/meandavejustice/min-vid/blob/master/CHANGELOG
contribute_url: 'https://github.com/meandavejustice/min-vid'
bug_report_url: 'https://github.com/meandavejustice/min-vid/issues'
discourse_url: 'https://discourse.mozilla-community.org/c/test-pilot/min-vid'
legal_notice: By proceeding, you agree to the <a>terms</a> and <a>privacy</a> policies of Test Pilot and the <a>Dev Example privacy policy</a>.
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Dev Example privacy policy</modal-link>.
legal_notice_l10nsuffix: withlinks
privacy_notice_url: 'https://github.com/meandavejustice/min-vid/blob/master/docs/metrics.md'
xpi_url: 'https://testpilot.firefox.com/files/@sloth/latest'
addon_id: '@sloth'
@@ -0,0 +1,76 @@
id: 20
title: 'Email Tabs'
slug: email-tabs
is_featured: false
platforms: ['addon']
min_release: 63
thumbnail: /static/images/experiments/email-tabs/email-tabs.png
description: >
Easily create emails from all of your tabs and make saving and sharing easier than ever.
introduction: >
<p>Ever needed to save or share a whole bunch of tabs as you research, shop, or just browse the web? Email Tabs lets you create beautiful emails from your open tabs to save them for later or share them. You can use Email Tabs to automatically send along links, screenshots, or even the text from articles.<p>
<p>Email Tabs currently works with the Gmail webmail client, but we’re working to bring it to other popular webmail providers as well.</p>
image_twitter: /static/images/experiments/email-tabs/email-tabs-twitter.jpg
image_facebook: /static/images/experiments/email-tabs/email-tabs-fb.jpg
changelog_url: 'https://github.com/mozilla/email-tabs/releases'
contribute_url: 'https://github.com/mozilla/email-tabs'
bug_report_url: 'https://github.com/mozilla/email-tabs/issues'
discourse_url: 'https://discourse.mozilla-community.org/c/test-pilot/email-tabs'
privacy_notice_url: 'https://github.com/mozilla/email-tabs/blob/master/docs/metrics.md'
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Email Tabs privacy policy</modal-link>.
measurements:
- >
Email Tabs collects information about your use of the feature such as which email provider you choose, how often you interact with Email Tabs UI, and whether you choose to send all tabs at once or just a selection.
- >
Email Tabs also collects information about the emails generated by the feature such as how many tabs you choose to send, the Email Tabs template you select, the count of recipients for Email Tabs emails, and whether the sender email address matches the recipient email address.
- >
Email Tabs does not collect any information about your email address, your email contacts or the URLS of any websites you visit or send while using Email Tabs.
xpi_url: 'https://testpilot.firefox.com/files/email-tabs@mozilla.org/latest'
addon_id: 'email-tabs@mozilla.org'
gradient_start: '#008EA4'
gradient_stop: '#31FFC3'
details:
-
image: /static/images/experiments/email-tabs/email-tabs-detail-1.jpg
copy: >
Create emails from your open tabs in just a few clicks.
-
image: /static/images/experiments/email-tabs/email-tabs-detail-2.jpg
copy: >
Email Tabs lets you send links, screenshots, and even full articles.
-
image: /static/images/experiments/email-tabs/email-tabs-detail-3.jpg
copy: >
Email Tabs automatically creates your email. Just add your recipients and send.
tour_steps:
-
image: /static/images/experiments/email-tabs/email-tabs-tour-0.jpg
copy: Access Email Tabs from your browser’s toolbar.
-
image: /static/images/experiments/email-tabs/email-tabs-tour-1.jpg
copy: You can choose to email all of your tabs, or just select a few.
-
image: /static/images/experiments/email-tabs/email-tabs-tour-2.jpg
copy: You can send links to your tabs, screenshots and links, or even full articles.
-
image: /static/images/experiments/email-tabs/email-tabs-tour-3.jpg
copy: Email Tabs will automatically format your email. Just add your recipients and send.
-
image: /static/images/experiments/email-tabs/email-tabs-tour-4.jpg
copy: You can always give us feedback or disable Email Tabs from Test Pilot.
contributors:
-
display_name: 'Ian Bicking'
title: 'Software Engineer'
avatar: /static/images/experiments/page-shot/avatars/ian-bicking.jpg
-
display_name: 'Dave Justice'
title: 'Engineer'
avatar: /static/images/experiments/min-vid/avatars/dave-justice.jpg
-
display_name: 'Mark Liang'
title: 'Firefox UX'
avatar: /static/images/experiments/avatars/mliang.png
created: '2018-11-12T14:00:00Z'
launch_date: '2018-11-12T14:00:00Z'
order: 0
@@ -1,8 +1,8 @@
id: 18
slug: firefox-lockbox
title: 'Firefox Lockbox'
order: 1
is_featured: true
order: 0
is_featured: false
dev: false
web_url: https://lockbox.firefox.com/
platforms: ['ios']
@@ -13,10 +13,9 @@ description: >
Take your passwords everywhere with the Firefox Lockbox App for iPhone.
introduction: >
<p>Take your passwords everywhere with Firefox Lockbox. This Test Pilot experiment gives you access to all the logins you've saved to Firefox, in a secure app on your iPhone. Just log in to Lockbox with your Firefox Account, and your saved usernames and passwords will sync to the app using 256-bit encryption. So you’ll have easy access to online accounts, even on the go.</p>
<p><strong>Firefox Lockbox requires a Firefox Account in order to access your previously <a href="chrome://passwordmgr/content/passwordManager.xul">saved logins</a>. If you do not have an account set up, get started <a href="https://lockbox.firefox.com/faq.html#how-do-i-get-my-firefox-saved-logins-into-firefox-lockbox">here</a>.</strong></p>
<p><em>Want to be notified when our Android app launches? Sign up <a href="https://goo.gl/forms/ZwLIfHSGLrYcM6k83">here</a>.</em></p>
<p>Please note, Firefox Lockbox is currently an English only app available in a few regions including Australia, New Zealand, United Kingdom, United States, and Canada.</p>
<p><strong>Firefox Lockbox requires a Firefox Account in order to access your previously saved logins. If you do not have an account set up, get started:<br/><a href="https://lockbox.firefox.com/faq.html">https://lockbox.firefox.com/faq.html</a></strong></p>
<p><em>Want to be notified when our Android app launches? Sign up:<br/><a href="https://goo.gl/forms/ZwLIfHSGLrYcM6k83">https://goo.gl/forms/ZwLIfHSGLrYcM6k83</a></em></p>
introduction_l10nsuffix: 'urlsandcountries'

video_url: https://www.youtube.com/embed/lOniFEypZhQ
image_twitter: /static/images/experiments/lockbox/social/share-twitter.jpg
@@ -126,4 +125,5 @@ contributors:
privacy_preamble: >
Firefox Lockbox allows Firefox Accounts users to access the login and password information (which we call “credentials”) on their mobile phones, outside any browser, by using Firefox Sync. In addition to the data collected by all Test Pilot experiments...
privacy_notice_url: 'https://lockbox.firefox.com/privacy.html'
legal_notice: By proceeding, you agree to the <a>terms</a> and <a>privacy</a> policies of Test Pilot and the <a>Firefox Lockbox privacy policy</a>.
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Firefox Lockbox privacy policy</modal-link>.
legal_notice_l10nsuffix: withlinks
@@ -19,7 +19,8 @@ changelog_url: 'https://github.com/mozilla/notes/releases'
contribute_url: 'https://github.com/mozilla/notes'
bug_report_url: 'https://github.com/mozilla/notes/issues'
discourse_url: 'https://discourse.mozilla-community.org/c/test-pilot/notes'
legal_notice: By proceeding, you agree to the <a>terms</a> and <a>privacy</a> policies of Test Pilot and the <a>Notes privacy policy</a>.
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Notes privacy policy</modal-link>.
legal_notice_l10nsuffix: withlinks
privacy_notice_url: 'https://github.com/mozilla/notes/blob/master/docs/metrics.md'
measurements:
- >
@@ -0,0 +1,57 @@
addon_id: shopping-testpilot@mozilla.org
bug_report_url: "https://github.com/mozilla/price-wise/issues"
contributors:
-
avatar: /static/images/experiments/price-wise/avatars/bianca.jpg
display_name: "Bianca Danforth"
-
avatar: /static/images/experiments/price-wise/avatars/osmose.jpg
display_name: Osmose
-
avatar: /static/images/experiments/price-wise/avatars/carmen.jpg
display_name: Carmen Fat
-
avatar: /static/images/experiments/price-wise/avatars/pablo.jpg
display_name: Pablo Oubina
-
avatar: /static/images/experiments/price-wise/avatars/remus.jpg
display_name: Remus Dranca
created: "2018-11-12T14:00:00Z"
description: "Price Wise automatically looks for price drops on specific products at Amazon, Walmart and other top U.S. retailers."
description_l10nsuffix: "us_clarification"
details:
-
copy: "Tell Price Wise to keep an eye on a product and it’s added to your watch list."
image: /static/images/experiments/price-wise/details/detail1.png
-
copy: "When the price drops, Price Wise alerts you with a colorful heads-up."
image: /static/images/experiments/price-wise/details/detail2.png
discourse_url: "https://discourse.mozilla-community.org/c/test-pilot/price-wise"
is_featured: true
gradient_start: "#DE5BC5"
gradient_stop: "#86BBF9"
id: 19
image_facebook: /static/images/experiments/price-wise/social/price-wise-facebook.jpg
image_twitter: /static/images/experiments/price-wise/social/price-wise-twitter.jpg
introduction: "Price Wise spots price drops on things you’re interested in at Amazon and AmazonSmile, Best Buy, eBay, Home Depot and Walmart (U.S. domains only for now). When Price Wise finds a price drop, the add-on gives you a heads-up about the lower price."
introduction_l10nsuffix: "us_clarification"
launch_date: "2018-11-12T14:00:00Z"
legal_notice: "By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Price Wise privacy policy</modal-link>."
locale_grantlist:
- en
locales:
- en
measurements:
- "Product Pages: Price Wise collects how often you visit saved product pages."
- "Product Data: Price Wise collects data about the products you choose to track such as prices change frequency, size, and the accuracy of that information."
- "Interaction Data: Price Wise collects information about how you use the feature such as how often you add, delete and interact with saved products, number of products you choose to track, and how you engage with messages and push notifications."
min_release: 63
order: 1
platforms:
- addon
privacy_notice_url: "https://github.com/mozilla/price-wise/blob/master/docs/METRICS.md"
slug: price-wise
thumbnail: /static/images/experiments/price-wise/icon/thumbnail.png
title: "Price Wise"
video_url: "https://www.youtube.com/embed/UpRLjTQmkW4"
xpi_url: "https://testpilot.firefox.com/files/shopping-testpilot@mozilla.org/latest"
@@ -19,7 +19,8 @@ changelog_url: 'https://github.com/mozilla/side-view/releases'
contribute_url: 'https://github.com/mozilla/side-view/'
bug_report_url: 'https://github.com/mozilla/side-view/issues'
discourse_url: 'https://discourse.mozilla-community.org/c/test-pilot/side-view'
legal_notice: By proceeding, you agree to the <a>terms</a> and <a>privacy</a> policies of Test Pilot and the <a>Side View privacy policy</a>.
legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Side View privacy policy</modal-link>.
legal_notice_l10nsuffix: withlinks
privacy_notice_url: 'https://github.com/mozilla/side-view/blob/master/docs/metrics.md'
measurements:
- >
@@ -4,7 +4,7 @@

To graduate an experiment, an `eol_warning` should be added and deployed at least a few weeks before the experiment is due to graduate. Set the `completed` field to the date on which the experiment will graduate.

Once the graduation date has passed, the following fields should be removed from the experiment yaml file, so that localizers don't do work translating strings which will never be shown again:
Once the graduation date has passed, the following fields should be removed from the experiment YAML file, so that localizers don't do work translating strings which will never be shown again:

- eol_warning
- measurements
@@ -9,7 +9,7 @@ To make a new experiment:

1. Copy the `template.yaml` file below into `./content-src/experiments`
2. Rename your file to match the eventual slug of your experiment eg `tab-center.yaml`
3. You'll need a place to put image assets for your experiment. Make a new directory './frontend/src/images/experiments' to match the name of your yaml file
3. You'll need a place to put image assets for your experiment. Make a new directory './frontend/src/images/experiments' to match the name of your YAML file
4. In the directory you've created, `mkdir details social avatars tour icon`. You'll put various image assets into these folders.
5. As you add images, please compress them. You can do this with an [app](https://imageoptim.com/mac) or [command line tool](https://www.npmjs.com/package/image-min).
6. Populate the content as appropriate, using the [reference](#reference) to help.
@@ -159,11 +159,11 @@ web_url: 'https://example.com/some-web-experiment'

## `ios_url`

Url to the iOS app store page for the app. Required when `platform` includes `ios`.
URL to the iOS app store page for the app. Required when `platform` includes `ios`.

## `android_url`

Url to the Google Play store page for the app. Required when `platform` includes `android`.
URL to the Google Play store page for the app. Required when `platform` includes `android`.

## `basket_msg_id`

@@ -217,8 +217,8 @@ see also:[General news updates](#general-news-updates)

## `video_url`

If there's a youtube video for the experiment use this field. Note that this should be the embed URL
rather than the sharing url
If there's a YouTube video for the experiment use this field. Note that this should be the embed URL
rather than the sharing URL

```yaml
video_url: 'video_url: https://www.youtube.com/embed/lDv68xYHFXM'
@@ -456,7 +456,7 @@ contributors_extra: 'https://example.com'

## `tour_steps`

An array of one or more steps to a tour presented to users after instsalling the experiment. Each one should contain:
An array of one or more steps to a tour presented to users after installing the experiment. Each one should contain:

- `image` - The URL to an image. Should be 1280x720. Required.
- `copy` - A caption for the image. Should be wrapped in `<p>` tags. Localized, required, HTML allowed.
@@ -530,7 +530,7 @@ graduation_url: http://example.com/graduation-report

## `eol_warning`

When your experiement is ending, add this field to idicate to users what will happen next.
When your experiment is ending, add this field to indicate to users what will happen next.

Localized, required when you add a `completed` field.

@@ -606,7 +606,7 @@ locale_grantlist:

## `dev`

A boolean indicating whether this experiment should only appear in a dev environment, i.e. for testing or active development. Required.
A Boolean indicating whether this experiment should only appear in a dev environment, i.e. for testing or active development. Required.

```yaml
dev: false
@@ -616,7 +616,7 @@ dev: false

## `testpilot_options`

A set of options for configuring testpilot features for this experiment.
A set of options for configuring TestPilot features for this experiment.

Enabling / disabling the rating feedback prompt is the only option right now. Valid values are `enabled` or `disabled`. Defaults to `enabled`.

@@ -626,8 +626,8 @@ testpilot_options:
```

# `General News Updates`
General news updates about testpilot can be added in `content/news_updates.yaml`.
These will show up on the `/experiments` page in reverse chronilogical order along with
General news updates about TestPilot can be added in `content/news_updates.yaml`.
These will show up on the `/experiments` page in reverse chronological order along with
the experiment updates.

``` yaml
@@ -7,11 +7,11 @@ This document details the process we use to deploy Test Pilot to our stage and p
- Thursday (a week before we freeze) merge new strings to the `l10n` branch so
localizers have a week to get any new strings in. Hold off committing any
major new strings at this point.
- Thursday (a week after step 1) at 1pm PST the train is cut, all code is in.
- Thursday at 4pm PST we tag and push stage, and send email out.
- Thursday (a week after step 1) at 1PM PST the train is cut, all code is in.
- Thursday at 4PM PST we tag and push stage, and send email out.
- Friday is a buffer day.
- Monday at regular stand-up we review anything that Softvision found and fix it.
- Tuesday at 8am PST we push and Softvision verifies.
- Tuesday at 8AM PST we push and Softvision verifies.

## Softvision ##

@@ -41,7 +41,7 @@ Note: we auto deploy the master branch to our *development environment*: [https:

## Merge l10n to master ##

Before tagging the release, merge the l10n branch into the master branch. These commands assume the remote named `mozilla` points at the mozilla repository on github.
Before tagging the release, merge the l10n branch into the master branch. These commands assume the remote named `mozilla` points at the mozilla repository on GitHub.

`git checkout -b l10n mozilla/l10n && git pull mozilla l10n && git checkout master && git pull mozilla master && git merge l10n && git push mozilla master`

@@ -78,7 +78,7 @@ Send out an email notification to `testpilot-qa@softvision.ro` to please test th

Include Softvision and the issue link in the email notification.

Follow the steps in the ["Test Pilot Deployment Verification Test Plan" doc](DEPLOYMENT-VERIFICATION.md) to verify that the site works as expected. Along with testing any major features in the release.
Follow the steps in the ["Test Pilot Deployment Verification Test Plan" doc](verification.md) to verify that the site works as expected. Along with testing any major features in the release.

## Report Issues & Status ##

@@ -45,7 +45,7 @@ npm start
**Note:** While you *will* be able to see the web site locally via
http://localhost:8000/ - the `example.com` hostname alias is important to
several features of this site for local development. The domain `example.com`
is whitelisted and allowed to use the mozAddonManager api to manage add-ons.
is whitelisted and allowed to use the mozAddonManager API to manage add-ons.

These steps will give you a working development web server and file
watcher that will rebuild site assets as you edit. Just a few more steps and
@@ -52,7 +52,7 @@ deploy snapshots of Storybook that can be shared and viewed by many people on
the team to preview component work in progress.

The main prerequisite is that this work needs to happen on branches in the
[`mozilla/testpilot`][repo] repository on Github. Branches in personal fork
[`mozilla/testpilot`][repo] repository on GitHub. Branches in personal fork
repositories cannot participate in automated Storybook deployment. This runs
counter to our usual process, and limits participation to core team members
with commit access to the main repository.
@@ -11,20 +11,20 @@ handy to know how to run them and write new ones as code changes.
We use the recommended mozilla-central lint rules. See the
[source for eslint-plugin-mozilla][source] for details about the rules.

We also use the following eslint plugin recommended rules:
We also use the following ESLint plugin recommended rules:

- eslint-plugin-import
- eslint-plugin-flowtype
- eslint-plugin-react

To lint the frontend, run `npm run lint` in the testpilot directory.
To lint the addon, run `npm run lint` in the addon directory.
To lint the add-on, run `npm run lint` in the `addon` directory.

To lint only one file in the frontend, run eslint inside the testpilot directory:
To lint only one file in the frontend, run ESLint inside the testpilot directory:

./node_modules/.bin/eslint [path/to/file.js]

To lint only one file in the addon, run eslint inside the addon directory:
To lint only one file in the add-on, run eslint inside the `addon` directory:

./node_modules/.bin/eslint -c ../.eslintrc [path/to/file.js]

@@ -33,7 +33,7 @@ To lint only one file in the addon, run eslint inside the addon directory:

## All tests

To quickly run all tests, including addon tests, frontend tests, eslint checks, and flow types coverage reports, use `npm run test:all`.
To quickly run all tests, including add-on tests, frontend tests, eslint checks, and flow types coverage reports, use `npm run test:all`.

## Front-end client tests

@@ -85,7 +85,7 @@ Look in the `addon/test` directory for examples of tests.
## Integration tests

The tests expect the WebExtension to be built. If not you will receive an error
stating that the addon or webextension is not found.
stating that the add-on or WebExtension is not found.

Please follow the instructions [here](./quickstart.md).

This file was deleted.

@@ -65,7 +65,7 @@

### Firefox <small>(ETA: 25m)</small>

1. Open Firefox (any channel, preferrably _Release_ or _Nightly_).
1. Open Firefox (any channel, preferably _Release_ or _Nightly_).

1. Go to **about:addons** and verify that you don't have any existing Test Pilot add-on or experiment add-ons installed.

@@ -116,7 +116,7 @@
- In the case of **Tab Center**, you should see your top tabs disappear and reappear on the left hand side.
- In the case of **Universal Search** you should see a new search UI in the Awesome Bar if you search for a movie or something on Wikipedia.

1. After the experiment is successsfully installed, verify that a dialog box appears saying **"{Experiment name} enabled!"**. Click the **"Take the Tour"** button and verify the tour works and closes as expected. Confirm that after installing the experiment, the number of active users increases by 1.
1. After the experiment is successfully installed, verify that a dialog box appears saying **"{Experiment name} enabled!"**. Click the **"Take the Tour"** button and verify the tour works and closes as expected. Confirm that after installing the experiment, the number of active users increases by 1.

1. After enabling an experiment, the header area should now say **"{Experiment name} is enabled."** in green.

@@ -130,7 +130,7 @@

1. Click the Test Pilot icon in the toolbar and verify that both active experiments are listed as **"{Experiment name} is enabled."**.

1. Go back to the original Test Pilot exeriment details page and click the gray **"Disable {experiment name}"** button. Verify that the number of active experiment users decreases by 1.
1. Go back to the original Test Pilot experiment details page and click the gray **"Disable {experiment name}"** button. Verify that the number of active experiment users decreases by 1.

1. Verify that you see a **"Thank You!"** modal dialog which has an icon and thanks the user for their participation, and has two buttons:
- a blue **"Take a quick survey"** button
@@ -146,7 +146,7 @@

1. Click the **"Uninstall Test Pilot"** link in the pop-up menu.

1. Verify that you're prompted to uninstall test pilot and given the option of proceeding (a big, scary red button), or cancelling (a non-threatening blue link). Clicking the Cancel button takes you back to safetly. Clicking the scary red button will uninstall the Test Pilot add-on and any experiments you have currently installed.
1. Verify that you're prompted to uninstall test pilot and given the option of proceeding (a big, scary red button), or cancelling (a non-threatening blue link). Clicking the Cancel button takes you back to safety. Clicking the scary red button will uninstall the Test Pilot add-on and any experiments you have currently installed.

1. Click the red **"Proceed"** button to leave the Test Pilot program. You should see a **"Shutting down"** spinner and then a **"Thanks for flying!"** modal dialog thanking you for your participaction and prompting you to **"Take a quick survey"** or returning to the Home page.

@@ -116,7 +116,7 @@ Blocked on something:
* `needs:qa`
* `needs:ux`

Priority lables are based on [bugzilla's triage process](https://wiki.mozilla.org/Bugmasters/Process/Triage#Weekly_or_More_Frequently_.28depending_on_the_component.29):
Priority labels are based on [bugzilla's triage process](https://wiki.mozilla.org/Bugmasters/Process/Triage#Weekly_or_More_Frequently_.28depending_on_the_component.29):
* `p1`
* `p2`
* `p3`
@@ -3,7 +3,7 @@
# Experiment Metrics
Test Pilot experiments use Google Analytics for metrics collection, and Google Data Studio for visualization and reporting. Each experiment should create and use a new property in Mozilla’s Google Analytics account. If you need help doing so, please talk to Wil Clouser.

Events are reported through the low-level [Google Analytics Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/v1/). Refer to the documentation for the [developer guide](https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide) and [parameter reference](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters) to understand to basics of how data is reported. The [hit builder](https://ga-dev-tools.appspot.com/hit-builder/) can help you construct and validate events before reporting. Use the [`testpilot-ga` library](https://www.npmjs.com/package/testpilot-ga) to simplify the reporting prociess.
Events are reported through the low-level [Google Analytics Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/v1/). Refer to the documentation for the [developer guide](https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide) and [parameter reference](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters) to understand to basics of how data is reported. The [hit builder](https://ga-dev-tools.appspot.com/hit-builder/) can help you construct and validate events before reporting. Use the [`testpilot-ga` library](https://www.npmjs.com/package/testpilot-ga) to simplify the reporting process.

## testpilot-ga package
We have a package on npm to simplify sending ga pings in a testpilot experiment.
@@ -61,12 +61,6 @@ Here are the current events on the website as of this writing, grouped by their
| Click `Wiki` from settings | drop-down menu | wiki |
| Click `File Issue` from settings | drop-down menu | File Issue |

#### `ExperimentsPage Interactions`

| Description | `eventAction` | `eventLabel` |
|-------------|---------------|--------------|
| Click on experiment from landing page | Open detail page | `{experiment title}` |

#### `ExperimentDetailsPage Interactions`

| Description | `eventAction` | `eventLabel` |
@@ -91,6 +85,7 @@ Here are the current events on the website as of this writing, grouped by their
| Cancel Firefox permission dialog | Cancel From Permission | `{experiment title}`
| Send experiment app link to device | mobile send click | `{experiment title}` |
| Click app store links on experiment page | mobile store click | `{experiment title}` `{ios \|\| android}` |
| Click Download Firefox in Promo on experiment page | download firefox click | Download Firefox at `${title}`|

#### `SMS Modal Interactions`
| Description | `eventAction` | `eventLabel` |
@@ -146,12 +141,6 @@ Here are the current events on the website as of this writing, grouped by their
| Click on Twitter link in footer | social link clicked | Twitter |
| Click on GitHub link in footer | social link clicked | GitHub |

#### `ShareView Interactions`

| Description | `eventAction` | `eventLabel` |
|-------------|---------------|--------------|
| Click on a button in the Share section | button click | {facebook,twitter,email,copy} |

#### `PostInstall Interactions`

| Description | `eventAction` | `eventLabel` |
@@ -218,7 +207,7 @@ Here is a list of dimensions we are currently using

### Tagged Links

Whenever we are referring users to the Test Pilot website (either from an external website, or the add-on itself via a doorhanger/notification), we should include `utm_*` paramaters to allow us to properly measure conversion rates of the channel.
Whenever we are referring users to the Test Pilot website (either from an external website, or the add-on itself via a doorhanger/notification), we should include `utm_*` parameters to allow us to properly measure conversion rates of the channel.

Here is a description of the different utm tags ([URL builder tool from Google](https://support.google.com/analytics/answer/1033867))

@@ -23,7 +23,7 @@ Example from https://trello.com/c/4PNAwpwQ/3-new-experiment-add-on-ui

## Google Analytics

https://github.com/mozilla/testpilot/blob/master/docs/ga.md
https://github.com/mozilla/testpilot/blob/master/docs/metrics/ga.md

All measurements from within the web site are done in Google Analytics. This includes links from the add-on to the website (meaning, we include GA variables in the URL). GA provides a robust foundation for analytics and let's us have funnel tracking for free.

@@ -6,30 +6,6 @@ This document outlines how work is submitted, tagged, estimated and assigned in
Test Pilot. It's focused on Engineering -- there are lots of other processes
going on before, during, and after this scope.

## Waffle

Test Pilot uses a [waffle board](https://waffle.io/mozilla/testpilot) to
organize issues. The columns are:

* *Untriaged*: These are new issues we haven't looked at yet.
* *Backlog*: We've looked at these issues, but there are still some open
questions or were not ready to start working on them yet. Every issue in
this column should have a `needs:*` label and/or have an open question for
someone.
* *Ready*: These issues have been triaged, questions are answered, assets are
available, and work is ready to begin. None of the issues here should have a
`needs:*` label.
* *On Deck*: This is a short list, ordered by priority, for what we're working
on next.
* *In Progress*: These issues are being actively worked on. To be in this
column an issue must be assigned to someone.
* *In Review*: These issues are ready to be reviewed. PRs also land here.
* *Done*: These issues are closed. That likely means a fix was committed, but
it may mean an alternative was found or we decided to wontfix the issue.

An issue marked *done* is not necessarily live on the site. Pushes are done
periodically (more details below).

## New Work

New work is proposed via [Issues](https://github.com/mozilla/testpilot/issues/new).
@@ -47,13 +23,7 @@ If the issue is ready to be worked on:
* Consider labels (perhaps it's a `good-first-bug`?)
* Move the issue to *Ready*

## What is being worked on?

You can see a snapshot of what is being done right now by looking at the *In
Progess* column. Everything in the *In Progress* column will have an assignee.

## How are priorities determined?

Anything with a `critical` label is a top priority.

Past that, it's a bit fuzzier. Our stand-ups will help determine what is

This file was deleted.

@@ -1,4 +1,3 @@

declare module 'fluent-react/compat' {
declare function Localized(): Object;
}
@@ -0,0 +1,3 @@
declare module 'fluent/compat' {
declare module.exports: any;
}

This file was deleted.

@@ -0,0 +1,73 @@
// flow-typed signature: e2997cda0412ba771b3b6d9f311e9650
// flow-typed version: <<STUB>>/html-react-parser_v^0.4.1/flow_v0.64.0

/**
* This is an autogenerated libdef stub for:
*
* 'html-react-parser'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/

declare module 'html-react-parser' {
declare module.exports: any;
}

/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'html-react-parser/dist/html-react-parser' {
declare module.exports: any;
}

declare module 'html-react-parser/dist/html-react-parser.min' {
declare module.exports: any;
}

declare module 'html-react-parser/lib/attributes-to-props' {
declare module.exports: any;
}

declare module 'html-react-parser/lib/dom-to-react' {
declare module.exports: any;
}

declare module 'html-react-parser/lib/property-config' {
declare module.exports: any;
}

declare module 'html-react-parser/lib/utilities' {
declare module.exports: any;
}

// Filename aliases
declare module 'html-react-parser/dist/html-react-parser.js' {
declare module.exports: $Exports<'html-react-parser/dist/html-react-parser'>;
}
declare module 'html-react-parser/dist/html-react-parser.min.js' {
declare module.exports: $Exports<'html-react-parser/dist/html-react-parser.min'>;
}
declare module 'html-react-parser/index' {
declare module.exports: $Exports<'html-react-parser'>;
}
declare module 'html-react-parser/index.js' {
declare module.exports: $Exports<'html-react-parser'>;
}
declare module 'html-react-parser/lib/attributes-to-props.js' {
declare module.exports: $Exports<'html-react-parser/lib/attributes-to-props'>;
}
declare module 'html-react-parser/lib/dom-to-react.js' {
declare module.exports: $Exports<'html-react-parser/lib/dom-to-react'>;
}
declare module 'html-react-parser/lib/property-config.js' {
declare module.exports: $Exports<'html-react-parser/lib/property-config'>;
}
declare module 'html-react-parser/lib/utilities.js' {
declare module.exports: $Exports<'html-react-parser/lib/utilities'>;
}
@@ -0,0 +1,23 @@
// flow-typed signature: cf86673cc32d185bdab1d2ea90578d37
// flow-typed version: 614bf49aa8/classnames_v2.x.x/flow_>=v0.25.x

type $npm$classnames$Classes =
| string
| { [className: string]: * }
| false
| void
| null;

declare module "classnames" {
declare module.exports: (
...classes: Array<$npm$classnames$Classes | $npm$classnames$Classes[]>
) => string;
}

declare module "classnames/bind" {
declare module.exports: $Exports<"classnames">;
}

declare module "classnames/dedupe" {
declare module.exports: $Exports<"classnames">;
}
@@ -1,3 +1,6 @@
// flow-typed signature: afa3502910d5b2aef93707cc683f52b8
// flow-typed version: 492c298a82/react-helmet_v5.x.x/flow_>=v0.53.x

declare module 'react-helmet' {
declare type Props = {
base?: Object,
@@ -27,10 +30,15 @@ declare module 'react-helmet' {
toComponent(): [React$Element<*>] | React$Element<*> | Array<Object>;
}

declare interface AttributeTagMethods {
toString(): string;
toComponent(): {[string]: *};
}

declare interface StateOnServer {
base: TagMethods;
bodyAttributes: TagMethods,
htmlAttributes: TagMethods;
bodyAttributes: AttributeTagMethods,
htmlAttributes: AttributeTagMethods;
link: TagMethods;
meta: TagMethods;
noscript: TagMethods;
@@ -39,7 +47,7 @@ declare module 'react-helmet' {
title: TagMethods;
}

declare class Helmet extends React$Component<DefaultProps, Props, State> {
declare class Helmet extends React$Component<Props> {
static rewind(): StateOnServer;
static renderStatic(): StateOnServer;
static canUseDom(canUseDOM: boolean): void;
@@ -1,47 +1,40 @@
// flow-typed signature: a52ef25660e2172052618e087e7f31b7
// flow-typed version: 37d8964a70/react-router-dom_v4.x.x/flow_>=v0.38.x <=v0.52.x

declare module 'react-router-dom' {
declare export class BrowserRouter extends React$Component {
props: {
basename?: string,
forceRefresh?: boolean,
getUserConfirmation?: GetUserConfirmation,
keyLength?: number,
children?: React$Element<*>,
}
}

declare export class HashRouter extends React$Component {
props: {
basename?: string,
getUserConfirmation?: GetUserConfirmation,
hashType?: 'slash' | 'noslash' | 'hashbang',
children?: React$Element<*>,
}
}

declare export class Link extends React$Component {
props: {
to: string | LocationShape,
replace?: boolean,
children?: React$Element<*>,
}
}

declare export class NavLink extends React$Component {
props: {
to: string | LocationShape,
activeClassName?: string,
className?: string,
activeStyle?: Object,
style?: Object,
isActive?: (match: Match, location: Location) => boolean,
children?: React$Element<*>,
exact?: bool,
strict?: bool,
}
}
// flow-typed signature: 9fac6739b666e8a59414aa13358b677e
// flow-typed version: 187bd8b1be/react-router-dom_v4.x.x/flow_>=v0.63.x

declare module "react-router-dom" {
declare export class BrowserRouter extends React$Component<{|
basename?: string,
forceRefresh?: boolean,
getUserConfirmation?: GetUserConfirmation,
keyLength?: number,
children?: React$Node
|}> {}

declare export class HashRouter extends React$Component<{|
basename?: string,
getUserConfirmation?: GetUserConfirmation,
hashType?: "slash" | "noslash" | "hashbang",
children?: React$Node
|}> {}

declare export class Link extends React$Component<{
className?: string,
to: string | LocationShape,
replace?: boolean,
children?: React$Node
}> {}

declare export class NavLink extends React$Component<{
to: string | LocationShape,
activeClassName?: string,
className?: string,
activeStyle?: Object,
style?: Object,
isActive?: (match: Match, location: Location) => boolean,
children?: React$Node,
exact?: boolean,
strict?: boolean
}> {}

// NOTE: Below are duplicated from react-router. If updating these, please
// update the react-router and react-router-native types as well.
@@ -50,122 +43,136 @@ declare module 'react-router-dom' {
search: string,
hash: string,
state?: any,
key?: string,
}
key?: string
};

declare export type LocationShape = {
pathname?: string,
search?: string,
hash?: string,
state?: any,
}
state?: any
};

declare export type HistoryAction = 'PUSH' | 'REPLACE' | 'POP'
declare export type HistoryAction = "PUSH" | "REPLACE" | "POP";

declare export type RouterHistory = {
length: number,
location: Location,
action: HistoryAction,
listen(callback: (location: Location, action: HistoryAction) => void): () => void,
listen(
callback: (location: Location, action: HistoryAction) => void
): () => void,
push(path: string | LocationShape, state?: any): void,
replace(path: string | LocationShape, state?: any): void,
go(n: number): void,
goBack(): void,
goForward(): void,
canGo?: (n: number) => bool,
block(callback: (location: Location, action: HistoryAction) => boolean): void,
canGo?: (n: number) => boolean,
block(
callback: (location: Location, action: HistoryAction) => boolean
): void,
// createMemoryHistory
index?: number,
entries?: Array<Location>,
}
entries?: Array<Location>
};

declare export type Match = {
params: { [key: string]: ?string },
isExact: boolean,
path: string,
url: string,
}
url: string
};

declare export type ContextRouter = {
declare export type ContextRouter = {|
history: RouterHistory,
location: Location,
match: Match,
}

declare export type GetUserConfirmation =
(message: string, callback: (confirmed: boolean) => void) => void

declare type StaticRouterContext = {
url?: string,
}

declare export class StaticRouter extends React$Component {
props: {
basename?: string,
location?: string | Location,
context: StaticRouterContext,
children?: React$Element<*>,
}
}

declare export class MemoryRouter extends React$Component {
props: {
initialEntries?: Array<LocationShape | string>,
initialIndex?: number,
getUserConfirmation?: GetUserConfirmation,
keyLength?: number,
children?: React$Element<*>,
}
}

declare export class Router extends React$Component {
props: {
history: RouterHistory,
children?: React$Element<*>,
}
}

declare export class Prompt extends React$Component {
props: {
message: string | (location: Location) => string | true,
when?: boolean,
}
}

declare export class Redirect extends React$Component {
props: {
to: string | LocationShape,
push?: boolean,
}
}

declare export class Route extends React$Component {
props: {
component?: ReactClass<*>,
render?: (router: ContextRouter) => React$Element<*>,
children?: (router: ContextRouter) => React$Element<*>,
path?: string,
exact?: bool,
strict?: bool,
}
}

declare export class Switch extends React$Component {
props: {
children?: Array<React$Element<*>>,
}
}

declare type FunctionComponent<P> = (props: P) => ?React$Element<any>;
declare type ClassComponent<D, P, S> = Class<React$Component<D, P, S>>;
declare export function withRouter<P, S>(Component: ClassComponent<void, P, S> | FunctionComponent<P>): ClassComponent<void, $Diff<P, ContextRouter>, S>;
staticContext?: StaticRouterContext
|};

declare type ContextRouterVoid = {
history: RouterHistory | void,
location: Location | void,
match: Match | void,
staticContext?: StaticRouterContext | void
};

declare type MatchPathOptions = {
path: ?string,
declare export type GetUserConfirmation = (
message: string,
callback: (confirmed: boolean) => void
) => void;

declare export type StaticRouterContext = {
url?: string
};

declare export class StaticRouter extends React$Component<{|
basename?: string,
location?: string | Location,
context: StaticRouterContext,
children?: React$Node
|}> {}

declare export class MemoryRouter extends React$Component<{|
initialEntries?: Array<LocationShape | string>,
initialIndex?: number,
getUserConfirmation?: GetUserConfirmation,
keyLength?: number,
children?: React$Node
|}> {}

declare export class Router extends React$Component<{|
history: RouterHistory,
children?: React$Node
|}> {}

declare export class Prompt extends React$Component<{|
message: string | ((location: Location) => string | boolean),
when?: boolean
|}> {}

declare export class Redirect extends React$Component<{|
to: string | LocationShape,
push?: boolean,
from?: string,
exact?: boolean,
strict?: boolean
|}> {}

declare export class Route extends React$Component<{|
component?: React$ComponentType<*>,
render?: (router: ContextRouter) => React$Node,
children?: React$ComponentType<ContextRouter> | React$Node,
path?: string,
exact?: boolean,
strict?: boolean,
location?: LocationShape,
sensitive?: boolean
|}> {}

declare export class Switch extends React$Component<{|
children?: React$Node,
location?: Location
|}> {}

declare export function withRouter<P: {}, Component: React$ComponentType<P>>(
WrappedComponent: Component
): React$ComponentType<
$Diff<React$ElementConfig<Component>, ContextRouterVoid>
>;

declare type MatchPathOptions = {
path?: string,
exact?: boolean,
sensitive?: boolean,
strict?: boolean
};

declare export function matchPath(pathname: string, options?: MatchPathOptions | string): null | Match
declare export function matchPath(
pathname: string,
options?: MatchPathOptions | string,
parent?: Match
): null | Match;

declare export function generatePath(pattern?: string, params?: Object): string;
}
@@ -0,0 +1,41 @@
// flow-typed signature: e0a359e4d48c1106a3f8b77134811839
// flow-typed version: <<STUB>>/seedrandom_v2.4.3/flow_v0.78.0

/**
* This is an autogenerated libdef stub for:
*
* 'seedrandom'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/

declare module 'seedrandom' {
declare module.exports: any;
}

declare module 'seedrandom/seedrandom' {
declare module.exports: any;
}

declare module 'seedrandom/seedrandom.min' {
declare module.exports: any;
}

// Filename aliases
declare module 'seedrandom/index' {
declare module.exports: $Exports<'seedrandom'>;
}
declare module 'seedrandom/index.js' {
declare module.exports: $Exports<'seedrandom'>;
}
declare module 'seedrandom/seedrandom.js' {
declare module.exports: $Exports<'seedrandom/seedrandom'>;
}
declare module 'seedrandom/seedrandom.min.js' {
declare module.exports: $Exports<'seedrandom/seedrandom.min'>;
}

This file was deleted.

@@ -1,7 +1,7 @@
const fs = require("fs");
const path = require("path");
const util = require("util");
const globby = require("globby");
const glob = require("glob");
const mkdirp = util.promisify(require("mkdirp"));
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
@@ -19,7 +19,7 @@ class ContentTransformerPlugin {
// Resolve configured input globs into file tracking records
this.inputs = {};
Object.entries(this.options.inputs).forEach(([key, patterns]) => {
this.inputs[key] = globby.sync(patterns).map(filename => ({
this.inputs[key] = glob.sync(patterns).map(filename => ({
filename,
content: null,
parsed: null,
@@ -45,7 +45,7 @@ class ContentTransformerPlugin {
context: "."
};
const compilation = {
fileDependencies: [],
fileDependencies: new Set([]),
fileTimestamps: {},
assets: {}
};
@@ -69,7 +69,7 @@ class ContentTransformerPlugin {
file.path = path.join(compiler.context, file.filename);

// Add the file to the dependency watch list
compilation.fileDependencies.push(file.path);
compilation.fileDependencies.add(file.path);

// Work out whether this file has been modified.
const currentModified = compilation.fileTimestamps[file.path];
@@ -1,4 +1,4 @@
const Feed = require("feed");
const Feed = require("feed").Feed;
const config = require("../../config.js");
const { extractNewsUpdates } = require("./utils");

@@ -0,0 +1,26 @@
// @flow

import type {
FetchCountryCodeAction
} from "../reducers/browser";

// There may be a better place for this, but this seems
// the best for now.
export const acceptedSMSCountries = ["US", "DE", "FR"];
export const COUNTRY_CODE_ENDPOINT = "https://location.services.mozilla.com/v1/country";

export function fetchCountryCode(): Promise<FetchCountryCodeAction> {
return fetch(COUNTRY_CODE_ENDPOINT)
.then((resp) => resp.json())
.then((data) => {
return {
type: "FETCH_COUNTRY_CODE",
payload: data.country_code
};
}).catch((err) => {
return {
type: "FETCH_COUNTRY_CODE",
payload: ""
};
});
}
@@ -9,12 +9,10 @@ type BannerProps = {
background?: boolean,
condensed?: boolean,
dataL10nId?: string,
children?: Array<any>
children?: any
}

export default class Banner extends React.Component {
props: BannerProps

export default class Banner extends React.Component<BannerProps> {
render() {
const { children, condensed = false, background = false } = this.props;
return <div className={classnames("banner", {
@@ -1,6 +1,6 @@
// @flow

import React from "react";
import React, { Component } from "react";
import classnames from "classnames";

import "./index.scss";
@@ -10,8 +10,7 @@ type CopterProps = {
animation?: string
}

export default class Copter extends React.Component {
props: CopterProps
export default class Copter extends Component<CopterProps> {

render() {
const { small = false, animation = null } = this.props;
@@ -1,6 +1,6 @@
// @flow
import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";

import NewsletterForm from "../NewsletterForm";
import { subscribeToBasket } from "../../lib/utils";
@@ -20,12 +20,9 @@ type EmailDialogState = {
privacy: boolean
}

export default class EmailDialog extends React.Component {
props: EmailDialogProps
state: EmailDialogState

modalContainer: Object
submitButton: Object
export default class EmailDialog extends Component<EmailDialogProps, EmailDialogState> {
modalContainer: ?HTMLElement
submitButton: ?HTMLElement

constructor(props: EmailDialogProps) {
super(props);
@@ -170,7 +167,7 @@ export default class EmailDialog extends React.Component {
reset(e: Object) {
e.preventDefault();
e.stopPropagation();
this.setState({ isSuccess: false, isError: false });
this.setState({ isError: false });
}

skip(e: Object) {
@@ -228,7 +225,7 @@ export default class EmailDialog extends React.Component {
// Keeps the modal-container focused
// after success/error state renders
this.focusModalContainer();
this.submitButton.click();
if (this.submitButton) this.submitButton.click();
} else if (isSuccess) {
this.continue(e);
} else if (isError) {
@@ -1,4 +1,4 @@
@import '~photon-colors/colors.scss';
@import '~photon-colors/photon-colors.scss';
@import '../../../styles/_utils';

.experiment-platform {
@@ -21,8 +21,8 @@
}

.details-header & {
width: auto;
white-space: nowrap;
width: auto;
}

.platform-copy {
@@ -2,7 +2,7 @@

import classnames from "classnames";
import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";
import { Link } from "react-router-dom";

import { buildSurveyURL, experimentL10nId } from "../../lib/utils";
@@ -32,8 +32,7 @@ type ExperimentRowCardProps = {
isAfterCompletedDate: Function
}

export default class ExperimentRowCard extends React.Component {
props: ExperimentRowCardProps
export default class ExperimentRowCard extends Component<ExperimentRowCardProps> {

l10nId(pieces: string) {
return experimentL10nId(this.props.experiment, pieces);
@@ -1,4 +1,4 @@
@import '~photon-colors/colors.scss';
@import '~photon-colors/photon-colors.scss';
@import '../../../styles/_utils';

.experiment-summary {
@@ -42,7 +42,7 @@
}

header {
@include flex-container(row, space-between, center);
@include flex-container(row, space-between, flex-start);
margin-bottom: $grid-unit * .5;
width: 100%;
}
@@ -85,6 +85,10 @@
}

h4 {
color: $grey-70;
font-size: 13px;
font-weight: normal;
line-height: 15px;
margin: 0;
}

@@ -138,10 +142,10 @@
opacity: .9;
}

html[dir="rtl"] .experiment-summary {
@include respond-to('big') {
&:nth-child(3n+1) {
margin-left: 40px;
}
html[dir='rtl'] .experiment-summary {
@include respond-to('big') {
&:nth-child(3n+1) {
margin-left: 40px;
}
}
}
@@ -2,7 +2,7 @@

import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";

import StepModal from "../StepModal";

@@ -21,8 +21,7 @@ type ExperimentTourDialogProps = {
isFeatured?: boolean
}

export default class ExperimentTourDialog extends React.Component {
props: ExperimentTourDialogProps
export default class ExperimentTourDialog extends Component<ExperimentTourDialogProps> {

applyAdditionalMetricsArgs(gaArgs: Object) {
const { installed, hasAddon } = this.props;
@@ -97,7 +96,8 @@ export default class ExperimentTourDialog extends React.Component {
};

renderStep = (tourSteps: Array<Object>, currentStep: number) => {
return tourSteps.map((step, idx) => (idx === currentStep) && (
// $FlowFixMe
return tourSteps.map((step: Object, idx: number) => (idx === currentStep) && (
<div key={idx} className="step-content">
<div className="step-image">
<img src={step.image} />
@@ -43,7 +43,7 @@ describe("app/components/ExperimentTourDialog", () => {
const expectedTourStep = props.experiment.tour_steps[0];
expect(subject.find(".step-image > img").prop("src"))
.to.equal(expectedTourStep.image);
// There is now a LocalizedHtml element between the
// There is now a Localized element between the
// .step-text element and the p element, so
// '.step-text > p' won't work, but '.step-text p' does
expect(subject.find(".step-text p").html())
@@ -1,9 +1,8 @@
// @flow

import React from "react";
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { Localized } from "fluent-react/compat";
import LocalizedHtml from "../LocalizedHtml";
import { buildSurveyURL, experimentL10nId, isMobile } from "../../lib/utils";

import Modal from "../Modal";
@@ -13,37 +12,45 @@ import MobileDialog from "../MobileDialog";
import LayoutWrapper from "../LayoutWrapper";

import {
MobileStoreButton,
MobileTriggerButton,
MobileStoreButton
MobileTriggerIOSButton,
MobileTriggerAndroidButton
} from "../../containers/ExperimentPage/ExperimentButtons";

import type { InstalledExperiments } from "../../reducers/addon";

type FeaturedButtonProps = {
clientUUID?: string,
countryCode: null | string,
enabled: boolean,
experiment: Object,
eventCategory: string,
fetchCountryCode: Function,
getWindowLocation: Function,
hasAddon: any,
isMobile: boolean,
enableExperiment: Function,
installAddon: Function,
isExperimentEnabled: Function,
isFirefox: boolean,
isMinFirefox: boolean,
installed: InstalledExperiments,
sendToGA: Function,
userAgent: string
}

type FeaturedButtonState = {
isIOSDialog: boolean,
showLegalDialog: boolean,
showMobileDialog: boolean
}

export default class FeaturedButton extends React.Component {
props: FeaturedButtonProps
state: FeaturedButtonState

export default class FeaturedButton extends Component<FeaturedButtonProps, FeaturedButtonState> {
constructor(props: FeaturedButtonProps) {
super(props);
this.state = {
isIOSDialog: false,
showLegalDialog: false,
showMobileDialog: false
};
@@ -75,14 +82,17 @@ export default class FeaturedButton extends React.Component {
this.sendMetric(ev, {eventLabel: "Popup Featured privacy"});
};

return (<LocalizedHtml id={this.l10nId("legal-notice")}
$title={title}>
return (<Localized id={this.l10nId("legal_notice")}
$title={title}
terms-link={<a href="/terms" onClick={(ev) => this.sendMetric(ev, {eventLabel: "Open general terms"})}></a>}
privacy-link={<a href="/privacy" onClick={(ev) => this.sendMetric(ev, {eventLabel: "Open general privacy"})}></a>}
modal-link={<a href="#" onClick={launchLegalModal}></a>}>
<p className="main-install__legal">
By proceeding, you agree to the <a href="/terms" onClick={(ev) => this.sendMetric(ev, {eventLabel: "Open general terms"})}></a>
and <a href="/privacy" onClick={(ev) => this.sendMetric(ev, {eventLabel: "Open general privacy"})}></a> policies of Test Pilot and the
<a href="#" onClick={launchLegalModal}></a>.
By proceeding, you agree to the <terms-link>terms</terms-link>{" "}
and <privacy-link>privacy</privacy-link> policies of Test Pilot and{" "}
<modal-link>this experiment&apos;s privacy policy</modal-link>.
</p>
</LocalizedHtml>);
</Localized>);
}

renderLegalModal() {
@@ -127,11 +137,14 @@ export default class FeaturedButton extends React.Component {
});
}

doShowMobileAppDialog = (evt: MouseEvent) => {
doShowMobileAppDialog = (evt: MouseEvent, platform: string) => {
evt.preventDefault();
const { experiment } = this.props;

this.setState({ showMobileDialog: true });
this.setState({
showMobileDialog: true,
isIOSDialog: (platform === "ios")
});
this.props.sendToGA("event", {
eventCategory: "Featured Experiment",
eventAction: "mobile send click",
@@ -145,20 +158,33 @@ export default class FeaturedButton extends React.Component {
hasAddon, enabled, userAgent, sendToGA } = this.props;
const { slug, survey_url, title, platforms, ios_url, android_url } = experiment;

const { showMobileDialog } = this.state;
const { showMobileDialog, isIOSDialog } = this.state;

const category = "Featured Experiment";

const mobileControls = () => {
if (!isMobile(userAgent)) {
if (platforms.includes("ios") && platforms.includes("android")) {
return (
<React.Fragment>
<div className="main-install__spacer"></div>
<div className="mobile-button-wrap">
<MobileTriggerIOSButton doShowMobileAppDialog={this.doShowMobileAppDialog} color={"default"} />
<MobileTriggerAndroidButton doShowMobileAppDialog={this.doShowMobileAppDialog} color={"primary"} />
</div>
{ this.renderLegalLink() }
</React.Fragment>
);
}
return (
<React.Fragment>
<div className="main-install__spacer"></div>
<MobileTriggerButton optionalClass={"main-install__button"} doShowMobileAppDialog={this.doShowMobileAppDialog} color={"primary"} />
<MobileTriggerButton optionalClass={"main-install__button"} doShowMobileAppDialog={this.doShowMobileAppDialog} color={"primary"} platforms={platforms} />
{ this.renderLegalLink() }
</React.Fragment>
);
}

return (
<React.Fragment>
{platforms.includes("ios") && <MobileStoreButton {...{ url: ios_url, platform: "ios", slug, category, sendToGA }} />}
@@ -173,7 +199,7 @@ export default class FeaturedButton extends React.Component {
Buttons = (
<div>
{showMobileDialog &&
<MobileDialog {...this.props} fromFeatured={true}
<MobileDialog {...this.props} fromFeatured={true} isIOS={isIOSDialog}
onCancel={() => this.setState({ showMobileDialog: false })}
/>}
<LayoutWrapper flexModifier={"column-center-start-breaking"}
@@ -1,6 +1,6 @@
// @flow

import React from "react";
import React, { Component } from "react";
import { Localized } from "fluent-react/compat";
import { justUpdated, justLaunched } from "../../lib/experiment";

@@ -9,8 +9,7 @@ type FeaturedStatusProps = {
experiment: Object
}

export default class FeaturedStatus extends React.Component {
props: FeaturedStatusProps
export default class FeaturedStatus extends Component<FeaturedStatusProps> {

render() {
const { enabled, experiment } = this.props;
@@ -1,7 +1,7 @@
// @flow

import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";
import { Link } from "react-router-dom";

import { experimentL10nId } from "../../lib/utils";
@@ -18,6 +18,7 @@ import ExperimentPlatforms from "../ExperimentPlatforms";

type FeaturedExperimentProps = {
clientUUID?: string,
countryCode: null | string,
enabled: boolean,
eventCategory: string,
experiment: Object,
@@ -27,14 +28,15 @@ type FeaturedExperimentProps = {
isFirefox: boolean,
isExperimentEnabled: Function,
isMinFirefox: boolean,
installAddon: Function,
installed: InstalledExperiments,
enableExperiment: Function,
isMobile: boolean,
sendToGA: Function,
userAgent: string
}

export default class FeaturedExperiment extends React.Component {
props: FeaturedExperimentProps

export default class FeaturedExperiment extends Component<FeaturedExperimentProps> {
constructor(props: FeaturedExperimentProps) {
super(props);
}
@@ -1,6 +1,18 @@
@import '~photon-colors/colors.scss';
@import '~photon-colors/photon-colors.scss';
@import '../../../styles/_utils';

.mobile-button-wrap {
display: flex;

a:last-child {
margin-left: 20px;
}
}

.mobile-trigger {
width: 200px;
}

.featured-experiment {
@include respond-to('small') {
@include flex-container(column, center, stretch, nowrap);
@@ -214,8 +226,8 @@

img {
height: 24px;
width: auto;
padding-right: 10px;
width: auto;
}
}

@@ -266,6 +278,7 @@

border: 0;
margin: 0;
z-index: 2;

img {
height: 60px;
@@ -1,6 +1,6 @@
// @flow
import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";

import RetireConfirmationDialog from "../RetireConfirmationDialog";

@@ -16,10 +16,7 @@ type FooterState = {
showRetireDialog: boolean
}

export default class Footer extends React.Component {
props: FooterProps
state: FooterState

export default class Footer extends Component<FooterProps, FooterState> {
constructor(props: FooterProps) {
super(props);

@@ -1,4 +1,4 @@
@import '~photon-colors/colors.scss';
@import '~photon-colors/photon-colors.scss';
@import '../../../styles/_utils';

#main-footer {
@@ -75,12 +75,12 @@
}

&:first-child {
margin-left: 0px;
margin-left: 0;
}
}

&:first-child {
margin-left: 0px !important;
margin-left: 0 !important;
}

.github {
@@ -1,4 +1,4 @@
@import '~photon-colors/colors.scss';
@import '~photon-colors/photon-colors.scss';
@import '../../../styles/_utils';

.graduated-notice {
@@ -0,0 +1,59 @@
// @flow
import React, { Component } from "react";

type HeadProps = {
metaTitle: string,
metaDescription: string,
availableLocales: string,
canonicalPath: string,
imageFacebook: string,
imageTwitter: string
}

export default class Head extends Component<HeadProps> {

render() {
const {
metaTitle, metaDescription,
availableLocales, canonicalPath,
imageFacebook, imageTwitter
} = this.props;

const canonicalUrl = `https://testpilot.firefox.com/${canonicalPath}`;

return (
<head>
<meta charSet="utf-8" />
<link rel="shortcut icon" href="/static/images/favicon.ico" />
<link rel="stylesheet" href="https://code.cdn.mozilla.net/fonts/fira.css" />
<link rel="stylesheet" href="/static/styles/experiments.css" />
<link rel="stylesheet" href="/static/app/app.css" />

<meta name="defaultLanguage" content="en-US" />
<meta name="availableLanguages" content={availableLocales} />
<meta name="viewport" content="width=device-width" />

<link rel="alternate" type="application/atom+xml" href="/feed.atom" title="Atom Feed" />
<link rel="alternate" type="application/rss+xml" href="/feed.rss" title="RSS Feed" />
<link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed" />

<link rel="canonical" href={canonicalUrl} />

<title>{metaTitle}</title>

<meta property="og:type" content="website" />
<meta property="og:title" content={metaTitle} />
<meta name="twitter:title" content={metaTitle} />
<meta name="description" content={metaDescription} />
<meta property="og:description" content={metaDescription} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:card" content="summary" />
<meta property="og:image" content={imageFacebook} />
<meta name="twitter:image" content={imageTwitter} />
<meta property="og:url" content={canonicalUrl} />
</head>
);
}

}

@@ -0,0 +1,53 @@
/* global describe, beforeEach, it */
import React from "react";

import Head from ".";

import { expect } from "chai";
import { shallow } from "enzyme";

describe("app/components/Head", () => {
let subject, props;

beforeEach(() => {
props = {
metaTitle: "Firefox Test Pilot",
metaDescription: "Test new Features. Give us feedback. Help build Firefox.",

imageFacebook: "/static/images/thumbnail-facebook.png",
imageTwitter: "/static/images/thumbnail-twitter.png",

availableLocales: "en-US,fr-CA",
canonicalPath: "canonical/path"
};

subject = shallow(<Head {...props} />);
});

it("should contain the correct page title", () => {
expect(subject.find("title").text()).equals(props.metaTitle);
});

it("should contain the correct available locales", () => {
expect(subject.find(`meta[name="availableLanguages"]`).props().content).equals(props.availableLocales);
});

it("should contain the correct canonical URL", () => {
expect(subject.find(`link[rel="canonical"]`).props().href).match(new RegExp(`${props.canonicalPath}$`));
});

it("should contain correct Twitter metadata", () => {
expect(subject.find(`meta[name="twitter:title"]`).props().content).equals(props.metaTitle);
expect(subject.find(`meta[name="twitter:description"]`).props().content).equals(props.metaDescription);
expect(subject.find(`meta[name="twitter:image"]`).props().content).equals(props.imageTwitter);
expect(subject.find(`meta[name="twitter:card"]`).props().content).equals("summary");
});

it("should contain correct Facebook metadata", () => {
expect(subject.find(`meta[property="og:type"]`).props().content).equals("website");
expect(subject.find(`meta[property="og:title"]`).props().content).equals(props.metaTitle);
expect(subject.find(`meta[property="og:description"]`).props().content).equals(props.metaDescription);
expect(subject.find(`meta[property="og:image"]`).props().content).equals(props.imageFacebook);
expect(subject.find(`meta[property="og:url"]`).props().content).to.match(new RegExp(`${props.canonicalPath}$`));
});
});
@@ -1,7 +1,7 @@
// @flow

import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";
import { Link } from "react-router-dom";

import LayoutWrapper from "../LayoutWrapper";
@@ -22,9 +22,7 @@ type HeaderState = {
showSettings: boolean
}

export default class Header extends React.Component {
props: HeaderProps
state: HeaderState
export default class Header extends Component<HeaderProps, HeaderState> {
closeTimer: any

constructor(props: HeaderProps) {
@@ -1,4 +1,4 @@
@import '~photon-colors/colors.scss';
@import '~photon-colors/photon-colors.scss';
@import '../../../styles/_utils';

#main-header {
@@ -1,136 +1,29 @@

import { Localized } from "fluent-react/compat";
import {Children, cloneElement} from "react";
import { withLocalization } from "fluent-react/compat";
import parser from "html-react-parser";
import domToReact from "html-react-parser/lib/dom-to-react";
import React from "react";

function recurseChildrenAndFindAnchors(found, node) {
/*
When anchors are present in a LocalizedHtml instance,
all anchors in the ftl should have their attributes
overwritten by the attributes in the corresponding
anchors in the jsx. This prevents localizers from
hijacking anchors.
const MISSING_TRANSLATION = Symbol();

// Retrieve the translation for the id, parse it with html-react-parser, and
// render the wrapped element using the parsed result as its children.
function LocalizedHtml({children, id, getString}) {
const wrappedElement = Children.only(children);

To support this, this function simply performs a
depth-first traversal of the children of the React
element `node` and pushes any `a` tags into the
`found` Array.
*/
if (typeof node === "string" || typeof node.props === "undefined") {
return;
// By default, when the translation is missing, getString returns the
// identifier as fallback. Instead, pass a custom symbol as the fallback to be
// used and return the wrapped element without any modifications. This is also
// used in tests.
const translation = getString(id, null, MISSING_TRANSLATION);
if (translation === MISSING_TRANSLATION) {
return wrappedElement;
}
React.Children.forEach(node.props.children, child => {
if (child.type === "a") {
found.push(child);
} else {
recurseChildrenAndFindAnchors(found, child);
}

return cloneElement(wrappedElement, {
// The parsed result may be a single React element or an array of elements.
// Pass it explicitly as the children prop (rather than as ...children) to
// handle this ambiguity.
children: parser(translation)
});
}


export default class LocalizedHtml extends Localized {
render() {
const templates = {
anchors: [],
insertIndex: 0,
readIndex: 0
};
recurseChildrenAndFindAnchors(templates.anchors, this.props.children);

/*
Localized.render returns one of two things: if the
ftl string being localized contains ${template}
placeholders, it will return an element whose children
are strings and object instances representing the
placeholders. If the ftl string being localized does
not contain template placeholders, it will return an
element with a string children prop.
*/
const result = super.render();
/*
If we are being used in the tests, just return the
placeholder content which Localized returns in this
case.
*/
if (typeof this.context.l10n === "undefined") {
return result;
}

let joined;
if (typeof result.props.children === "string") {
/*
If children is a string, there are
no template substitutions to make. Simply
prepare for parsing the ftl element as html.
*/
joined = result.props.children;
} else {
/*
Otherwise, join together result.props.children,
replacing template elements with the sentinel
html <span>///</span> and storing the original
template elements in `templates`.
*/
const mapped = React.Children.map(result.props.children, child => {
if (typeof child === "string") {
return child;
}
templates.insertIndex++;
templates[templates.insertIndex] = child;
return "<span>///</span>";
});

if (mapped === null) {
joined = "";
} else {
joined = mapped.join("");
}
}

/*
Now that we have replaced any template nodes with
<span>///</span> and produced one html string from
the ftl string, we can use html-react-parser to parse
the string into React elements. html-react-parser
takes an options object which has a replace method
which takes a node (in htmlparser2.parseDOM format)
and returns React elements. If `node` is
<span>///</span>, we return the template node
stored earlier. If `node` is an anchor, we copy
all the attributes from the jsx anchor to the one
parsed from the ftl.
*/
const options = {
replace: node => {
// node has the same structure as htmlparser2.parseDOM
// https://github.com/fb55/domhandler#example
if (node.type === "tag") {
if (node.name === "span" &&
node.children && node.children.length === 1 &&
node.children[0].data === "///") {
templates.readIndex++;
return templates[templates.readIndex];
} else if (node.name === "a") {
if (templates.anchors.length) {
const anchor = templates.anchors.shift();
return React.cloneElement(
anchor,
{
children: domToReact(node.children, options),
...anchor.props
}
);
}
throw new Error(`ftl string "${this.props.id}" did not have as many anchors as the jsx`);
}
}
return undefined;
}
};

const parsed = parser(joined, options);
return React.cloneElement(result, { children: parsed });
}
}
export default withLocalization(LocalizedHtml);
@@ -1,23 +1,23 @@
// @flow

import classnames from "classnames";
import { Localized } from "fluent-react/compat";
import React from "react";
import React, { Component } from "react";

import LayoutWrapper from "../LayoutWrapper";
import LocalizedHtml from "../LocalizedHtml";

import "./index.scss";

import config from "../../config";

import type { MainInstallButtonProps } from "../types";

import {
WebExperimentButton
} from "../../containers/ExperimentPage/ExperimentButtons";

type MainInstallButtonState = { isInstalling: boolean };

export default class MainInstallButton extends React.Component {
props: MainInstallButtonProps;
state: MainInstallButtonState;
export default class MainInstallButton extends Component<MainInstallButtonProps, MainInstallButtonState> {

constructor(props: MainInstallButtonProps) {
super(props);
@@ -36,8 +36,7 @@ export default class MainInstallButton extends React.Component {
eventLabel, experiment, experimentTitle, isFeatured,
installed, hasAddon, enableExperiment } = this.props;

if (isFeatured) {
const { slug } = experiment;
if (isFeatured && experiment && experiment.slug) {
sendToGA("event", {
eventCategory,
eventAction: "button click",
@@ -47,7 +46,7 @@ export default class MainInstallButton extends React.Component {
dimension3: installed ? Object.keys(installed).length : 0,
dimension4: false, // enabled?
dimension5: experimentTitle,
dimension11: slug,
dimension11: experiment.slug,
dimension13: "Featured Experiment"
});
}
@@ -61,51 +60,80 @@ export default class MainInstallButton extends React.Component {
install.then(after, after);
}

render() {
const { isFirefox, isMinFirefox, isMobile, hasAddon, experimentTitle, experimentLegalLink, experiment } = this.props;
const isInstalling = this.state.isInstalling || (experiment && experiment.inProgress);
renderWebExperimentButton() {
const { sendToGA, experiment } = this.props;
if (!experiment) return;
const { title, slug, web_url } = experiment;
// eslint-disable-next-line consistent-return
return (
<WebExperimentButton {...{
web_url,
title,
slug,
sendToGA,
color: "default main-install__button"
}} />
);
}

const terms = <Localized id="landingLegalNoticeTermsOfUse">
<a href="/terms"/>
</Localized>;
const privacy = <Localized id="landingLegalNoticePrivacyNotice">
<a href="/privacy"/>
</Localized>;
renderMainButton() {
const { isFirefox, isMinFirefox, isMobile, hasAddon, experiment } = this.props;

const isInstalling = this.state.isInstalling || (!!experiment && experiment.inProgress);
const showWebButton = (!!experiment && experiment.platforms.includes("web") && experiment.platforms.length === 1);

if (showWebButton) {
return this.renderWebExperimentButton();
} else if (isMinFirefox && !isMobile) {
return this.renderInstallButton(isInstalling, hasAddon);
}

return this.renderAltButton(isFirefox, isMobile);
}

render() {
const { isMinFirefox, isMobile, experimentTitle, experimentLegalLink, experiment } = this.props;
const showWebButton = (experiment && experiment.platforms.includes("web") && experiment.platforms.length === 1);
const layout = experimentTitle ? "column-center-start-breaking" : "column-center";

return (
<LayoutWrapper flexModifier={layout} helperClass="main-install">
<div className="main-install__spacer" />
{(isMinFirefox && !isMobile) ? this.renderInstallButton(isInstalling, hasAddon) : this.renderAltButton(isFirefox, isMobile) }
{isMinFirefox && !isMobile && !experimentLegalLink && <LocalizedHtml id="landingLegalNotice" $terms={terms} $privacy={privacy}>

{this.renderMainButton()}

{isMinFirefox && !isMobile && !experimentLegalLink && <Localized id="landingLegalNoticeWithLinks"
terms-link={<a href="/terms"></a>}
privacy-link={<a href="/privacy"></a>}>
<p className="main-install__legal">
By proceeding, you agree to the {terms} and {privacy} of Test Pilot.
By proceeding, you agree to the <terms-link>Terms of Use</terms-link> and{" "}
<privacy-link>Privacy Notice</privacy-link> of Test Pilot.
</p>
</LocalizedHtml>}
</Localized>}

{isMinFirefox && !isMobile && experimentLegalLink && experimentLegalLink}
{!showWebButton && isMinFirefox && !isMobile && experimentLegalLink && experimentLegalLink}
</LayoutWrapper>
);
}

renderEnableExperimentButton(title: string) {
return (
<div className="main-install__enable">
<LocalizedHtml id="oneClickInstallMajorCta" $title={title}>
<span className="main-install__minor-cta">Enable {title}</span>
</LocalizedHtml>
<Localized id="enableExperiment" $title={title}>
<span className="default-text">Enable {title}</span>
</Localized>
</div>
);
}

renderOneClickInstallButton(title: string) {
return (
<div className="main-install__one-click">
<LocalizedHtml id="oneClickInstallMinorCta">
<Localized id="oneClickInstallMinorCta">
<span className="main-install__minor-cta">Install Test Pilot &amp;</span>
</LocalizedHtml>
<Localized id="oneClickInstallMajorCta" $title={title}>
<span className="main-install__major-cta">Enable {title}</span>
</Localized>
<Localized id="enableExperiment" $title={title}>
<span className="default-text">Enable {title}</span>
</Localized>
</div>
);
@@ -125,7 +153,7 @@ export default class MainInstallButton extends React.Component {
<span className="progress-btn-msg">Enabling...</span>
</Localized>);

if (experimentTitle) {
if (experimentTitle && experiment) {
const enabled = isExperimentEnabled(experiment);
if (hasAddon && !enabled) {
installButton = this.renderEnableExperimentButton(experimentTitle);
@@ -66,6 +66,18 @@
user-select: none;
width: 280px;

&:hover .main-install__badge {
animation: bounce 0.4s infinite alternate;
@keyframes bounce {
from {
transform: translateY(0px);
}
to {
transform: translateY(3px);
}
}
}

.layout-wrapper--column-center & {
margin-left: auto;
margin-right: auto;
@@ -84,7 +96,6 @@
padding: 14px;
position: absolute;
right: 2px;
transition-duration: 150ms;
width: 16px;
}

@@ -11,7 +11,6 @@ describe("app/components/MainInstallButton", () => {
let subject, props;
beforeEach(() => {
props = {
restartRequired: false,
sendToGA: sinon.spy(),
eventCategory: "test",
hasAddon: false,
@@ -69,7 +68,7 @@ describe("app/components/MainInstallButton", () => {
});

it("fires sendToGA when install is called", () => {
const mockExperiment = {slug: "testing", title: "Testing Experiment"};
const mockExperiment = {slug: "testing", title: "Testing Experiment", platforms: ["addon"]};
subject.setProps({
isFeatured: true,
experiment: mockExperiment,
@@ -3,7 +3,6 @@
import React from "react";
import classnames from "classnames";
import { Localized } from "fluent-react/compat";
import LocalizedHtml from "../../components/LocalizedHtml";

type MeasurementsSectionType = {
experiment: Object,
@@ -23,12 +22,6 @@ export default function MeasurementsSection({
highlightMeasurementPanel,
l10nId
}: MeasurementsSectionType) {
const privacy = (
<Localized id="experimentMeasurementIntroPrivacyLink">
<a target="_blank" rel="noopener noreferrer" href="/privacy" />
</Localized>
);

return (
<section
className={classnames("measurements", {
@@ -47,27 +40,25 @@ export default function MeasurementsSection({
</Localized>}
{measurements &&
<div>
<LocalizedHtml
<Localized
id="experimentMeasurementIntro"
$experimentTitle={title}
$privacy={privacy}
>
a={<a target="_blank" rel="noopener noreferrer" href="/privacy" />}>
<p>
In addition to the {privacy} collected by all Test Pilot
In addition to the <a>data</a> collected by all Test Pilot
experiments, here are the key things you should know about what is
happening when you use {title}:
</p>
</LocalizedHtml>
</Localized>

<ul>
{measurements.map((note, idx) =>
<LocalizedHtml key={idx} id={l10nId(["measurements", idx])}>
<li>
{EXPERIMENT_MEASUREMENT_URLS[idx] === null
? null
: <a href={EXPERIMENT_MEASUREMENT_URLS[idx]} />}
</li>
</LocalizedHtml>
<Localized key={idx} id={l10nId(["measurements", idx])}
a={EXPERIMENT_MEASUREMENT_URLS[idx] === null
? null
: <a href={EXPERIMENT_MEASUREMENT_URLS[idx]} />}>
<li></li>
</Localized>
)}
</ul>
</div>}