From 4f3635b2b1126ef65025d4044eb2dab34ea50f2e Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Thu, 22 Nov 2018 15:47:49 +0000 Subject: [PATCH] [IMP] website, *: add event handler possibilities * web This commit introduces helpers for async handlers, especially for button clicks. Those helpers are now used automatically for website widgets (animations) if the 'async' word is placed in front of a handler name in the `events` key definition. --- addons/web/static/src/js/core/dom.js | 91 ++++++++++++++++++- addons/web/static/src/scss/ui.scss | 5 + .../static/tests/helpers/test_utils_mock.js | 4 + .../src/js/content/snippets.animation.js | 72 +++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) 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. *