diff --git a/app/scripts/templates/partial/password-repeat-balloon.mustache b/app/scripts/templates/partial/password-repeat-balloon.mustache
new file mode 100644
index 0000000000..dd9534f831
--- /dev/null
+++ b/app/scripts/templates/partial/password-repeat-balloon.mustache
@@ -0,0 +1,5 @@
+
diff --git a/app/scripts/templates/partial/password-strength-balloon.mustache b/app/scripts/templates/partial/password-strength-balloon.mustache
index 86bb600c20..5ee023ee50 100644
--- a/app/scripts/templates/partial/password-strength-balloon.mustache
+++ b/app/scripts/templates/partial/password-strength-balloon.mustache
@@ -1,6 +1,6 @@
diff --git a/app/scripts/templates/sign_up_password.mustache b/app/scripts/templates/sign_up_password.mustache
index 4acb4936f5..2e5490c51b 100644
--- a/app/scripts/templates/sign_up_password.mustache
+++ b/app/scripts/templates/sign_up_password.mustache
@@ -26,6 +26,7 @@
{{{ coppaHTML }}}
diff --git a/app/scripts/views/mixins/password-mixin.js b/app/scripts/views/mixins/password-mixin.js
index 4a16feea69..479ebdc505 100644
--- a/app/scripts/views/mixins/password-mixin.js
+++ b/app/scripts/views/mixins/password-mixin.js
@@ -199,12 +199,12 @@ module.exports = {
},
showPasswordHelper () {
- this.$('.input-help:not(.password-strength-balloon)').css('opacity', '1');
+ this.$('.input-help:not(.password-strength-balloon,.password-repeat-balloon)').css('opacity', '1');
},
hidePasswordHelper () {
// Hide all input-help classes except input-help-forgot-pw
- this.$('.input-help:not(.input-help-forgot-pw,.password-strength-balloon)').css('opacity', '0');
+ this.$('.input-help:not(.input-help-forgot-pw,.password-strength-balloon,.password-repeat-balloon)').css('opacity', '0');
},
/**
diff --git a/app/scripts/views/mixins/password-repeat-mixin.js b/app/scripts/views/mixins/password-repeat-mixin.js
new file mode 100644
index 0000000000..7779433908
--- /dev/null
+++ b/app/scripts/views/mixins/password-repeat-mixin.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import PasswordWithRepeatBalloonView from '../password_repeat/password_with_repeat_balloon';
+
+/**
+ * Create the mixin to set up the password repeat UI.
+ *
+ * @param {Object} config
+ * @param {String} config.balloonEl selector where the password repeat balloon should attach
+ * @param {String} config.passwordEl selector for the password element to watch
+ * @returns {Object} the mixin
+ */
+export default function (config = {}) {
+ const { balloonEl, passwordEl } = config;
+
+ return {
+ afterRender () {
+ return Promise.resolve().then(() => {
+ if (! this.$(passwordEl).length) {
+ return;
+ }
+ const passwordView = this._createPasswordWithRepeatBalloonView();
+ this.trackChildView(passwordView);
+ });
+ },
+
+ _createPasswordWithRepeatBalloonView () {
+ return new PasswordWithRepeatBalloonView({
+ balloonEl: this.$(balloonEl),
+ el: this.$(passwordEl),
+ lang: this.lang,
+ translator: this.translator
+ });
+ }
+ };
+}
diff --git a/app/scripts/views/password_repeat/password_repeat_balloon.js b/app/scripts/views/password_repeat/password_repeat_balloon.js
new file mode 100644
index 0000000000..91b1ca0636
--- /dev/null
+++ b/app/scripts/views/password_repeat/password_repeat_balloon.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Display a password strength balloon. Component automatically
+ * updates whenever changes are made to the underlying model.
+ *
+ * @export
+ * @class PasswordRepeatBalloonView
+ * @extends {BaseView}
+ */
+
+import BaseView from '../base';
+import Cocktail from 'cocktail';
+import OneVisibleOfTypeMixin from '../mixins/one-visible-of-type-mixin';
+import Template from '../../templates/partial/password-repeat-balloon.mustache';
+
+// Allow the balloon to stay visible for a bit so that
+// the user can see all the criteria were met.
+const DELAY_BEFORE_HIDE_MS = 750;
+
+const DELAY_BEFORE_HIDE_BALLOON_EL_MS = 500;
+
+const PASSWORD_REPEAT_BALLOON_SELECTOR = '.password-repeat-balloon';
+
+class PasswordRepeatBalloonView extends BaseView {
+ template = Template;
+
+ initialize (config = {}) {
+ this.delayBeforeHideMS = config.delayBeforeHideMS || DELAY_BEFORE_HIDE_MS;
+ }
+
+ afterRender () {
+ this.show();
+ }
+
+ update () {
+ this.clearTimeouts();
+ return this.render()
+ .then(() => this.hideAfterDelay());
+ }
+
+ clearTimeouts () {
+ this.clearTimeout(this._hideTimeout);
+ this.clearTimeout(this._hideBalloonElTimeout);
+ }
+
+ show () {
+ this.$(PASSWORD_REPEAT_BALLOON_SELECTOR).show().css('opacity', '1');
+ }
+
+ hide () {
+ const $balloonEl = this.$(PASSWORD_REPEAT_BALLOON_SELECTOR);
+ $balloonEl.css('opacity', '0');
+ this._hideBalloonElTimeout = this.setTimeout(() => {
+ $balloonEl.hide();
+ }, DELAY_BEFORE_HIDE_BALLOON_EL_MS);
+ }
+
+ hideAfterDelay () {
+ this._hideTimeout = this.setTimeout(() => {
+ this.hide();
+ }, this.delayBeforeHideMS);
+ }
+}
+
+Cocktail.mixin(
+ PasswordRepeatBalloonView,
+ OneVisibleOfTypeMixin({
+ hideMethod: 'hide',
+ showMethod: 'show',
+ viewType: 'tooltip'
+ })
+);
+
+export default PasswordRepeatBalloonView;
diff --git a/app/scripts/views/password_repeat/password_with_repeat_balloon.js b/app/scripts/views/password_repeat/password_with_repeat_balloon.js
new file mode 100644
index 0000000000..89131b78f6
--- /dev/null
+++ b/app/scripts/views/password_repeat/password_with_repeat_balloon.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Creates and manages a PasswordRepeatBalloon.
+ * Updates to the bound password element cause updates to the model
+ * which are propagated out to the PasswordRepeatBalloon to
+ * update the UI.
+ *
+ * @export
+ * @class PasswordWithRepeatBalloonView
+ * @extends {FormView}
+ */
+
+import FormView from '../form';
+import PasswordRepeatBalloonView from './password_repeat_balloon';
+
+const PasswordWithRepeatBalloonView = FormView.extend({
+ events: {
+ blur: 'hideBalloon',
+ focus: 'showBalloon',
+ },
+
+ initialize (options = {}) {
+ this.passwordHelperBalloon = options.passwordHelperBalloon;
+ this.balloonEl = options.balloonEl;
+ },
+
+ showBalloon () {
+ if (! this.passwordHelperBalloon) {
+ this.passwordHelperBalloon = new PasswordRepeatBalloonView({
+ el: this.balloonEl,
+ lang: this.lang,
+ model: this.model,
+ translator: this.translator
+ });
+ this.trackChildView(this.passwordHelperBalloon);
+ }
+ return this.passwordHelperBalloon.render()
+ .then(() => this.passwordHelperBalloon.show());
+ },
+
+ hideBalloon () {
+ if (this.passwordHelperBalloon) {
+ return this.passwordHelperBalloon.hide();
+ }
+ }
+});
+
+export default PasswordWithRepeatBalloonView;
diff --git a/app/scripts/views/password_strength/password_with_strength_balloon.js b/app/scripts/views/password_strength/password_with_strength_balloon.js
index 916c6222b1..f40e90415a 100644
--- a/app/scripts/views/password_strength/password_with_strength_balloon.js
+++ b/app/scripts/views/password_strength/password_with_strength_balloon.js
@@ -22,6 +22,7 @@ const DELAY_BEFORE_UPDATE_MODEL_MS = 1000;
const PasswordWithStrengthBalloonView = FormView.extend({
events: {
+ blur: 'hideBalloon',
change: 'updateModelAfterDelay',
focus: 'createBalloonIfNeeded',
keypress: 'updateModelAfterDelay',
@@ -44,13 +45,14 @@ const PasswordWithStrengthBalloonView = FormView.extend({
this.updateModelAfterDelay = debounce(() => this.updateModel(), delayBeforeUpdateModelMS);
},
- createBalloonIfNeeded () {
+ createBalloonIfNeeded (ev) {
// The balloon is created as soon as the user focuses the input element
// and the password is missing or invalid, or as soon as the model
// becomes invalid.
if (this.shouldCreateBalloon()) {
this.createBalloon();
}
+ this.showBalloon();
},
shouldCreateBalloon () {
@@ -97,6 +99,18 @@ const PasswordWithStrengthBalloonView = FormView.extend({
});
},
+ showBalloon () {
+ if (this.passwordHelperBalloon) {
+ this.passwordHelperBalloon.show();
+ }
+ },
+
+ hideBalloon () {
+ if (this.passwordHelperBalloon) {
+ this.passwordHelperBalloon.hide();
+ }
+ },
+
/**
* Updates the model after some sort of user action.
*/
diff --git a/app/scripts/views/sign_up_password.js b/app/scripts/views/sign_up_password.js
index 92cf8811f0..53820d716d 100644
--- a/app/scripts/views/sign_up_password.js
+++ b/app/scripts/views/sign_up_password.js
@@ -12,6 +12,7 @@ import FormPrefillMixin from './mixins/form-prefill-mixin';
import FormView from './form';
import PasswordMixin from './mixins/password-mixin';
import PasswordStrengthMixin from './mixins/password-strength-mixin';
+import PasswordRepeatMixin from './mixins/password-repeat-mixin';
import { preventDefaultThen } from './base';
import ServiceMixin from './mixins/service-mixin';
import SignUpMixin from './mixins/signup-mixin';
@@ -95,8 +96,12 @@ Cocktail.mixin(
FlowEventsMixin,
FormPrefillMixin,
PasswordMixin,
+ PasswordRepeatMixin({
+ balloonEl: '#vpassword + .helper-balloon',
+ passwordEl: '#vpassword'
+ }),
PasswordStrengthMixin({
- balloonEl: '.helper-balloon',
+ balloonEl: '#password + .helper-balloon',
passwordEl: '#password'
}),
ServiceMixin,
diff --git a/app/styles/modules/_password-row.scss b/app/styles/modules/_password-row.scss
index 0fd046ae66..9029899050 100644
--- a/app/styles/modules/_password-row.scss
+++ b/app/styles/modules/_password-row.scss
@@ -175,12 +175,13 @@
}
+ .password-repeat-balloon,
.password-strength-balloon {
font-size: 14px;
font-weight: 400;
line-height: 1.3;
opacity: 1;
- padding: 28px 14px;
+ padding: 21px 14px;
z-index: 5;
// The characters in Arabic look way too small at 14px and are difficult to read. Bump them up by 1
@@ -256,6 +257,10 @@
background-image: url('/images/icon-lock-grey-50.svg');
text-indent: -9999px;
}
+
+ &.lock.last {
+ padding-bottom: 7px;
+ }
}
.big-only {
diff --git a/app/styles/modules/_settings.scss b/app/styles/modules/_settings.scss
index 48b10ed917..0508c7a4e8 100644
--- a/app/styles/modules/_settings.scss
+++ b/app/styles/modules/_settings.scss
@@ -113,7 +113,7 @@ body.settings #main-content.card {
z-index: 2;
@include respond-to('big') {
- margin: 0px;
+ margin: 0;
position: absolute;
top: 64px;
width: 100%;
diff --git a/tests/functional.js b/tests/functional.js
index 08bb7bf395..6ce183e520 100644
--- a/tests/functional.js
+++ b/tests/functional.js
@@ -59,6 +59,7 @@ module.exports = [
'tests/functional/pages.js',
'tests/functional/back_button_after_start.js',
'tests/functional/cookies_disabled.js',
+ 'tests/functional/password_repeat.js',
'tests/functional/password_strength.js',
'tests/functional/password_visibility.js',
'tests/functional/avatar.js',
diff --git a/tests/functional/lib/selectors.js b/tests/functional/lib/selectors.js
index 3abe9aca45..6fa584639f 100644
--- a/tests/functional/lib/selectors.js
+++ b/tests/functional/lib/selectors.js
@@ -13,6 +13,10 @@ const PASSWORD_BALLOON = {
NOT_EMAIL_UNMET: '.not-email.unmet',
};
+const VPASSWORD_BALLOON = {
+ BALLOON: '.password-repeat-balloon'
+};
+
/*eslint-disable max-len*/
module.exports = {
'123DONE': {
@@ -303,6 +307,7 @@ module.exports = {
SHOW_VPASSWORD: '#vpassword ~ .show-password-label',
SUBMIT: 'button[type="submit"]',
VPASSWORD: '#vpassword',
+ VPASSWORD_BALLOON,
},
SMS_LEARN_MORE: {
HEADER: '#websites-notice'
diff --git a/tests/functional/password_repeat.js b/tests/functional/password_repeat.js
new file mode 100644
index 0000000000..903ffe6e1b
--- /dev/null
+++ b/tests/functional/password_repeat.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+'use strict';
+
+const { registerSuite } = intern.getInterface('object');
+const assert = intern.getPlugin('chai').assert;
+const TestHelpers = require('../lib/helpers');
+const FunctionalHelpers = require('./lib/helpers');
+const selectors = require('./lib/selectors');
+
+const config = intern._config;
+const PAGE_URL = `${config.fxaContentRoot}?context=fx_firstrun_v2&service=sync&action=email`; //eslint-disable-line max-len
+
+let email;
+
+const {
+ clearBrowserState,
+ click,
+ openPage,
+ testElementExists,
+ type,
+} = FunctionalHelpers;
+
+registerSuite('password repeat balloon', {
+ beforeEach: function () {
+ email = TestHelpers.createEmail('sync{id}');
+
+ return this.remote
+ .then(clearBrowserState({ force: true }))
+ .then(openPage(PAGE_URL, selectors.ENTER_EMAIL.HEADER, {
+ webChannelResponses: {
+ 'fxaccounts:can_link_account': {ok: true}
+ }
+ }))
+ .then(type(selectors.ENTER_EMAIL.EMAIL, email))
+ .then(click(selectors.ENTER_EMAIL.SUBMIT, selectors.SIGNUP_PASSWORD.HEADER));
+ },
+
+ tests: {
+ 'appears on repeat field focused': function () {
+ return this.remote
+ .then(click(selectors.SIGNUP_PASSWORD.PASSWORD))
+ .then(click(selectors.SIGNUP_PASSWORD.VPASSWORD))
+ .then(testElementExists(selectors.SIGNUP_PASSWORD.VPASSWORD_BALLOON.BALLOON))
+ .findByCssSelector(selectors.SIGNUP_PASSWORD.VPASSWORD_BALLOON.BALLOON)
+ .then(el => el.getAttribute('style'))
+ .then(style => assert.include(style, 'opacity: 1'));
+ },
+ 'disappears on repeat field blur': function () {
+ return this.remote
+ .then(click(selectors.SIGNUP_PASSWORD.VPASSWORD))
+ .then(click(selectors.SIGNUP_PASSWORD.AGE))
+ .then(testElementExists(selectors.SIGNUP_PASSWORD.VPASSWORD_BALLOON.BALLOON))
+ .findByCssSelector(selectors.SIGNUP_PASSWORD.VPASSWORD_BALLOON.BALLOON)
+ .then(el => el.getAttribute('style'))
+ .then(style => assert.include(style, 'opacity: 0'));
+ },
+ }
+});
+