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