diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 2a961596862..848e406c4b2 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -20,6 +20,7 @@ odoo.define('web.dom', function (require) { * something happens in the DOM. */ +var concurrency = require('web.concurrency'); var config = require('web.config'); var core = require('web.core'); var _t = core._t; @@ -40,7 +41,9 @@ function _notify(content, callbacks) { core.bus.trigger('DOM_updated', content); } -return { +var dom = { + DEBOUNCE: 400, + /** * Appends content in a jQuery object and optionnally triggers an event * @@ -208,6 +211,91 @@ return { } return position; }, + /** + * Protects a function which is to be used as a handler by preventing its + * execution for the duration of a previous call to it (including async + * parts of that call). + * + * Limitation: as the handler is ignored during async actions, + * the 'preventDefault' or 'stopPropagation' calls it may want to do + * will be ignored too. Using the 'preventDefault' and 'stopPropagation' + * arguments solves that problem. + * + * @param {function} fct + * The function which is to be used as a handler. If a promise + * is returned, it is used to determine when the handler's action is + * finished. Otherwise, the return is used as jQuery uses it. + * @param {function|boolean} preventDefault + * @param {function|boolean} stopPropagation + */ + makeAsyncHandler: function (fct, preventDefault, stopPropagation) { + // Create a deferred indicating if a previous call to this handler is + // still pending. + var def = $.when(); + + return function (ev) { + if (preventDefault === true || preventDefault && preventDefault()) { + ev.preventDefault(); + } + if (stopPropagation === true || stopPropagation && stopPropagation()) { + ev.stopPropagation(); + } + + if (def.state() === 'pending') { + // If a previous call to this handler is still pending, ignore + // the new call. + return; + } + var result = fct.apply(this, arguments); + def = $.when(result); + return result; + }; + }, + /** + * Creates a debounced version of a function to be used as a button click + * handler. Also improves the handler to disable the button for the time of + * the debounce and/or the time of the async actions it performs. + * + * Limitation: if two handlers are put on the same button, the button will + * become enabled again once any handler's action finishes (multiple click + * handlers should however not be binded to the same button). + * + * @param {function} fct + * The function which is to be used as a button click handler. If a + * promise is returned, it is used to determine when the button can be + * re-enabled. Otherwise, the return is used as jQuery uses it. + */ + makeButtonHandler: function (fct) { + // Fallback: if the final handler is not binded to a button, at least + // make it an async handler (also handles the case where some events + // might ignore the disabled state of the button). + fct = dom.makeAsyncHandler(fct); + + return function (ev) { + var result = fct.apply(this, arguments); + + var $button = $(ev.target).closest('.btn'); + if (!$button.length) { + return result; + } + + // Disable the button for the duration of the handler's action + // or at least for the duration of the click debounce. This makes + // a 'real' debounce creation useless. Also, during the debouncing + // part, the button is disabled without any visual effect. + $button.addClass('o_debounce_disabled'); + $.when(dom.DEBOUNCE && concurrency.delay(dom.DEBOUNCE)).then(function () { + $button.addClass('disabled').prop('disabled', true); + $button.removeClass('o_debounce_disabled'); + + return $.when(result).always(function () { + $button.removeClass('disabled').prop('disabled', false); + }); + }); + + return result; + }; + }, /** * Prepends content in a jQuery object and optionnally triggers an event * @@ -460,4 +548,5 @@ return { } }, }; +return dom; }); diff --git a/addons/web/static/src/scss/ui.scss b/addons/web/static/src/scss/ui.scss index 46995aa9ba1..44d639805be 100644 --- a/addons/web/static/src/scss/ui.scss +++ b/addons/web/static/src/scss/ui.scss @@ -78,3 +78,8 @@ outline: 20px solid fade-out($o-brand-primary, 1.0); } } + +// Declare a similar class as 'disabled' but with no visual effect +.o_debounce_disabled { + pointer-events: none; +} diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js index 769c179472a..3eaaae0a46f 100644 --- a/addons/web/static/tests/helpers/test_utils_mock.js +++ b/addons/web/static/tests/helpers/test_utils_mock.js @@ -13,6 +13,7 @@ odoo.define('web.test_utils_mock', function (require) { var basic_fields = require('web.basic_fields'); var config = require('web.config'); var core = require('web.core'); +var dom = require('web.dom'); var MockServer = require('web.MockServer'); var session = require('web.session'); @@ -154,6 +155,8 @@ function addMockEnvironment(widget, params) { // make sure the debounce value for input fields is set to 0 var initialDebounceValue = DebouncedField.prototype.DEBOUNCE; DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; + var initialDOMDebounceValue = dom.DEBOUNCE; + dom.DEBOUNCE = 0; var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle; initialSession = _.extend({}, session); session.getTZOffset = function () { @@ -196,6 +199,7 @@ function addMockEnvironment(widget, params) { core.bus.trigger('clear_cache'); DebouncedField.prototype.DEBOUNCE = initialDebounceValue; + dom.DEBOUNCE = initialDOMDebounceValue; if (params.debounce === false) { _.debounce = initialDebounce; } diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index a0b25c75b01..a6737fe7b24 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -7,6 +7,7 @@ odoo.define('website.content.snippets.animation', function (require) { var Class = require('web.Class'); var core = require('web.core'); +var dom = require('web.dom'); var mixins = require('web.mixins'); var utils = require('web.utils'); var Widget = require('web.Widget'); @@ -255,6 +256,29 @@ var Animation = Widget.extend({ * `Animation` elements. */ selector: false, + /** + * Extension of @see Widget.events + * + * A description of the event handlers to bind/delegate once the widget + * has been rendered. + * + * 'click .hello .world': 'async _onHelloWorldClick', + * _^_ _^_ _^_ _^_ + * | | | | + * | (Optional) jQuery | Handler method name + * | delegate selector | + * | |_ (Optional) space separated options + * | * async: use the automatic system + * |_ Event name with making handlers promise-ready (see + * potential jQuery makeButtonHandler, makeAsyncHandler) + * namespaces + * + * Note: the values may be replaced by a function declaration. This is + * however a deprecated behavior. + * + * @type {Object} + */ + events: {}, /** * Acts as @see Widget.events except that the events are only binded if the * Animation instance is instanciated in edit mode. @@ -371,6 +395,54 @@ var Animation = Widget.extend({ // Private //-------------------------------------------------------------------------- + /** + * @see this.events + * @override + */ + _delegateEvents: function () { + var self = this; + var originalEvents = this.events; + + var events = {}; + _.each(this.events, function (method, event) { + // If the method is a function, use the default Widget system + if (typeof method !== 'string') { + events[event] = method; + return; + } + // If the method is only a function name without options, use the + // default Widget system + var methodOptions = method.split(' '); + if (methodOptions.length <= 1) { + events[event] = method; + return; + } + // If the method has no meaningful options, use the default Widget + // system + var isAsync = _.contains(methodOptions, 'async'); + if (!isAsync) { + events[event] = method; + return; + } + + method = self.proxy(methodOptions[methodOptions.length - 1]); + if (_.str.startsWith(event, 'click')) { + // Protect click handler to be called multiple times by + // mistake by the user and add a visual disabling effect + // for buttons. + method = dom.makeButtonHandler(method); + } else { + // Protect all handlers to be recalled while the previous + // async handler call is not finished. + method = dom.makeAsyncHandler(method); + } + events[event] = method; + }); + + this.events = events; + this._super.apply(this, arguments); + this.events = originalEvents; + }, /** * Registers `AnimationEffect` instances. *