packages/fxa-content-server/app/scripts/views/form.js (286 lines of code) (raw):
/* 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/. */
/**
* Generic module to use if a view is a form. This module provides a common
* way to do form validation and invalid element reporting. Descendent modules
* can provide strategies for the following functions:
* - isValidStart (optional)
* - isValidEnd (optional)
* - showValidationErrorsStart (optional)
* - showValidationErrorsEnd (optional)
* - beforeSubmit (optional)
* - submit (required)
* - afterSubmit (optional)
*
* See documentation for an explanation of each.
*/
import './elements/jquery-plugin';
import _ from 'underscore';
import allowOnlyOneSubmit from './decorators/allow_only_one_submit';
import AuthErrors from '../lib/auth-errors';
import BaseView from './base';
import cancelEventThen from './decorators/cancel_event_then';
import Duration from 'duration';
import notifyDelayedRequest from './decorators/notify_delayed_request';
import p from '../lib/promise';
import preventDefaultThen from './decorators/prevent_default_then';
import showButtonProgressIndicator from './decorators/progress_indicator';
import Tooltip from './tooltip';
/**
* Decorator that checks whether the form has changed, and if so, call
* the specified handler.
* Called if `keypress` or `change` is fired on the form.
*
* @param {Function} handler
* @returns {Function}
*/
function ifFormValuesChanged(handler) {
return function () {
if (this.updateFormValueChanges()) {
return this.invokeHandler(handler, arguments);
}
};
}
const proto = BaseView.prototype;
var FormView = BaseView.extend({
// Time to wait for a request to finish before showing a notice
LONGER_THAN_EXPECTED: new Duration('10s').milliseconds(),
constructor: function (options) {
BaseView.call(this, options);
this._attachEvents();
},
events: {
'change form': ifFormValuesChanged(cancelEventThen('onFormChange')),
'input form': ifFormValuesChanged(cancelEventThen('onFormChange')),
'keyup form': ifFormValuesChanged(cancelEventThen('onFormChange')),
'submit form': preventDefaultThen('onFormSubmit'),
},
_notifiedOfEngaged: false,
onFormChange() {
// the change event can be called after the form is already
// submitted if the user presses "enter" in the form. If the
// form is in the midst of being submitted, bail out now.
if (this.isSubmitting() || this.isHalted()) {
return;
}
// hide success and error messages after user changes the form
this.hideError();
this.hideSuccess();
if (!this._notifiedOfEngaged) {
this._notifiedOfEngaged = true;
this.notifier.trigger('form.engaged');
}
},
afterRender() {
this._attachEvents();
// Firefox has a strange issue where if the previous
// screen was submit using the keyboard, the `enter` key's
// `keyup` event fires here on the element that receives
// focus. Without seeding the initial form values, any
// errors passed from the previous screen are immediately
// hidden.
this.updateFormValueChanges();
return proto.afterRender.call(this);
},
/**
* Get the current form values. Does not fetch the value of elements with
* the `data-novalue` attribute.
*
* @method getFormValues
* @returns {Object}
*/
getFormValues() {
var values = {};
this.getFormElements().each((index, el) => {
const $el = this.$(el);
// If the element has neither a name nor id, use the index as key
const key = $el.attr('name') || $el.attr('id') || index;
values[key] = this.getElementValue($el);
});
return values;
},
/**
* Get a list of form elements that do not have the `data-novalue` attribute.
*
* @method getFormElements
* @returns {Object}
*/
getFormElements() {
return this.$('input,textarea,select').filter((index, el) => {
const $el = this.$(el);
// elements that have data-novalue (like password show fields)
// are not added to the values.
return _.isUndefined($el.attr('data-novalue'));
});
},
/**
* Check if the form is enabled
*
* @returns {Boolean}
*/
isFormEnabled() {
const $submitEl = this.$('button[type=submit]');
return !$submitEl.hasClass('disabled') && !$submitEl.attr('disabled');
},
onFormSubmit() {
if (this.skipBaseOnFormSubmit) {
return;
}
return (
this.validateAndSubmit()
// drop the error on the ground, it'll already be logged.
.catch((err) => {})
);
},
/**
* Validate and if valid, submit the form.
*
* If the form is valid, three functions are run in series using
* a promise chain: beforeSubmit, submit, and afterSubmit.
*
* By default, beforeSubmit and afterSubmit are used to prevent
* multiple concurrent form submissions. The form is disbled in
* beforeSubmit, and if no error is displayed, the form is re-enabled
* in afterSubmit. This behavior can be overridden in subclasses.
*
* Form submission is prevented if beforeSubmit resolves to false.
*
* Functions can return a promise to allow for asynchronous operations.
*
* If a function throws an error or returns a rejected promise,
* displayError will display the error to the user.
*
* @method validateAndSubmit
* @param {Object} [event]
* @param {Object} [options]
* @param {Object} [options.artificialDelay={}]
* Minimum artificial delay for the submit to resolve, useful for loading indicators
* @return {Promise}
*/
validateAndSubmit: allowOnlyOneSubmit(function validateAndSubmit(
event,
options = {}
) {
const startTime = Date.now();
const artificialDelay = options.artificialDelay || 0;
if (event) {
event.stopImmediatePropagation();
}
this.trigger('submitStart');
return Promise.resolve()
.then(() => {
if (this.isHalted()) {
return;
}
if (!this.isValid()) {
// Validation error is surfaced for testing.
throw this.showValidationErrors();
}
// the form enabled check is done after the validation check
// so that the form's `submit` handler is triggered and validation
// error tooltips are displayed, even if the form is disabled.
if (!this.isFormEnabled()) {
// form is disabled, get outta here.
return;
}
// all good, do the beforeSubmit, submit, and afterSubmit chain.
this.logViewEvent('submit');
return this._submitForm()
.then(() => {
const diff = Date.now() - startTime;
const extraDelayTimeMS = Math.max(artificialDelay - diff, 0);
return p.delay(extraDelayTimeMS);
})
.then(() => {
return this.afterSubmit();
});
})
.finally(() => {
this.trigger('submitEnd');
});
}),
_submitForm: notifyDelayedRequest(
showButtonProgressIndicator(function () {
return Promise.resolve()
.then(_.bind(this.beforeSubmit, this))
.then((shouldSubmit) => {
// submission is opt out, not opt in.
if (shouldSubmit !== false) {
return this.submit();
}
})
.catch((err) => {
// display error and surface for testing.
this.displayError(err);
throw err;
});
})
),
/**
* Attaches events of the descendent view and the newly created view.
* It's called on view creation and afterRender for nested views to handle both sources of events.
* @private
*/
_attachEvents() {
this.delegateEvents(_.extend({}, FormView.prototype.events, this.events));
},
/**
* Checks whether the form is valid. Checks the validitity of each
* form element. If any elements are invalid, returns false.
*
* No errors are displayed.
*
* Descendent views can override isValidStart or isValidEnd to perform
* view specific checks.
*
* @returns {Boolean}
*/
isValid() {
if (!this.isValidStart()) {
return false;
}
const inputEls = this.$('input');
for (var i = 0, length = inputEls.length; i < length; ++i) {
var $el = this.$(inputEls[i]);
try {
$el.validate();
} catch (e) {
return false;
}
}
return this.isValidEnd();
},
/**
* Check form for validity. isValidStart is run before
* input elements are checked. Descendent views only need to
* override to do any form specific checks that cannot be
* handled by the generic handlers.
*
* @return {Boolean} true if form is valid, false otw.
*/
isValidStart() {
return true;
},
/**
* Check form for validity. isValidEnd is run after
* input elements are checked. Descendent views only need to
* override to do any form specific checks that cannot be
* handled by the generic handlers.
*
* @return {Boolean} true if form is valid, false otw.
*/
isValidEnd() {
return true;
},
/**
* Display form validation errors.
*
* Descendent views can override showValidationErrorsStart
* or showValidationErrorsEnd to display view specific messages.
*
* @returns {undefined}
*/
showValidationErrors() {
this.hideError();
if (this.showValidationErrorsStart()) {
// only one message at a time.
return;
}
const inputEls = this.$('input');
for (var i = 0, length = inputEls.length; i < length; ++i) {
const el = inputEls[i];
const $el = this.$(el);
try {
$el.validate();
} catch (validationError) {
this.showValidationError(el, validationError);
// only one message at a time.
return;
}
}
this.showValidationErrorsEnd();
},
/**
* Get an element value, trimming the value of whitespace if necessary
*
* @param {String} el
* @returns {String}
*/
getElementValue(el) {
return this.$(el).val();
},
/**
* Display form validation errors. isValidStart is run before
* input element validation errors are displayed. Descendent
* views only need to override to show any form specific
* validation errors that are not handled by the generic handlers.
*
* @return {undefined} true if a validation error is displayed.
*/
showValidationErrorsStart() {},
/**
* Display form validation errors. isValidEnd is run after
* input element validation errors are displayed. Descendent
* views only need to override to show any form specific
* validation errors that are not handled by the generic handlers.
*
* @return {undefined} true if a validation error is displayed.
*/
showValidationErrorsEnd() {},
/**
* Show a form validation error to the user in the form of a tooltip.
*
* @param {String} el
* @param {Error} err
* @param {Boolean} shouldFocusEl
* @returns {String}
*/
showValidationError(el, err, shouldFocusEl = true, translationContext = {}) {
const $invalidEl = this.$(el);
const message = AuthErrors.toMessage(err);
this.logError(err);
const maybeFocus = () => {
return new Promise((resolve) => {
if (shouldFocusEl) {
// wait to focus otherwise
// on screen keyboard may cover message
setTimeout(
() => {
try {
$invalidEl.get(0).focus();
} catch (e) {
// IE can blow up if the element is not visible.
}
resolve();
},
// Create account page needs a bit more time than next tick
// for some unknown reason. Maybe investigate later...
200
);
} else {
resolve();
}
});
};
if (err.describedById) {
this.markElementInvalid($invalidEl, err.describedById);
maybeFocus();
} else {
// tooltipId is used to bind the invalid element
// with the tooltip using `aria-describedby`
const tooltipId = `error-tooltip-${err.errno}`;
var tooltip = new Tooltip({
id: tooltipId,
invalidEl: $invalidEl,
message,
translator: this.translator,
translationContext,
});
maybeFocus().then(() => {
tooltip
.on('destroyed', () => {
this.markElementValid($invalidEl);
this.trigger('validation_error_removed', el);
})
.render()
.then(() => {
this.markElementInvalid($invalidEl, tooltipId);
// used for testing
this.trigger('validation_error', el, message);
});
this.trackChildView(tooltip);
});
}
return message;
},
/**
* Mark an element as invalid
*
* @param {Element} $el to mark invalid
* @param {String} [describedById] if set, sets 'aria-describedby' attribute on `$el`
*/
markElementInvalid($el, describedById) {
$el.addClass('invalid').attr('aria-invalid', 'true');
if (describedById) {
$el.attr('aria-describedby', describedById);
}
},
/**
* Mark an element as valid
*
* @param {Element} $el to mark valid
*/
markElementValid($el) {
$el
.removeClass('invalid')
.attr('aria-invalid', null)
.attr('aria-describedby', null);
},
/**
* Descendent views can override.
*
* Descendent views may want to override this to allow multiple form
* submissions or to disable form submissions. Return false or a
* promise that resolves to false to prevent form submission.
*
* @returns {Promise|Boolean|none} Return a promise if
* beforeSubmit is an asynchronous operation.
*/
beforeSubmit() {
return Promise.resolve();
},
/**
* Descendent views should override.
*
* Submit form data to the server. Only called if isValid returns true
* and beforeSubmit does not return false.
*
*/
submit() {},
/**
* Descendent views can override.
*
* Descendent views may want to override this to allow
* multiple form submissions.
*
* @param {Object} result
* @returns {Promise|none} Return a promise if afterSubmit is
* an asynchronous operation.
*/
afterSubmit(result) {
return Promise.resolve().then(() => {
// the flow may be halted by an authentication broker after form
// submission. Views may display an error without throwing an exception.
// Ensure the flow is not halted and and no errors are visible before
// re-enabling the form. The user must modify the form for it to
// be re-enabled.
if (result && result.halt) {
this.halt();
}
return result;
});
},
/**
* Check if the form is currently being submitted
*
* @returns {Boolean} true if form is being submitted, false otw.
*/
isSubmitting() {
return this._isSubmitting;
},
/**
* Halt! Disable form edits, submission.
*
* TODO - this should be named disableForm, but that name is already taken.
*/
halt() {
this.$('input,textarea,button').attr('disabled', 'disabled').blur();
this._isHalted = true;
},
/**
* Check if the view is halted
*
* @returns {Boolean} true if the view is halted, false otw.
*/
isHalted() {
return this._isHalted;
},
/**
* Detect if form values have changed
*
* @returns {Object|null} the form values or null if they haven't changed.
*/
detectFormValueChanges() {
// oldValues will be `undefined` the first time through.
var oldValues = this._previousFormValues;
var newValues = this.getFormValues();
if (!_.isEqual(oldValues, newValues)) {
return newValues;
}
return null;
},
/**
* Detect if form values have changed and use the new
* values as the baseline to detect future changes.
*
* @returns {Object|null} the form values or null if they haven't changed.
*/
updateFormValueChanges() {
var newValues = this.detectFormValueChanges();
if (newValues) {
this._previousFormValues = newValues;
}
return newValues;
},
/**
* Checks whether the selected form is valid.
* If the elements is invalid, returns false.
*
* @param {String} selector
* @returns {Boolean}
*/
validateFormField(selector) {
const $el = this.$(selector);
try {
// calling the jQuery plugin to validate the input elements
// and thrown and error if the input fields are not validated.
$el.validate();
} catch (err) {
this.showValidationError(selector, err);
return false;
}
return true;
},
});
export default FormView;