packages/fxa-content-server/app/scripts/views/base.js (793 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/. */ import $ from 'jquery'; import _ from 'underscore'; import AuthErrors from '../lib/auth-errors'; import Backbone from 'backbone'; import Cocktail from 'cocktail'; import domWriter from '../lib/dom-writer'; import ErrorUtils from '../lib/error-utils'; import ExternalLinksMixin from './mixins/external-links-mixin'; import NotifierMixin from '../lib/channels/notifier-mixin'; import NullMetrics from '../lib/null-metrics'; import Logger from '../lib/logger'; import * as Sentry from '@sentry/browser'; import UrlMixin from '../lib/url-mixin'; import Strings from '../lib/strings'; import TimerMixin from './mixins/timer-mixin'; import Translator from '../lib/translator'; import VerificationMethods from '../lib/verification-methods'; import VerificationReasons from '../lib/verification-reasons'; var DEFAULT_TITLE = window.document.title; var DEFAULT_HEADER_HEIGHT = 64; // see _settings.scss for details (.settings-success-wrapper) var STATUS_MESSAGE_ANIMATION_MS = 150; // A null metrics instance is created for unit tests. In the app, // when a view is initialized, an initialized Metrics instance // is passed in to the constructor. var nullMetrics = new NullMetrics(); function displaySuccess(displayStrategy, msg) { this.hideError(); var $success = this.$('.success'); if (msg) { $success[displayStrategy](this.translator.get(msg)); } // the 'data-shown' attribute value is added so the functional tests // can find out if the success message was successfully shown, even // if the element is then hidden. In the functional tests, // testSuccessWasShown removes the attribute so multiple checks for the // element can take place in the same test. $success.slideDown(STATUS_MESSAGE_ANIMATION_MS).attr('data-shown', 'true'); if (this.window.pageYOffset >= DEFAULT_HEADER_HEIGHT) { $success.css({ left: 0, position: 'fixed', top: 0, width: '100%', }); } else { $success.css({ left: '', position: '', top: '', width: '', }); } this.trigger('success', msg); this._isSuccessVisible = true; } function displayError(displayStrategy, err) { // Errors are disabled on page unload to suppress errors // caused by aborted XHR requests. if (!this._areErrorsEnabled) { this.logger.error('Error ignored: %s', JSON.stringify(err)); return; } this.hideSuccess(); err = this._normalizeError(err); this.logError(err); var translated = this.translateError(err); var $error = this.$('.error'); if (AuthErrors.is(err, 'WORKING')) { this.logFlowEvent('working'); // Avoid a scary red warning for 'Working...' $error.addClass('info'); } else { $error.removeClass('info'); } if (translated) { $error[displayStrategy](translated); } // the 'data-shown' attribute value is added so the functional tests // can find out if the error message was successfully shown, even // if the element is then hidden. In the functional tests, // testErrorWasShown removes the attribute so multiple checks for the // element can take place in the same test. $error.slideDown(STATUS_MESSAGE_ANIMATION_MS).attr('data-shown', 'true'); this.trigger('error', translated); this._isErrorVisible = true; return translated; } /** * Return the error module that produced the error, based on the error's * namespace. * * @param {Error} err * @returns {Object} */ function getErrorModule(err) { if (err && err.errorModule) { return err.errorModule; } else { return AuthErrors; } } var BaseView = Backbone.View.extend({ /** * A class name that is added to the 'body' element pre-render * and removed on destroy. * * @property layoutClassName */ layoutClassName: null, /** * The default view name * * @property viewName */ viewName: '', /** * Partial templates that are automatically included in a template's context. * * The key is the name of the field within the context, the value is the template function. * * e.g., * * ``` * partialTemplates: { * unsafeCoppaHTML: CoppaTemplate * } * ``` * * Within the view's template, the COPPA template can be rendered like: * * ``` * {{{ unsafeCoppaHTML }}} * ``` * * @property partialTemplates */ partialTemplates: {}, constructor: function (options = {}) { this.broker = options.broker; this.currentPage = options.currentPage; this.model = options.model || new Backbone.Model(); this.metrics = options.metrics || nullMetrics; this.relier = options.relier; this.sentryMetrics = options.sentryMetrics || Sentry; this.childViews = []; this.user = options.user; this.lang = options.lang; this.window = options.window || window; this.logger = new Logger(this.window); this.config = options.config || {}; this.navigator = options.navigator || this.window.navigator || navigator; this.translator = options.translator || new Translator(); // `events` are defined on child views without extending // BaseView's events. Defining events on BaseView (or any // of its mixins) results in a clobbered events hash. // Just mix the ExternalLinksMixin's events in. _.extend(this.events, ExternalLinksMixin.events); // Replace any string declarations with a standin // that looks up the function by name when invoked. // The extra level of indirection allows sinon // spies & stubs to be used on DOM event handlers. // Without indirection, the original function is // always called. // eslint-disable-next-line no-unused-vars for (const eventName in this.events) { const method = this.events[eventName]; if (_.isString(method) && _.isFunction(this[method])) { // a function must be used instead of a fat arrow // or else Backbone will not add the handler. this.events[eventName] = function (...args) { this[method](...args); }; } } /** * Prefer the `viewName` set on the object prototype. ChildViews * define their viewName on the prototype to avoid taking the * name of the parent view. This is a terrible hack, but workable * until a better solution arises. See #3029 */ if (!this.viewName && options.viewName) { this.viewName = options.viewName; } // The mixin's initialize is called directly instead of the normal // override the `initialize` function because not all sub-classes // call the parent's `initialize`. w/o the call to the parent, // the mixin does not initialize correctly. NotifierMixin.initialize.call(this, options); Backbone.View.call(this, options); // Prevent errors from being displayed by aborted XHR requests. this._boundDisableErrors = this.disableErrors.bind(this); $(this.window).on('beforeunload', this._boundDisableErrors); this._boundCheckAuthorization = this.checkAuthorization.bind(this); $(this.window).on('focus', this._boundCheckAuthorization); // batch re-renders so that it's only called once. this.rerender = _.debounce(this.rerender, 50); }, /** * Render the view - Rendering is done asynchronously. * * Two functions can be overridden to perform data validation: * * beforeRender - called before rendering occurs. Can be used * to perform data validation. Return a promise to * perform an asynchronous check. Return false or a promise * that resolves to false to prevent rendering. If `navigate` is * called in `beforeRender`, rendering of the current view * is prevented. * * afterRender - called after the rendering occurs. Can be used * to print an error message after the view is already rendered. * * @returns {Promise} resolves to `true` if the view should be * displayed, `false` if not. */ render() { if (this.layoutClassName) { $('body').addClass(this.layoutClassName); } const style = this.relier && this.relier.get('style'); if (style) { $('body').addClass(style); } // reset _hasNavigated for every render, otherwise settings panels // cannot re-render themselves after displaying an inline child view. this._hasNavigated = false; return Promise.resolve() .then(() => this.checkAuthorization()) .then((isUserAuthorized) => { return isUserAuthorized && this.beforeRender(); }) .then((shouldRender) => { // rendering is opt out, should not occur if the view // has already navigated. if (shouldRender === false || this.hasNavigated()) { return false; } this.destroyChildViews(); // force a re-load of the context every time the // view is rendered or else stale data may // be returned. this._context = null; return Promise.resolve() .then(() => { if (this.renderReactComponent) { return this.renderReactComponent(); } else { this.$el.html(this.renderTemplate(this.template.bind(this))); } }) .then(() => { this.trigger('rendered'); // Track whether status messages were made visible via the template. this._isErrorVisible = this.$('.error').hasClass('visible'); this._isSuccessVisible = this.$('.success').hasClass('visible'); return this.afterRender(); }); }) .then((shouldDisplay) => { return shouldDisplay !== false && !this.hasNavigated(); }); }, /** * Render a template using view's own context combined with * `additionalContext`. * * @param {Function} template - template function * @param {Object} [additionalContext] - additional context to pass to * template function. * @returns {String} - rendered template */ renderTemplate(template, additionalContext = {}) { // `t` and `unsafeTranslate` are helper functions used by // the template for translation. `context` is passed to // each to propagate values from `additionalContext`. const context = _.extend( {}, this.getContext(), { // `t` is a Mustache helper to translate and HTML escape strings. t: (msg) => this.translateInTemplate(msg, context), // `unsafeTranslate` is a Mustache helper that translates a // string without HTML escaping. Prefer `t` unsafeTranslate: (msg) => this.unsafeTranslateInTemplate(msg, context), }, additionalContext ); // Mustache helpers to render partialTemplates if // used within the template. // eslint-disable-next-line no-unused-vars for (const contextName in this.partialTemplates) { const template = this.partialTemplates[contextName]; // Use a fat arrow to only render the template if it's used. context[contextName] = () => this.renderTemplate(template, additionalContext); } return template(context); }, /** * Write content to the DOM * * @param {String|Element} content * @returns {undefined} */ writeToDOM(content) { return domWriter.write(this.window, content); }, /** * Checks whether the user is authorized to view the current view. * If user is not authorized they will be sent to another screen * to sign in or confirm their account. * * @returns {Promise} resolves to true or false. */ checkAuthorization() { if (this.mustAuth || this.mustVerify) { return this.user.sessionStatus().then( (account) => { if (this.mustVerify && !account.get('verified')) { this.relier.set('redirectTo', this.currentPage); let targetScreen; if ( account.get('verificationReason') === VerificationReasons.SIGN_UP ) { // Trying to use an unverified account. A code // is not re-sent automatically, so send a new one // and then go to the confirm screen. return account.verifySessionResendCode().then(() => { this.navigate('confirm_signup_code', { account }); return false; }); } else if ( account.get('verificationReason') === VerificationReasons.SIGN_IN ) { if ( account.get('verificationMethod') === VerificationMethods.EMAIL_2FA ) { targetScreen = 'signin_code'; } else { targetScreen = 'confirm_signin'; } } this.navigate(targetScreen, { account, }); return false; } return true; }, (err) => { if (AuthErrors.is(err, 'INVALID_TOKEN')) { this.logError(AuthErrors.toError('SESSION_EXPIRED')); // The redirectTo in .navigate() is lost if there's later navigations, so by saving it here // we can get it later (e.g., in case of a signUp): this.relier.set('redirectTo', this.window.location.href); this.navigate(this._reAuthPage()); return false; } throw err; } ); } return Promise.resolve(true); }, // If the user navigates to a page that requires auth and their session // is not currently cached, we ask them to sign in again. If the relier // specifies an email address, we force the user to use that account. _reAuthPage() { if (this.relier && this.relier.get('email')) { // setting the email here ensures that React signin can pick up on this email this.user.set('emailFromIndex', this.relier.get('email')); return 'force_auth'; } // Until email-first is fully the default, this is // needed to ensure the `/` uses the email-first flow // and not redirect unauthenticated users directly // to /signup. this.relier.set('action', 'email'); return '/'; }, displayStatusMessages() { var success = this.model.get('success'); if (success) { this.displaySuccess(success); this.model.unset('success'); const account = this.model.get('account'); if (account) { account.unset('alertText'); } } var unsafeSuccess = this.model.get('unsafeSuccess'); if (unsafeSuccess) { this.unsafeDisplaySuccess(unsafeSuccess); this.model.unset('unsafeSuccess'); } var error = this.model.get('error'); if (error) { this.displayError(error); this.model.unset('error'); } }, titleFromView(baseTitle) { var title = baseTitle || DEFAULT_TITLE; var titleText = this.$('header:first h1').text(); var subText = this.$('header:first h2').text(); if (titleText && subText) { title = titleText + ': ' + subText; } else if (titleText) { title = titleText; } else if (subText) { title = title + ': ' + subText; } return title; }, getContext() { // use cached context, if available. This prevents the context() // function from being called multiple times per render. if (!this._context) { this._context = new Backbone.Model(this.model.toJSON()); this.setInitialContext(this._context); } return this._context.toJSON(); }, /** * Update the `context` model * * @param {Object} context */ setInitialContext(context) { // Implement in subclasses }, /** * Translate a string, output will be HTML escaped. * * @param {String} text - string to translate * @param {Object} [context] - interpolation context, defaults to * this.getContext(); * @returns {String} */ translate(text, context = this.getContext()) { if (Strings.hasHTML(text)) { const err = AuthErrors.toError('HTML_WILL_BE_ESCAPED'); err.string = text; this.logError(err); } return _.escape(this.translator.get(text, context)); }, /** * Translate a string, do not escape the output. * This should rarely be used, prefer `translate` * * ** WARNING ** DOES NOT HTML ESCAPE * * @param {String} text - string to translate * @param {Object} [context] - interpolation context, defaults to * this.getContext(); * @returns {String} */ unsafeTranslate(text, context = this.getContext()) { if (Strings.hasUnsafeVariables(text)) { const err = AuthErrors.toError('UNSAFE_INTERPOLATION_VARIABLE_NAME'); err.string = text; this.logError(err); } return this.translator.get(text, context); }, /** * Return a Mustache helper that translates a string. * Translations are HTML escaped. * * @param {String} [text] to translate * @param {Object} [context] passed to translation function * @returns {Function} */ translateInTemplate(text, context) { return (innerText) => this.translate(text || innerText, context); }, /** * Return a Mustache helper that translates a string. * Translations are not HTML escaped. * Prefer `translateInTemplate` * * ** WARNING ** DOES NOT HTML ESCAPE * * @param {string} [text] string to translate * @param {Object} [context] passed to translation function * @returns {function} */ unsafeTranslateInTemplate(text, context) { return (innerText) => this.unsafeTranslate(text || innerText, context); }, /** * Called before rendering begins. If returns false, or if returns * a promise that resolves to false, then the view is not * rendered. Useful to immediately redirect to another view before * rendering begins. */ beforeRender() {}, /** * Called after the rendering occurs. Can be used to print an * error message after the view is already rendered. * * @returns {Promise} */ afterRender() { // Override in subclasses return Promise.resolve(); }, /** * Called after the view is visible. * * @returns {Promise} */ afterVisible() { // jQuery 3.x requires the view to be visible // before animating the status messages. this.displayStatusMessages(); this.stackWideLinks(); this.focusAutofocusElement(); return Promise.resolve(); }, /** * Re-renders the view, assumes the view * is still visible. * * @returns {Promise} */ rerender() { return this.render().then(() => this.afterVisible()); }, /** * Stack side-by-side links if they are too long to fit on one line */ stackWideLinks() { const $links = this.$('.links'); $links.each((index, linkContainer) => { const $linkContainer = this.$(linkContainer); const $links = $linkContainer.children('a'); // Math.floor takes care of odd number widths const maxLinkWidthWithoutStacking = Math.floor( $linkContainer.width() / $links.length ); // if any link is equal to or more than half its parent's width, // make *all* links in the same parent to be stacked const $tooWideLinks = $links.filter((i, item) => { const $item = this.$(item); // disable wrapping and width constraints to get the natural width of the element $item.css('max-width', '100%'); $item.css('white-space', 'nowrap'); const isTooWide = $item.outerWidth() >= maxLinkWidthWithoutStacking; // re-enable wrapping $item.css('white-space', ''); $item.css('max-width', ''); return isTooWide; }); if ($tooWideLinks.length) { $linkContainer.addClass('centered'); $links.removeClass('left').removeClass('right'); } }); }, destroy(remove) { this.trigger('destroy'); if (this.beforeDestroy) { this.beforeDestroy(); } if (remove) { this.remove(); } else { this.stopListening(); this.$el.off(); } if (this.layoutClassName) { $('body').removeClass(this.layoutClassName); } $(this.window).off('beforeunload', this._boundDisableErrors); $(this.window).off('focus', this._boundCheckAuthorization); this.destroyChildViews(); this.trigger('destroyed'); }, trackChildView(view) { if (!_.contains(this.childViews, view)) { this.childViews.push(view); view.on('destroyed', _.bind(this.untrackChildView, this, view)); } return view; }, untrackChildView(view) { this.childViews = _.without(this.childViews, view); return view; }, destroyChildViews() { _.invoke(this.childViews, 'destroy'); this.childViews = []; }, isChildViewTracked(view) { return _.indexOf(this.childViews, view) > -1; }, /** * Display a success message * @method displaySuccess * If msg is not given, the contents of the .success element's text * will not be updated. */ displaySuccess: _.partial(displaySuccess, 'text'), /** * Display a success message. If msg is not given, the contents of * the .success element's HTML will not be updated. */ unsafeDisplaySuccess: _.partial(displaySuccess, 'html'), hideSuccess() { this.$('.success') .slideUp(STATUS_MESSAGE_ANIMATION_MS) .removeClass('visible'); this._isSuccessVisible = false; }, /** * Return true if the success message is visible * * @returns {Boolean} */ isSuccessVisible() { return !!this._isSuccessVisible; }, /** * Display an error message. * @method translateError * @param {String} err - an error object * * @return {String} translated error text (if available), untranslated * error text otw. */ translateError(err) { var errors = getErrorModule(err); var translated = errors.toInterpolatedMessage(err, this.translator); return translated; }, _areErrorsEnabled: true, /** * Disable logging and display of errors. * * @method disableErrors */ disableErrors() { this._areErrorsEnabled = false; }, /** * Display an error message. * @method displayError * @param {String} err - If err is not given, the contents of the * `.error` element's text will not be updated. * * @return {String} translated error text (if available), untranslated * error text otw. */ displayError: _.partial(displayError, 'text'), /** * Display an error message that may contain HTML. Marked unsafe * because msg could contain XSS. Use with caution and never * with unsanitized user generated content. * * @method unsafeDisplayError * @param {String} err - If err is not given, the contents of the * `.error` element's text will not be updated. * * @return {String} translated error text (if available), untranslated * error text otw. */ unsafeDisplayError: _.partial(displayError, 'html'), /** * Log an error to the event stream * * @param {Error} err */ logError(err) { err = this._normalizeError(err); // The error could already be logged, if so, abort mission. // This can occur when `navigate` redirects a user to a different // view and an error is passed. The error is logged before the view // transition, the new view is rendered, then the original error is // displayed. This avoids duplicate entries. if (err.logged) { return; } err.logged = true; ErrorUtils.captureError(err, this.sentryMetrics, this.metrics); }, /** * Handle a fatal error. Logs and reports the error, then redirects * to the appropriate error page. * * @param {Error} err * @returns {Promise} */ fatalError(err) { return ErrorUtils.fatalError( err, this.sentryMetrics, this.metrics, this.window, this.translator ); }, /** * Get the view's name. * * @returns {String} */ getViewName() { return this.viewName; }, _normalizeError(err) { var errors = getErrorModule(err); if (!err) { // likely an error in logic, display an unexpected error to the // user and show a console trace to help us debug. err = errors.toError('UNEXPECTED_ERROR'); this.logger.trace(); } if (_.isString(err)) { err = new Error(err); } err.viewName = this.getViewName(); return err; }, /** * Log the current view */ logView() { this.metrics.logView(this.getViewName()); }, /** * Log an event to the event stream * * @param {String} eventName */ logEvent(eventName) { this.metrics.logEvent(eventName); }, /** * Log an event once per page load * * @param {String} eventName */ logEventOnce(eventName) { this.metrics.logEventOnce(eventName); }, /** * Log an event with the view name as a prefix * * @param {String} eventName */ logViewEvent(eventName) { this.metrics.logViewEvent(this.getViewName(), eventName); }, /** * Log a flow event to the event stream * * @param {String} eventName * @param {String} viewName * @param {Object} data */ logFlowEvent(eventName, viewName, data) { this.notifier.trigger( 'flow.event', _.assign({}, data, { event: eventName, viewName, }) ); }, /** * Log a flow event once per page load * * @param {String} eventName * @param {String} viewName */ logFlowEventOnce(eventName, viewName) { this.logFlowEvent(eventName, viewName, { once: true }); }, hideError() { this.$('.error') .slideUp(STATUS_MESSAGE_ANIMATION_MS) .removeClass('visible'); this._isErrorVisible = false; }, isErrorVisible() { return !!this._isErrorVisible; }, /** * navigate to another screen * * @param {String} url - url of screen * @param {Object} [nextViewData] - data to pass to the next view * @param {RouterOptions} [routerOptions] - options to pass to the router */ navigate(url, nextViewData, routerOptions) { nextViewData = nextViewData || {}; routerOptions = routerOptions || {}; if (nextViewData.error) { // log the error entry before the new view is rendered so events // stay in the correct order. this.logError(nextViewData.error); } this._hasNavigated = true; this.notifier.trigger('navigate', { nextViewData: nextViewData, routerOptions: routerOptions, url: url, }); }, /** * Navigate externally to the application. * * @param {String} url */ navigateAway(url) { this._hasNavigated = true; this.notifier.trigger('navigate', { server: true, url, }); }, /** * Replace the current page with `url`. * * @param {String} url - url of screen * @param {Object} [nextViewData={}] - data to pass to the next view */ replaceCurrentPage(url, nextViewData = {}) { this.navigate(url, nextViewData, { replace: true, trigger: true }); }, /** * Has the view navigated? Useful to check when a view should * perform an action, but only if the view hasn't already * navigated. * * @returns {Boolean} */ hasNavigated() { return !!this._hasNavigated; }, /** * Focus the element with the [autofocus] attribute, if not a touch device. * Focusing an element on a touch device causes the virtual keyboard to * be displayed, which hides part of the screen. */ focusAutofocusElement() { // make a huge assumption and say if the device does not have touch, // it's a desktop device and autofocus can be applied without // hiding part of the view. The no-touch class is added by // startup-styles const $autofocusEl = this.$('[autofocus]'); if (!$('html').hasClass('no-touch') || !$autofocusEl.length) { return; } const attemptFocus = () => { if ($autofocusEl.is(':focus')) { return; } // only elements that are visible can be focused. When embedded in // about:accounts, the content is hidden when the first "focus" is // done. Keep trying to focus until the element is actually focused, // and then stop trying. if (!$autofocusEl.is(':visible')) { this.setTimeout(attemptFocus, 50); return; } this.focus($autofocusEl); }; attemptFocus(); }, /** * Safely focus an element. Only sets focus on non-touch devices. * Focusing an element on a touch device causes the virtual keyboard to * be displayed, which hides part of the screen. * * @param {String} which */ focus(which) { if ($('html').hasClass('no-touch')) { try { const focusEl = this.$(which); // place the cursor at the end of the input when the // element is focused. focusEl.one('focus', () => this.placeCursorAt(focusEl)); focusEl.get(0).focus(); } catch (e) { // IE can blow up if the element is not visible. } } }, /** * Place the cursor at the given position within the input element * * @param {String | Element} which - Strings are assumed to be selectors * @param {Number} selectionStart - defaults to after the last character. * @param {Number} selectionEnd - defaults to selectionStart. */ placeCursorAt( which, selectionStart = $(which).__val().length, selectionEnd = selectionStart ) { const el = $(which).get(0); try { el.selectionStart = selectionStart; el.selectionEnd = selectionEnd; } catch (e) { // This can blow up on password fields in Chrome. Drop the error on // the ground, for whatever reason, it still behaves as we expect. } }, /** * Invoke the specified handler with the given event. Handler * can either be a function or a string. If a string, looks for * the handler on `this`. * * @method invokeHandler * @param {String|Function} handler * @param {...*} args - All additional arguments are passed to the handler. * @returns {undefined} */ invokeHandler(handler, ...args) { // convert a name to a function. if (_.isString(handler)) { handler = this[handler]; if (!_.isFunction(handler)) { throw new Error(handler + ' is an invalid function name'); } } if (_.isFunction(handler)) { // If an `arguments` type object was passed in as the first item, // then use that as the arguments list. Otherwise, use all arguments. if (_.isArguments(args[0])) { args = args[0]; } return handler.apply(this, args); } }, /** * Returns the currently logged in account * * @returns {Account} */ getSignedInAccount() { return this.user.getSignedInAccount(); }, /** * Returns the account that is active in the current view. It may not * be the currently logged in account. */ getAccount() { // Implement in subclasses }, /** * Shows the ChildView, creating and rendering it if needed. * * @param {Function} ChildView - child view's constructor * @param {Object} [options] - options to send. * @returns {Promise} resolves when complete */ showChildView(/* ChildView, options */) { // Implement in subclasses return Promise.resolve(); }, /** * Invoke a method on the broker, handling any returned behaviors * * @method invokeBrokerMethod * @param {String} methodName * @param {...*} args - all additional arguments are passed to the broker and behavior. * @returns {Promise} */ invokeBrokerMethod(methodName, ...args) { return Promise.resolve(this.broker[methodName](...args)).then((behavior) => this.invokeBehavior(behavior, ...args) ); }, /** * Invoke a behavior returned by an auth broker. If a behavior returns * another behavior, the returned behavior is recursively invoked. * Method resolves to the final behavior's result. * * @method invokeBehavior * @param {Function} behavior * @param {...*} args - all additional arguments are passed to the behavior. * @returns {Promise} resolves to the behavior's return value if behavior * is a function, otherwise resolves to the behavior value. */ invokeBehavior(behavior, ...args) { return Promise.resolve() .then(() => { if (_.isFunction(behavior)) { return behavior(this, ...args); } return behavior; }) .then((result) => { // recursively invoke returned behaviors. if (_.isFunction(behavior)) { return this.invokeBehavior(result, ...args); } return result; }); }, /** * Clear validation tooltips and all visible input values. */ clearInput() { const $inputEls = this.$('input'); $inputEls.each((i, inputEl) => { // Called to clear validation tooltips. issues/5680 $(inputEl).change(); }); const formEl = this.$('form input:not(".hidden")'); if (formEl) { for (let i = 0; i < formEl.length; i++) { formEl[i].value = ''; } } }, }); Cocktail.mixin( BaseView, // Attach the external links mixin in case the // view has any external links that need to have // their behaviors modified. ExternalLinksMixin, UrlMixin, TimerMixin ); export default BaseView;