From 11ba58a311311b798a68183df18673e562bb97d3 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Thu, 27 Feb 2020 14:55:44 +0000 Subject: [PATCH 01/10] [REF] web: Indent and reorganize generic files The purpose of this commit is to correctly indent and order some generic files. There is strictly no change in the code logic. --- .../web/static/src/js/chrome/action_mixin.js | 426 ++++---- .../web/static/tests/helpers/qunit_asserts.js | 435 ++++----- addons/web/static/tests/helpers/test_utils.js | 407 ++++---- .../static/tests/helpers/test_utils_create.js | 784 +++++++-------- .../static/tests/helpers/test_utils_dom.js | 909 +++++++++--------- .../static/tests/helpers/test_utils_fields.js | 423 ++++---- 6 files changed, 1699 insertions(+), 1685 deletions(-) diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js index 1831f3998f8..1ca87c15ef3 100644 --- a/addons/web/static/src/js/chrome/action_mixin.js +++ b/addons/web/static/src/js/chrome/action_mixin.js @@ -1,222 +1,232 @@ odoo.define('web.ActionMixin', function (require) { -"use strict"; - -/** - * We define here the ActionMixin, the generic notion of action (from the point - * of view of the web client). In short, an action is a widget which controls - * the main part of the screen (everything below the navbar). - * - * More precisely, the action manager is the component that coordinates a stack - * of actions. Whenever the user navigates in the interface, switches views, - * open different menus, the action manager creates/updates/destroys special - * widgets which implements the ActionMixin. These actions need to answer to a - * standardised API, which is the reason for this mixin. - * - * In practice, most actions are view controllers (coming from an - * ir.action.act_window). However, some actions are 'client actions'. They - * also need to implement the ActionMixin for a better cooperation with the - * action manager. - * - * @module web.ActionMixin - */ - -var core = require('web.core'); - -var ActionMixin = { - template: 'Action', + "use strict"; /** - * The action mixin assumes that it is rendered with the 'Action' template. - * This template has a special zone ('.o_content') where the content should - * be added. Actions that want to automatically render a template there - * should define the contentTemplate key. In short, client actions should - * probably define a contentTemplate key, and not a template key. + * We define here the ActionMixin, the generic notion of action (from the point + * of view of the web client). In short, an action is a widget which controls + * the main part of the screen (everything below the navbar). + * + * More precisely, the action manager is the component that coordinates a stack + * of actions. Whenever the user navigates in the interface, switches views, + * open different menus, the action manager creates/updates/destroys special + * widgets which implements the ActionMixin. These actions need to answer to a + * standardised API, which is the reason for this mixin. + * + * In practice, most actions are view controllers (coming from an + * ir.action.act_window). However, some actions are 'client actions'. They + * also need to implement the ActionMixin for a better cooperation with the + * action manager. + * + * @module web.ActionMixin */ - contentTemplate: null, - /** - * Events built by and managed by Odoo Framework - * - * It is expected that any Widget Class implementing this mixin - * will also implement the ParentedMixin which actually manages those - */ - custom_events: { - get_controller_query_params: '_onGetControllerQueryParams', - }, - /** - * If an action wants to use a control panel, it will be created and - * registered in this _controlPanel key (the widget). The way this control - * panel is created is up to the implementation (so, view controllers or - * client actions may have different needs). - * - * Note that most of the time, this key should be set by the framework, not - * by the code of the client action. - */ - _controlPanel: null, + var core = require('web.core'); - /** - * String containing the title of the client action (which may be needed to - * display in the breadcrumbs zone of the control panel). - * - * @see _setTitle - */ - _title: '', + var ActionMixin = { + template: 'Action', - /** - * @override - */ - renderElement: function () { - this._super.apply(this, arguments); - if (this.contentTemplate) { - var content = core.qweb.render(this.contentTemplate, {widget: this}); - this.$('.o_content').append(content); - } - }, - /** - * Called each time the action is attached into the DOM. - */ - on_attach_callback: function () {}, - /** - * Called each time the action is detached from the DOM. - */ - on_detach_callback: function () {}, - /** - * Called by the action manager when action is restored (typically, when the - * user clicks on the action in the breadcrumb) - * - * @returns {Promise|undefined} - */ - willRestore: function () {}, + /** + * The action mixin assumes that it is rendered with the 'Action' template. + * This template has a special zone ('.o_content') where the content should + * be added. Actions that want to automatically render a template there + * should define the contentTemplate key. In short, client actions should + * probably define a contentTemplate key, and not a template key. + */ + contentTemplate: null, - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- + /** + * Events built by and managed by Odoo Framework + * + * It is expected that any Widget Class implementing this mixin + * will also implement the ParentedMixin which actually manages those + */ + custom_events: { + get_controller_query_params: '_onGetControllerQueryParams', + }, - /** - * In some situations, we need confirmation from the controller that the - * current state can be destroyed without prejudice to the user. For - * example, if the user has edited a form, maybe we should ask him if we - * can discard all his changes when we switch to another action. In that - * case, the action manager will call this method. If the returned - * promise is successfully resolved, then we can destroy the current action, - * otherwise, we need to stop. - * - * @returns {Promise} resolved if the action can be removed, rejected - * otherwise - */ - canBeRemoved: function () { - return Promise.resolve(); - }, - /** - * This function is called when the current state of the action - * should be known. For instance, if the action is a view controller, - * this may be useful to reinstantiate a view in the same state. - * - * Typically the state can (and should) be encoded in a query object of - * the form:: - * - * { - * context: {...}, - * groupBy: [...], - * domain = [...], - * orderedBy = [...], - * } - * - * where the context key can contain many information. - * This method is mainly called during the creation of a custom filter. - * - * @returns {Object} - */ - getOwnedQueryParams: function () { - return {}; - }, - /** - * Returns a title that may be displayed in the breadcrumb area. For - * example, the name of the record (for a form view). This is actually - * important for the action manager: this is the way it is able to give - * the proper titles for other actions. - * - * @returns {string} - */ - getTitle: function () { - return this._title; - }, - /** - * Returns a serializable state that will be pushed in the URL by - * the action manager, allowing the action to be restarted correctly - * upon refresh. This function should be overriden to add extra information. - * Note that some keys are reserved by the framework and will thus be - * ignored ('action', 'active_id', 'active_ids' and 'title', for all - * actions, and 'model' and 'view_type' for act_window actions). - * - * @returns {Object} - */ - getState: function () { - return {}; - }, - /** - * Gives the focus to the action - */ - giveFocus: function () { - }, - /** - * Renders the buttons to append, in most cases, to the control panel (in - * the bottom left corner). When the action is rendered in a dialog, those - * buttons might be moved to the dialog's footer. - * - * @param {jQuery Node} $node - */ - renderButtons: function ($node) { - }, - /** - * This is the main method to customize the controlpanel content. - * - * @see updateContents method in ControlPanelRenderer for more info - * - * @param {Object} [status] - * @param {string} [status.title] - * @param {Object} [options] - * @param {boolean} [options.clear] - */ - updateControlPanel: function (status, options) { - if (this._controlPanel) { - status = status || {}; - status.title = status.title || this.getTitle(); - this._controlPanel.updateContents(status, options || {}); - } - }, - // TODO: add hooks methods: - // - onRestoreHook (on_reverse_breadcrumbs) + /** + * If an action wants to use a control panel, it will be created and + * registered in this _controlPanel key (the widget). The way this control + * panel is created is up to the implementation (so, view controllers or + * client actions may have different needs). + * + * Note that most of the time, this key should be set by the framework, not + * by the code of the client action. + */ + _controlPanel: null, - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + /** + * String containing the title of the client action (which may be needed to + * display in the breadcrumbs zone of the control panel). + * + * @see _setTitle + */ + _title: '', - /** - * @private - * @param {string} title - */ - _setTitle: function (title) { - this._title = title; - this.updateControlPanel({title: this._title}, {clear: false}); - }, - /** - * FIXME: this logic should be rethought - * - * Handles a context request: provides to the caller the state of the - * current controller. - * - * @private - * @param {OdooEvent} ev - * @param {function} ev.data.callback used to send the requested state - */ - _onGetControllerQueryParams: function (ev) { - ev.stopPropagation(); - var state = this.getOwnedQueryParams(); - ev.data.callback(state || {}); - }, -}; + /** + * Called each time the action is attached into the DOM. + */ + on_attach_callback: function () { }, -return ActionMixin; + /** + * Called each time the action is detached from the DOM. + */ + on_detach_callback: function () { }, + /** + * @override + */ + renderElement: function () { + this._super.apply(this, arguments); + if (this.contentTemplate) { + var content = core.qweb.render(this.contentTemplate, { widget: this }); + this.$('.o_content').append(content); + } + }, + + /** + * Called by the action manager when action is restored (typically, when + * the user clicks on the action in the breadcrumb) + * + * @returns {Promise|undefined} + */ + willRestore: function () { }, + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * In some situations, we need confirmation from the controller that the + * current state can be destroyed without prejudice to the user. For + * example, if the user has edited a form, maybe we should ask him if we + * can discard all his changes when we switch to another action. In that + * case, the action manager will call this method. If the returned + * promise is successfully resolved, then we can destroy the current action, + * otherwise, we need to stop. + * + * @returns {Promise} resolved if the action can be removed, rejected + * otherwise + */ + canBeRemoved: function () { + return Promise.resolve(); + }, + + /** + * This function is called when the current state of the action + * should be known. For instance, if the action is a view controller, + * this may be useful to reinstantiate a view in the same state. + * + * Typically the state can (and should) be encoded in a query object of + * the form:: + * + * { + * context: {...}, + * groupBy: [...], + * domain = [...], + * orderedBy = [...], + * } + * + * where the context key can contain many information. + * This method is mainly called during the creation of a custom filter. + * + * @returns {Object} + */ + getOwnedQueryParams: function () { + return {}; + }, + + /** + * Returns a serializable state that will be pushed in the URL by + * the action manager, allowing the action to be restarted correctly + * upon refresh. This function should be overriden to add extra information. + * Note that some keys are reserved by the framework and will thus be + * ignored ('action', 'active_id', 'active_ids' and 'title', for all + * actions, and 'model' and 'view_type' for act_window actions). + * + * @returns {Object} + */ + getState: function () { + return {}; + }, + + /** + * Returns a title that may be displayed in the breadcrumb area. For + * example, the name of the record (for a form view). This is actually + * important for the action manager: this is the way it is able to give + * the proper titles for other actions. + * + * @returns {string} + */ + getTitle: function () { + return this._title; + }, + + /** + * Gives the focus to the action + */ + giveFocus: function () { }, + + /** + * Renders the buttons to append, in most cases, to the control panel (in + * the bottom left corner). When the action is rendered in a dialog, those + * buttons might be moved to the dialog's footer. + * + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { }, + + /** + * This is the main method to customize the controlpanel content. + * + * @see updateContents method in ControlPanelRenderer for more info + * + * @param {Object} [status] + * @param {string} [status.title] + * @param {Object} [options] + * @param {boolean} [options.clear] + */ + updateControlPanel: function (status, options) { + if (this._controlPanel) { + status = status || {}; + status.title = status.title || this.getTitle(); + this._controlPanel.updateContents(status, options || {}); + } + }, + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @param {string} title + */ + _setTitle: function (title) { + this._title = title; + this.updateControlPanel({ title: this._title }, { clear: false }); + }, + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * FIXME: this logic should be rethought + * + * Handles a context request: provides to the caller the state of the + * current controller. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback used to send the requested state + */ + _onGetControllerQueryParams: function (ev) { + ev.stopPropagation(); + var state = this.getOwnedQueryParams(); + ev.data.callback(state || {}); + }, + }; + + return ActionMixin; }); diff --git a/addons/web/static/tests/helpers/qunit_asserts.js b/addons/web/static/tests/helpers/qunit_asserts.js index bb4438eb4cc..a57154e416e 100644 --- a/addons/web/static/tests/helpers/qunit_asserts.js +++ b/addons/web/static/tests/helpers/qunit_asserts.js @@ -1,240 +1,241 @@ odoo.define('web.qunit_asserts', function (require) { -"use strict"; + "use strict"; -/** - * In this file, we extend QUnit by adding some specialized assertions. The goal - * of these new assertions is twofold: - * - ease of use: they should allow us to simplify some common complex assertions - * - safer: these assertions will fail when some preconditions are not met. - * - * For example, the assert.isVisible assertion will also check that the target - * matches exactly one element. - */ + /** + * In this file, we extend QUnit by adding some specialized assertions. The goal + * of these new assertions is twofold: + * - ease of use: they should allow us to simplify some common complex assertions + * - safer: these assertions will fail when some preconditions are not met. + * + * For example, the assert.isVisible assertion will also check that the target + * matches exactly one element. + */ -var Widget = require('web.Widget'); + var Widget = require('web.Widget'); -//------------------------------------------------------------------------------ -// Private functions -//------------------------------------------------------------------------------ + //------------------------------------------------------------------------- + // Private functions + //------------------------------------------------------------------------- -/** - * Helper function, to check if a given element - * - is unique (if it is a jquery node set) - * - has (or has not) a css class - * - * @private - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} className - * @param {boolean} shouldHaveClass - * @param {string} [msg] - */ -function _checkClass(w, className, shouldHaveClass, msg) { - if (w instanceof jQuery && w.length !== 1) { - const assertion = shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'; - QUnit.assert.ok(false, `Assertion '${assertion} ${className}' targets ${w.length} elements instead of 1`); - } + /** + * Helper function, to check if a given element + * - is unique (if it is a jquery node set) + * - has (or has not) a css class + * + * @private + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} className + * @param {boolean} shouldHaveClass + * @param {string} [msg] + */ + function _checkClass(w, className, shouldHaveClass, msg) { + if (w instanceof jQuery && w.length !== 1) { + const assertion = shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'; + QUnit.assert.ok(false, `Assertion '${assertion} ${className}' targets ${w.length} elements instead of 1`); + } - const el = w instanceof Widget || w instanceof owl.Component ? w.el : - w instanceof jQuery ? w[0] : w; - - msg = msg || `target should ${shouldHaveClass ? 'have' : 'not have'} class ${className}`; - const hasClass = className.split(" ").reduce((acc, cls) => - acc && el.classList.contains(cls), true); - const condition = shouldHaveClass ? hasClass : !hasClass; - QUnit.assert.ok(condition, msg); -} - -/** - * Helper function, to check if a given element - * - is unique (if it is a jquery node set) - * - is (or not) visible - * - * @private - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {boolean} shouldBeVisible - * @param {string} [msg] - */ -function _checkVisible(w, shouldBeVisible, msg) { - if (w instanceof jQuery && w.length !== 1) { - const assertion = shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'; - QUnit.assert.ok(false, `Assertion '${assertion} ${className}' targets ${w.length} elements instead of 1`); - } - - const el = w instanceof Widget || w instanceof owl.Component ? w.el : + const el = w instanceof Widget || w instanceof owl.Component ? w.el : w instanceof jQuery ? w[0] : w; - msg = msg || `target should ${shouldBeVisible ? '' : 'not'} be visible`; - let isVisible = el && - el.offsetWidth && - el.offsetHeight; - if (isVisible) { - // This computation is a little more heavy and we only want to perform it - // if the above assertion has failed. - const rect = el.getBoundingClientRect(); - isVisible = rect.width + rect.height; + msg = msg || `target should ${shouldHaveClass ? 'have' : 'not have'} class ${className}`; + const hasClass = className.split(" ").reduce((acc, cls) => + acc && el.classList.contains(cls), true); + const condition = shouldHaveClass ? hasClass : !hasClass; + QUnit.assert.ok(condition, msg); } - const condition = shouldBeVisible ? isVisible : !isVisible; - QUnit.assert.ok(condition, msg); -} -//------------------------------------------------------------------------------ -// Public functions -//------------------------------------------------------------------------------ - -/** - * Checks that the target element (described by widget/jquery or html element) - * contains exactly n matches for the selector. - * - * Example: assert.containsN(document.body, '.modal', 0) - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} selector - * @param {number} n - * @param {string} [msg] - */ -function containsN(w, selector, n, msg) { - if (typeof n !== 'number') { - throw new Error("containsN assert should be called with a number as third argument"); - } - let matches = []; - if (w instanceof owl.Component) { - if (!w.el) { - throw new Error(`containsN assert with selector '${selector}' called on an unmounted component`); + /** + * Helper function, to check if a given element + * - is unique (if it is a jquery node set) + * - is (or not) visible + * + * @private + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {boolean} shouldBeVisible + * @param {string} [msg] + */ + function _checkVisible(w, shouldBeVisible, msg) { + if (w instanceof jQuery && w.length !== 1) { + const assertion = shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'; + QUnit.assert.ok(false, `Assertion '${assertion} ${className}' targets ${w.length} elements instead of 1`); } - matches = w.el.querySelectorAll(selector); - } else { - const $el = w instanceof Widget ? w.$el : + + const el = w instanceof Widget || w instanceof owl.Component ? w.el : + w instanceof jQuery ? w[0] : w; + + msg = msg || `target should ${shouldBeVisible ? '' : 'not'} be visible`; + let isVisible = el && + el.offsetWidth && + el.offsetHeight; + if (isVisible) { + // This computation is a little more heavy and we only want to perform it + // if the above assertion has failed. + const rect = el.getBoundingClientRect(); + isVisible = rect.width + rect.height; + } + const condition = shouldBeVisible ? isVisible : !isVisible; + QUnit.assert.ok(condition, msg); + } + + //------------------------------------------------------------------------- + // Public functions + //------------------------------------------------------------------------- + + /** + * Checks that the target element (described by widget/jquery or html element) + * contains exactly n matches for the selector. + * + * Example: assert.containsN(document.body, '.modal', 0) + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} selector + * @param {number} n + * @param {string} [msg] + */ + function containsN(w, selector, n, msg) { + if (typeof n !== 'number') { + throw new Error("containsN assert should be called with a number as third argument"); + } + let matches = []; + if (w instanceof owl.Component) { + if (!w.el) { + throw new Error(`containsN assert with selector '${selector}' called on an unmounted component`); + } + matches = w.el.querySelectorAll(selector); + } else { + const $el = w instanceof Widget ? w.$el : + w instanceof HTMLElement ? $(w) : + w; // jquery element + matches = $el.find(selector); + } + if (!msg) { + msg = `Selector '${selector}' should have exactly ${n} matches (inside the target)`; + } + QUnit.assert.strictEqual(matches.length, n, msg); + } + + /** + * Checks that the target element (described by widget/jquery or html element) + * contains exactly 0 match for the selector. + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} selector + * @param {string} [msg] + */ + function containsNone(w, selector, msg) { + containsN(w, selector, 0, msg); + } + + /** + * Checks that the target element (described by widget/jquery or html element) + * contains exactly 1 match for the selector. + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} selector + * @param {string} [msg] + */ + function containsOnce(w, selector, msg) { + containsN(w, selector, 1, msg); + } + + /** + * Checks that the target element (described by widget/jquery or html element) + * - exists + * - is unique + * - has the given class (specified by className) + * + * Note that it uses the hasClass jQuery method, so it can be used to check the + * presence of more than one class ('some-class other-class'), but it is a + * little brittle, because it depends on the order of these classes: + * + * div.a.b.c: has class 'a b c', but does not have class 'a c b' + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} className + * @param {string} [msg] + */ + function hasClass(w, className, msg) { + _checkClass(w, className, true, msg); + } + + /** + * Checks that the target element (described by widget/jquery or html element) + * - exists + * - is unique + * - does not have the given class (specified by className) + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} className + * @param {string} [msg] + */ + function doesNotHaveClass(w, className, msg) { + _checkClass(w, className, false, msg); + } + + /** + * Checks that the target element (described by widget/jquery or html element) + * - exists + * - is unique + * - has the given attribute with the proper value + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} attr + * @param {string} value + * @param {string} [msg] + */ + function hasAttrValue(w, attr, value, msg) { + var $el = w instanceof Widget ? w.$el : w instanceof HTMLElement ? $(w) : w; // jquery element - matches = $el.find(selector); + + if ($el.length !== 1) { + var descr = `hasAttrValue (${attr}: ${value})`; + QUnit.assert.ok(false, + `Assertion '${descr}' targets ${$el.length} elements instead of 1` + ); + } else { + msg = msg || `attribute '${attr}' of target should be '${value}'`; + QUnit.assert.strictEqual($el.attr(attr), value, msg); + } } - if (!msg) { - msg = `Selector '${selector}' should have exactly ${n} matches (inside the target)`; + + /** + * Checks that the target element (described by widget/jquery or html element) + * - exists + * - is visible (as far as jQuery can tell: not in display none, ...) + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} [msg] + */ + function isVisible(w, msg) { + _checkVisible(w, true, msg); } - QUnit.assert.strictEqual(matches.length, n, msg); -} -/** - * Checks that the target element (described by widget/jquery or html element) - * contains exactly 1 match for the selector. - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} selector - * @param {string} [msg] - */ -function containsOnce(w, selector, msg) { - containsN(w, selector, 1, msg); -} - -/** - * Checks that the target element (described by widget/jquery or html element) - * contains exactly 0 match for the selector. - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} selector - * @param {string} [msg] - */ -function containsNone(w, selector, msg) { - containsN(w, selector, 0, msg); -} - -/** - * Checks that the target element (described by widget/jquery or html element) - * - exists - * - is unique - * - has the given class (specified by className) - * - * Note that it uses the hasClass jQuery method, so it can be used to check the - * presence of more than one class ('some-class other-class'), but it is a - * little brittle, because it depends on the order of these classes: - * - * div.a.b.c: has class 'a b c', but does not have class 'a c b' - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} className - * @param {string} [msg] - */ -function hasClass(w, className, msg) { - _checkClass(w, className, true, msg); -} - -/** - * Checks that the target element (described by widget/jquery or html element) - * - exists - * - is unique - * - does not have the given class (specified by className) - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} className - * @param {string} [msg] - */ -function doesNotHaveClass(w, className, msg) { - _checkClass(w, className, false, msg); -} - -/** - * Checks that the target element (described by widget/jquery or html element) - * - exists - * - is unique - * - has the given attribute with the proper value - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} attr - * @param {string} value - * @param {string} [msg] - */ -function hasAttrValue(w, attr, value, msg) { - var $el = w instanceof Widget ? w.$el : - w instanceof HTMLElement ? $(w) : - w; // jquery element - - if ($el.length !== 1) { - var descr = `hasAttrValue (${attr}: ${value})`; - QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`); - } else { - msg = msg || `attribute '${attr}' of target should be '${value}'`; - QUnit.assert.strictEqual($el.attr(attr), value, msg); + /** + * Checks that the target element (described by widget/jquery or html element) + * - exists + * - is not visible (as far as jQuery can tell: display none, ...) + * + * @param {Widget|jQuery|HTMLElement|owl.Component} w + * @param {string} [msg] + */ + function isNotVisible(w, msg) { + _checkVisible(w, false, msg); } -} -/** - * Checks that the target element (described by widget/jquery or html element) - * - exists - * - is visible (as far as jQuery can tell: not in display none, ...) - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} [msg] - */ -function isVisible(w, msg) { - _checkVisible(w, true, msg); -} + //------------------------------------------------------------------------- + // Exposed API + //------------------------------------------------------------------------- -/** - * Checks that the target element (described by widget/jquery or html element) - * - exists - * - is not visible (as far as jQuery can tell: display none, ...) - * - * @param {Widget|jQuery|HTMLElement|owl.Component} w - * @param {string} [msg] - */ -function isNotVisible(w, msg) { - _checkVisible(w, false, msg); -} + QUnit.assert.containsN = containsN; + QUnit.assert.containsNone = containsNone; + QUnit.assert.containsOnce = containsOnce; -//------------------------------------------------------------------------------ -// Exposed API -//------------------------------------------------------------------------------ + QUnit.assert.hasClass = hasClass; + QUnit.assert.doesNotHaveClass = doesNotHaveClass; -QUnit.assert.containsOnce = containsOnce; -QUnit.assert.containsNone = containsNone; -QUnit.assert.containsN = containsN; - -QUnit.assert.hasClass = hasClass; -QUnit.assert.doesNotHaveClass = doesNotHaveClass; - -QUnit.assert.hasAttrValue = hasAttrValue; - -QUnit.assert.isVisible = isVisible; -QUnit.assert.isNotVisible = isNotVisible; + QUnit.assert.hasAttrValue = hasAttrValue; + QUnit.assert.isVisible = isVisible; + QUnit.assert.isNotVisible = isNotVisible; }); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index a9c07973566..f2b12eaf705 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -1,220 +1,219 @@ odoo.define('web.test_utils', function (require) { -"use strict"; + "use strict"; -/** - * Test Utils - * - * In this module, we define various utility functions to help simulate a mock - * environment as close as possible as a real environment. The main function is - * certainly createView, which takes a bunch of parameters and give you back an - * instance of a view, appended in the dom, ready to be tested. - */ + /** + * Test Utils + * + * In this module, we define various utility functions to help simulate a mock + * environment as close as possible as a real environment. The main function is + * certainly createView, which takes a bunch of parameters and give you back an + * instance of a view, appended in the dom, ready to be tested. + */ -var ajax = require('web.ajax'); -var concurrency = require('web.concurrency'); -var core = require('web.core'); -var relationalFields = require('web.relational_fields'); -var session = require('web.session'); -var testUtilsCreate = require('web.test_utils_create'); -var testUtilsDom = require('web.test_utils_dom'); -var testUtilsFields = require('web.test_utils_fields'); -var testUtilsFile = require('web.test_utils_file'); -var testUtilsForm = require('web.test_utils_form'); -var testUtilsGraph = require('web.test_utils_graph'); -var testUtilsKanban = require('web.test_utils_kanban'); -var testUtilsMock = require('web.test_utils_mock'); -var testUtilsModal = require('web.test_utils_modal'); -var testUtilsPivot = require('web.test_utils_pivot'); -var tools = require('web.tools'); + var ajax = require('web.ajax'); + var concurrency = require('web.concurrency'); + var core = require('web.core'); + var relationalFields = require('web.relational_fields'); + var session = require('web.session'); + var testUtilsCreate = require('web.test_utils_create'); + var testUtilsDom = require('web.test_utils_dom'); + var testUtilsFields = require('web.test_utils_fields'); + var testUtilsFile = require('web.test_utils_file'); + var testUtilsForm = require('web.test_utils_form'); + var testUtilsGraph = require('web.test_utils_graph'); + var testUtilsKanban = require('web.test_utils_kanban'); + var testUtilsMock = require('web.test_utils_mock'); + var testUtilsModal = require('web.test_utils_modal'); + var testUtilsPivot = require('web.test_utils_pivot'); + var tools = require('web.tools'); -function deprecated(fn, type) { - var msg = `Helper 'testUtils.${fn.name}' is deprecated. ` + - `Please use 'testUtils.${type}.${fn.name}' instead.`; - return tools.deprecated(fn, msg); -} + function deprecated(fn, type) { + var msg = `Helper 'testUtils.${fn.name}' is deprecated. ` + + `Please use 'testUtils.${type}.${fn.name}' instead.`; + return tools.deprecated(fn, msg); + } -/** - * Helper function, make a promise with a public resolve function. Note that - * this is not standard and should not be used outside of tests... - * - * @returns {Promise + resolve and reject function} - */ -function makeTestPromise() { - var resolve; - var reject; - var promise = new Promise(function (_resolve, _reject) { - resolve = _resolve; - reject = _reject; - }); - promise.resolve = function () { - resolve.apply(null, arguments); + /** + * Helper function, make a promise with a public resolve function. Note that + * this is not standard and should not be used outside of tests... + * + * @returns {Promise + resolve and reject function} + */ + function makeTestPromise() { + var resolve; + var reject; + var promise = new Promise(function (_resolve, _reject) { + resolve = _resolve; + reject = _reject; + }); + promise.resolve = function () { + resolve.apply(null, arguments); + return promise; + }; + promise.reject = function () { + reject.apply(null, arguments); + return promise; + }; return promise; - }; - promise.reject = function () { - reject.apply(null, arguments); - return promise; - }; - return promise; -} + } -/** - * Make a promise with public resolve and reject functions (see - * @makeTestPromise). Perform an assert.step when the promise is - * resolved/rejected. - * - * @param {Object} assert instance object with the assertion methods - * @param {function} assert.step - * @param {string} str message to pass to assert.step - * @returns {Promise + resolve and reject function} - */ -function makeTestPromiseWithAssert(assert, str) { - var prom = makeTestPromise(); - prom.then(() => assert.step('ok ' + str)).catch(function () {}); - prom.catch(() => assert.step('ko ' + str)); - return prom; -} + /** + * Make a promise with public resolve and reject functions (see + * @makeTestPromise). Perform an assert.step when the promise is + * resolved/rejected. + * + * @param {Object} assert instance object with the assertion methods + * @param {function} assert.step + * @param {string} str message to pass to assert.step + * @returns {Promise + resolve and reject function} + */ + function makeTestPromiseWithAssert(assert, str) { + var prom = makeTestPromise(); + prom.then(() => assert.step('ok ' + str)).catch(function () { }); + prom.catch(() => assert.step('ko ' + str)); + return prom; + } -/** - * Create a new promise that can be waited by the caller in order to execute - * code after the next microtask tick and before the next jobqueue tick. - * - * @return {Promise} an already fulfilled promise - */ -async function nextMicrotaskTick() { - return Promise.resolve(); -} + /** + * Create a new promise that can be waited by the caller in order to execute + * code after the next microtask tick and before the next jobqueue tick. + * + * @return {Promise} an already fulfilled promise + */ + async function nextMicrotaskTick() { + return Promise.resolve(); + } -/** - * Returns a promise that will be resolved after the tick after the - * nextAnimationFrame - * - * This is usefull to guarantee that OWL has had the time to render - * - * @returns {Promise} - */ -async function nextTick() { - return testUtilsDom.returnAfterNextAnimationFrame(); -} + /** + * Returns a promise that will be resolved after the tick after the + * nextAnimationFrame + * + * This is usefull to guarantee that OWL has had the time to render + * + * @returns {Promise} + */ + async function nextTick() { + return testUtilsDom.returnAfterNextAnimationFrame(); + } -// Loading static files cannot be properly simulated when their real content is -// really needed. This is the case for static XML files so we load them here, -// before starting the qunit test suite. -// (session.js is in charge of loading the static xml bundle and we also have -// to load xml files that are normally lazy loaded by specific widgets). -return Promise.all([ - session.is_bound, - ajax.loadXML('/web/static/src/xml/debug.xml', core.qweb), - ajax.loadXML('/web/static/src/xml/dialog.xml', core.qweb), - ajax.loadXML('/web/static/src/xml/translation_dialog.xml', core.qweb), -]).then(function () { - setTimeout(function () { - // jquery autocomplete refines the search in a setTimeout() parameterized - // with a delay, so we force this delay to 0 s.t. the dropdown is filtered - // directly on the next tick - relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + // Loading static files cannot be properly simulated when their real content is + // really needed. This is the case for static XML files so we load them here, + // before starting the qunit test suite. + // (session.js is in charge of loading the static xml bundle and we also have + // to load xml files that are normally lazy loaded by specific widgets). + return Promise.all([ + session.is_bound, + ajax.loadXML('/web/static/src/xml/debug.xml', core.qweb), + ajax.loadXML('/web/static/src/xml/dialog.xml', core.qweb), + ajax.loadXML('/web/static/src/xml/translation_dialog.xml', core.qweb), + ]).then(function () { + setTimeout(function () { + // jquery autocomplete refines the search in a setTimeout() parameterized + // with a delay, so we force this delay to 0 s.t. the dropdown is filtered + // directly on the next tick + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; - // this is done with the hope that tests are - // only started all together... - QUnit.start(); - }, 0); - return { - mock: { - addMockEnvironment: testUtilsMock.addMockEnvironment, - getMockedOwlEnv: testUtilsMock.getMockedOwlEnv, - intercept: testUtilsMock.intercept, - patch: testUtilsMock.patch, - patchDate: testUtilsMock.patchDate, - unpatch: testUtilsMock.unpatch, - fieldsViewGet: testUtilsMock.fieldsViewGet, - patchSetTimeout: testUtilsMock.patchSetTimeout, - }, - dom: { - triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, - triggerMouseEvent: testUtilsDom.triggerMouseEvent, - triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, - dragAndDrop: testUtilsDom.dragAndDrop, - openDatepicker: testUtilsDom.openDatepicker, - click: testUtilsDom.click, - clickFirst: testUtilsDom.clickFirst, - clickLast: testUtilsDom.clickLast, - triggerEvents: testUtilsDom.triggerEvents, - triggerEvent: testUtilsDom.triggerEvent, - }, - form: { - clickEdit: testUtilsForm.clickEdit, - clickSave: testUtilsForm.clickSave, - clickCreate: testUtilsForm.clickCreate, - clickDiscard: testUtilsForm.clickDiscard, - reload: testUtilsForm.reload, - }, - graph: { - reload: testUtilsGraph.reload, - }, - kanban: { - reload: testUtilsKanban.reload, - clickCreate: testUtilsKanban.clickCreate, - quickCreate: testUtilsKanban.quickCreate, - toggleGroupSettings: testUtilsKanban.toggleGroupSettings, - toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown, - }, - modal: { - clickButton: testUtilsModal.clickButton, - }, - pivot: { - clickMeasure: testUtilsPivot.clickMeasure, - toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown, - reload: testUtilsPivot.reload, - }, - fields: { - many2one: { - clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown, - clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem, - clickItem: testUtilsFields.clickM2OItem, - searchAndClickItem: testUtilsFields.searchAndClickM2OItem, + // this is done with the hope that tests are + // only started all together... + QUnit.start(); + }, 0); + return { + mock: { + addMockEnvironment: testUtilsMock.addMockEnvironment, + getMockedOwlEnv: testUtilsMock.getMockedOwlEnv, + intercept: testUtilsMock.intercept, + patch: testUtilsMock.patch, + patchDate: testUtilsMock.patchDate, + unpatch: testUtilsMock.unpatch, + fieldsViewGet: testUtilsMock.fieldsViewGet, + patchSetTimeout: testUtilsMock.patchSetTimeout, + }, + dom: { + triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, + triggerMouseEvent: testUtilsDom.triggerMouseEvent, + triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, + dragAndDrop: testUtilsDom.dragAndDrop, + openDatepicker: testUtilsDom.openDatepicker, + click: testUtilsDom.click, + clickFirst: testUtilsDom.clickFirst, + clickLast: testUtilsDom.clickLast, + triggerEvents: testUtilsDom.triggerEvents, + triggerEvent: testUtilsDom.triggerEvent, + }, + form: { + clickEdit: testUtilsForm.clickEdit, + clickSave: testUtilsForm.clickSave, + clickCreate: testUtilsForm.clickCreate, + clickDiscard: testUtilsForm.clickDiscard, + reload: testUtilsForm.reload, + }, + graph: { + reload: testUtilsGraph.reload, + }, + kanban: { + reload: testUtilsKanban.reload, + clickCreate: testUtilsKanban.clickCreate, + quickCreate: testUtilsKanban.quickCreate, + toggleGroupSettings: testUtilsKanban.toggleGroupSettings, + toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown, + }, + modal: { + clickButton: testUtilsModal.clickButton, + }, + pivot: { + clickMeasure: testUtilsPivot.clickMeasure, + toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown, + reload: testUtilsPivot.reload, + }, + fields: { + many2one: { + clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown, + clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem, + clickItem: testUtilsFields.clickM2OItem, + searchAndClickItem: testUtilsFields.searchAndClickM2OItem, + }, + editInput: testUtilsFields.editInput, + editSelect: testUtilsFields.editSelect, + editAndTrigger: testUtilsFields.editAndTrigger, + triggerKey: testUtilsFields.triggerKey, + triggerKeydown: testUtilsFields.triggerKeydown, + triggerKeyup: testUtilsFields.triggerKeyup, + }, + file: { + createFile: testUtilsFile.createFile, + dragoverFile: testUtilsFile.dragoverFile, + dropFile: testUtilsFile.dropFile, + dropFiles: testUtilsFile.dropFiles, }, - editInput: testUtilsFields.editInput, - editSelect: testUtilsFields.editSelect, - editAndTrigger: testUtilsFields.editAndTrigger, - triggerKey: testUtilsFields.triggerKey, - triggerKeydown: testUtilsFields.triggerKeydown, - triggerKeyup: testUtilsFields.triggerKeyup, - }, - file: { - createFile: testUtilsFile.createFile, - dragoverFile: testUtilsFile.dragoverFile, - dropFile: testUtilsFile.dropFile, - dropFiles: testUtilsFile.dropFiles, - }, - createActionManager: testUtilsCreate.createActionManager, - createDebugManager: testUtilsCreate.createDebugManager, - createAsyncView: testUtilsCreate.createView, - createCalendarView: testUtilsCreate.createCalendarView, - createControlPanel: testUtilsCreate.createControlPanel, - createView: testUtilsCreate.createView, - createModel: testUtilsCreate.createModel, - createParent: testUtilsCreate.createParent, - makeTestPromise: makeTestPromise, - makeTestPromiseWithAssert: makeTestPromiseWithAssert, - nextMicrotaskTick: nextMicrotaskTick, - nextTick: nextTick, - prepareTarget: testUtilsCreate.prepareTarget, - returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame, - - // backward-compatibility - addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'), - dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'), - fieldsViewGet: deprecated(testUtilsMock.fieldsViewGet, 'mock'), - intercept: deprecated(testUtilsMock.intercept, 'mock'), - openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'), - patch: deprecated(testUtilsMock.patch, 'mock'), - patchDate: deprecated(testUtilsMock.patchDate, 'mock'), - triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'), - triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'), - triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'), - unpatch: deprecated(testUtilsMock.unpatch, 'mock'), - }; -}); + createActionManager: testUtilsCreate.createActionManager, + createDebugManager: testUtilsCreate.createDebugManager, + createAsyncView: testUtilsCreate.createView, + createCalendarView: testUtilsCreate.createCalendarView, + createControlPanel: testUtilsCreate.createControlPanel, + createView: testUtilsCreate.createView, + createModel: testUtilsCreate.createModel, + createParent: testUtilsCreate.createParent, + makeTestPromise: makeTestPromise, + makeTestPromiseWithAssert: makeTestPromiseWithAssert, + nextMicrotaskTick: nextMicrotaskTick, + nextTick: nextTick, + prepareTarget: testUtilsCreate.prepareTarget, + returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame, + // backward-compatibility + addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'), + dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'), + fieldsViewGet: deprecated(testUtilsMock.fieldsViewGet, 'mock'), + intercept: deprecated(testUtilsMock.intercept, 'mock'), + openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'), + patch: deprecated(testUtilsMock.patch, 'mock'), + patchDate: deprecated(testUtilsMock.patchDate, 'mock'), + triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'), + triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'), + triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'), + unpatch: deprecated(testUtilsMock.unpatch, 'mock'), + }; + }); }); diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index 684485347a4..717b845e80e 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -1,411 +1,411 @@ odoo.define('web.test_utils_create', function (require) { -"use strict"; + "use strict"; -/** - * Create Test Utils - * - * This module defines various utility functions to help creating mock widgets - * - * Note that all methods defined in this module are exported in the main - * testUtils file. - */ + /** + * Create Test Utils + * + * This module defines various utility functions to help creating mock widgets + * + * Note that all methods defined in this module are exported in the main + * testUtils file. + */ -var ActionManager = require('web.ActionManager'); -var config = require('web.config'); -var ControlPanelView = require('web.ControlPanelView'); -var concurrency = require('web.concurrency'); -var DebugManager = require('web.DebugManager.Backend'); -var dom = require('web.dom'); -var testUtilsMock = require('web.test_utils_mock'); -var Widget = require('web.Widget'); + var ActionManager = require('web.ActionManager'); + var config = require('web.config'); + var ControlPanelView = require('web.ControlPanelView'); + var concurrency = require('web.concurrency'); + var DebugManager = require('web.DebugManager.Backend'); + var dom = require('web.dom'); + var testUtilsMock = require('web.test_utils_mock'); + var Widget = require('web.Widget'); -/** - * create and return an instance of ActionManager with all rpcs going through a - * mock method using the data, actions and archs objects as sources. - * - * @param {Object} [params] - * @param {Object} [params.actions] the actions given to the mock server - * @param {Object} [params.archs] this archs given to the mock server - * @param {Object} [params.data] the business data given to the mock server - * @param {function} [params.mockRPC] - * @returns {ActionManager} - */ -async function createActionManager(params) { - params = params || {}; - const target = prepareTarget(params.debug); + /** + * create and return an instance of ActionManager with all rpcs going through a + * mock method using the data, actions and archs objects as sources. + * + * @param {Object} [params] + * @param {Object} [params.actions] the actions given to the mock server + * @param {Object} [params.archs] this archs given to the mock server + * @param {Object} [params.data] the business data given to the mock server + * @param {function} [params.mockRPC] + * @returns {ActionManager} + */ + async function createActionManager(params) { + params = params || {}; + const target = prepareTarget(params.debug); - var widget = new Widget(); - // when 'document' addon is installed, the sidebar does a 'search_read' on - // model 'ir_attachment' each time a record is open, so we monkey-patch - // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly, - // whether or not 'document' is installed - var mockRPC = params.mockRPC; - _.extend(params, { - mockRPC: function (route, args) { - if (args.model === 'ir.attachment') { - return Promise.resolve([]); - } - if (mockRPC) { - return mockRPC.apply(this, arguments); - } - return this._super.apply(this, arguments); - }, - }); - testUtilsMock.addMockEnvironment(widget, _.defaults(params, { debounce: false })); - await widget.prependTo(target); - widget.$el.addClass('o_web_client'); - if (config.device.isMobile) { - widget.$el.addClass('o_touch_device'); - } - - var userContext = params.context && params.context.user_context || {}; - var actionManager = new ActionManager(widget, userContext); - - var originalDestroy = ActionManager.prototype.destroy; - actionManager.destroy = function () { - actionManager.destroy = originalDestroy; - widget.destroy(); - }; - var fragment = document.createDocumentFragment(); - return actionManager.appendTo(fragment).then(function () { - dom.append(widget.$el, fragment, { - callbacks: [{ widget: actionManager }], - in_DOM: true, - }); - return actionManager; - }); -} - -/** - * create a view from various parameters. Here, a view means a javascript - * instance of an AbstractView class, such as a form view, a list view or a - * kanban view. - * - * It returns the instance of the view, properly created, with all rpcs going - * through a mock method using the data object as source, and already loaded/ - * started. - * - * @param {Object} params - * @param {string} params.arch the xml (arch) of the view to be instantiated - * @param {any[]} [params.domain] the initial domain for the view - * @param {Object} [params.context] the initial context for the view - * @param {string[]} [params.groupBy] the initial groupBy for the view - * @param {integer} [params.fieldDebounce=0] the debounce value to use for the - * duration of the test. - * @param {AbstractView} params.View the class that will be instantiated - * @param {string} params.model a model name, will be given to the view - * @param {Object} params.intercepts an object with event names as key, and - * callback as value. Each key,value will be used to intercept the event. - * Note that this is particularly useful if you want to intercept events going - * up in the init process of the view, because there are no other way to do it - * after this method returns - * @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true. - * Default is false. - * @returns {Promise} resolves with the instance of the view - */ -async function createView(params) { - const target = prepareTarget(params.debug); - var widget = new Widget(); - // reproduce the DOM environment of views - var $webClient = $('
').addClass('o_web_client').prependTo(target); - var $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); - - - // add mock environment: mock server, session, fieldviewget, ... - var mockServer = testUtilsMock.addMockEnvironment(widget, params); - var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); - - // create the view - var View = params.View; - var viewOptions = _.defaults({}, params.viewOptions, { - modelName: params.model || 'foo', - ids: 'res_id' in params ? [params.res_id] : undefined, - currentId: 'res_id' in params ? params.res_id : undefined, - domain: params.domain || [], - context: params.context || {}, - hasSidebar: false, - }); - // patch the View to handle the groupBy given in params, as we can't give it - // in init (unlike the domain and context which can be set in the action) - testUtilsMock.patch(View, { - _updateMVCParams: function () { - this._super.apply(this, arguments); - this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || []; - testUtilsMock.unpatch(View); - }, - }); - if ('hasSelectors' in params) { - viewOptions.hasSelectors = params.hasSelectors; - } - - var view; - if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') { - // TODO: probably needs to create an helper just for that - view = new params.View({ - viewInfo: viewInfo, - modelName: params.model || 'foo', - }); - } else { - viewOptions.controlPanelFieldsView = testUtilsMock.fieldsViewGet(mockServer, { - arch: params.archs && params.archs[params.model + ',false,search'] || '', - fields: viewInfo.fields, - model: params.model, - }); - view = new params.View(viewInfo, viewOptions); - } - - if (params.interceptsPropagate) { - _.each(params.interceptsPropagate, function (cb, name) { - testUtilsMock.intercept(widget, name, cb, true); - }); - } - - return view.getController(widget).then(function (view) { - // override the view's 'destroy' so that it calls 'destroy' on the widget - // instead, as the widget is the parent of the view and the mockServer. - view.__destroy = view.destroy; - view.destroy = function () { - // remove the override to properly destroy the view and its children - // when it will be called the second time (by its parent) - delete view.destroy; - widget.destroy(); - $webClient.remove(); - }; - - // render the view in a fragment as they must be able to render correctly - // without being in the DOM - var fragment = document.createDocumentFragment(); - return view.appendTo(fragment).then( () => { - dom.prepend($actionManager, fragment, { - callbacks: [{ widget: view }], - in_DOM: true, - }); - - if (!params.doNotDisableAHref) { - [...view.el.getElementsByTagName('A')].forEach((elem) => { - elem.addEventListener('click', (ev) => { - ev.preventDefault(); - }); - }); - } - return view; - }); - }); -} - -/** - * Similar as createView, but specific for calendar views. Some calendar - * tests need to trigger positional clicks on the DOM produced by fullcalendar. - * Those tests must use this helper with option positionalClicks set to true. - * This will move the rendered calendar to the body (required to do positional - * clicks), and wait for a setTimeout(0) before returning, because fullcalendar - * makes the calendar scroll to 6:00 in a setTimeout(0), which might have an - * impact according to where we want to trigger positional clicks. - * - * @param {Object} params see @createView - * @param {Object} [options] - * @param {boolean} [options.positionalClicks=false] - * @returns {Promise} - */ -async function createCalendarView(params, options) { - var calendar = await createView(params); - if (!options || !options.positionalClicks) { - return calendar; - } - var $view = $('#qunit-fixture').contents(); - $view.prependTo('body'); - var destroy = calendar.destroy; - calendar.destroy = function () { - $view.remove(); - destroy(); - }; - await concurrency.delay(0); - return calendar; -} - -/** - * create a controlPanel from various parameters. - * - * It returns an instance of ControlPanelController - * - * @param {Object} [params={}] - * @param {Object} [params.action={}] - * @param {Object} [params.context={}] - * @param {string} [params.domain=[]] - * @param {integer} [params.fieldDebounce=0] the debounce value to use for the - * duration of the test. - * @param {Object} params.intercepts an object with event names as key, and - * callback as value. Each key,value will be used to intercept the event. - * Note that this is particularly useful if you want to intercept events going - * up in the init process of the view, because there are no other way to do it - * after this method returns - * @param {string} [params.modelName] - * @param {string[]} [params.searchMenuTypes = ['filter', 'groupBy', 'favorite']] - * determines search menus displayed. - * @param {string} [params.template] the QWeb template to render - * @param {Object} [params.viewInfo={arch: '', fields: {}}] - a controlpanel (or search) fieldsview - * @param {string} [params.viewInfo.arch] - * @param {boolean} [params.context.no_breadcrumbs=false] if set to true, - * breadcrumbs won't be rendered - * @param {boolean} [params.withBreadcrumbs=true] if set to false, - * breadcrumbs won't be rendered - * @param {boolean} [params.withSearchBar=true] if set to false, no default - * search bar will be rendered - * - * @returns {Promise} resolves with an instance of the ControlPanelController - */ -function createControlPanel(params) { - params = params || {}; - const target = prepareTarget(params.debug); - // reproduce the DOM environment of a view control panel - var $webClient = $('
').addClass('o_web_client').prependTo(target); - var $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); - var $action = $('
').addClass('o_action').appendTo($actionManager); - - var widget = new Widget(); - // add mock environment: mock server, session, fieldviewget, ... - var mockServer = testUtilsMock.addMockEnvironment(widget, params); - if (!params.viewInfo) { - try { - params.viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); - } catch (e) { - // if an error occurs we keep params.viewInfo undefined. - // It will be set to {arch: '', fields: {}} in - // ControlPanelView init function. - } - } - - var viewOptions = _.defaults({}, params.viewOptions, { - context: params.context, - modelName: params.model, - searchMenuTypes: params.searchMenuTypes, - viewInfo: params.viewInfo, - }); - var controlPanelView = new ControlPanelView(viewOptions); - return controlPanelView.getController(widget).then(function (controlPanel) { - // override the controlPanel's 'destroy' so that it calls 'destroy' on - // the widget instead, as the widget is the parent of the controlPanel - // and the mockServer. - controlPanel.__destroy = controlPanel.destroy; - controlPanel.destroy = function () { - // remove the override to properly destroy the controlPanel and its - // children when it will be called the second time (by its parent) - delete controlPanel.destroy; - widget.destroy(); - $webClient.remove(); - }; - - // render the controlPanel in a fragment as it must be able to render - // correctly without being in the DOM - var fragment = document.createDocumentFragment(); - return controlPanel.appendTo(fragment).then(function () { - dom.prepend($action, fragment, { - callbacks: [{ widget: controlPanel }], - in_DOM: true, - }); - return controlPanel; - }); - }); -} -/** - * Create and return an instance of DebugManager with all rpcs going through a - * mock method, assuming that the user has access rights, and is an admin. - * - * @param {Object} [params={}] - */ -var createDebugManager = function (params) { - params = params || {}; - var mockRPC = params.mockRPC; - _.extend(params, { - mockRPC: function (route, args) { - if (args.method === 'check_access_rights') { - return Promise.resolve(true); - } - if (args.method === 'xmlid_to_res_id') { - return Promise.resolve(true); - } - if (mockRPC) { - return mockRPC.apply(this, arguments); - } - return this._super.apply(this, arguments); - }, - session: { - user_has_group: function (group) { - if (group === 'base.group_no_one') { - return Promise.resolve(true); + var widget = new Widget(); + // when 'document' addon is installed, the sidebar does a 'search_read' on + // model 'ir_attachment' each time a record is open, so we monkey-patch + // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly, + // whether or not 'document' is installed + var mockRPC = params.mockRPC; + _.extend(params, { + mockRPC: function (route, args) { + if (args.model === 'ir.attachment') { + return Promise.resolve([]); + } + if (mockRPC) { + return mockRPC.apply(this, arguments); } return this._super.apply(this, arguments); }, - }, - }); - var debugManager = new DebugManager(); - testUtilsMock.addMockEnvironment(debugManager, params); - return debugManager; -}; + }); + testUtilsMock.addMockEnvironment(widget, _.defaults(params, { debounce: false })); + await widget.prependTo(target); + widget.$el.addClass('o_web_client'); + if (config.device.isMobile) { + widget.$el.addClass('o_touch_device'); + } -/** - * create a model from given parameters. - * - * @param {Object} params This object will be given to addMockEnvironment, so - * any parameters from that method applies - * @param {Class} params.Model the model class to use - * @returns {Model} - */ -function createModel(params) { - var widget = new Widget(); + var userContext = params.context && params.context.user_context || {}; + var actionManager = new ActionManager(widget, userContext); - var model = new params.Model(widget); + var originalDestroy = ActionManager.prototype.destroy; + actionManager.destroy = function () { + actionManager.destroy = originalDestroy; + widget.destroy(); + }; + var fragment = document.createDocumentFragment(); + return actionManager.appendTo(fragment).then(function () { + dom.append(widget.$el, fragment, { + callbacks: [{ widget: actionManager }], + in_DOM: true, + }); + return actionManager; + }); + } - testUtilsMock.addMockEnvironment(widget, params); + /** + * Similar as createView, but specific for calendar views. Some calendar + * tests need to trigger positional clicks on the DOM produced by fullcalendar. + * Those tests must use this helper with option positionalClicks set to true. + * This will move the rendered calendar to the body (required to do positional + * clicks), and wait for a setTimeout(0) before returning, because fullcalendar + * makes the calendar scroll to 6:00 in a setTimeout(0), which might have an + * impact according to where we want to trigger positional clicks. + * + * @param {Object} params see @createView + * @param {Object} [options] + * @param {boolean} [options.positionalClicks=false] + * @returns {Promise} + */ + async function createCalendarView(params, options) { + var calendar = await createView(params); + if (!options || !options.positionalClicks) { + return calendar; + } + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); + var destroy = calendar.destroy; + calendar.destroy = function () { + $view.remove(); + destroy(); + }; + await concurrency.delay(0); + return calendar; + } - // override the model's 'destroy' so that it calls 'destroy' on the widget - // instead, as the widget is the parent of the model and the mockServer. - model.destroy = function () { - // remove the override to properly destroy the model when it will be - // called the second time (by its parent) - delete model.destroy; - widget.destroy(); + /** + * create a controlPanel from various parameters. + * + * It returns an instance of ControlPanelController + * + * @param {Object} [params={}] + * @param {Object} [params.action={}] + * @param {Object} [params.context={}] + * @param {string} [params.domain=[]] + * @param {integer} [params.fieldDebounce=0] the debounce value to use for the + * duration of the test. + * @param {Object} params.intercepts an object with event names as key, and + * callback as value. Each key,value will be used to intercept the event. + * Note that this is particularly useful if you want to intercept events going + * up in the init process of the view, because there are no other way to do it + * after this method returns + * @param {string} [params.modelName] + * @param {string[]} [params.searchMenuTypes = ['filter', 'groupBy', 'favorite']] + * determines search menus displayed. + * @param {string} [params.template] the QWeb template to render + * @param {Object} [params.viewInfo={arch: '', fields: {}}] + a controlpanel (or search) fieldsview + * @param {string} [params.viewInfo.arch] + * @param {boolean} [params.context.no_breadcrumbs=false] if set to true, + * breadcrumbs won't be rendered + * @param {boolean} [params.withBreadcrumbs=true] if set to false, + * breadcrumbs won't be rendered + * @param {boolean} [params.withSearchBar=true] if set to false, no default + * search bar will be rendered + * + * @returns {Promise} resolves with an instance of the ControlPanelController + */ + function createControlPanel(params) { + params = params || {}; + const target = prepareTarget(params.debug); + // reproduce the DOM environment of a view control panel + var $webClient = $('
').addClass('o_web_client').prependTo(target); + var $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); + var $action = $('
').addClass('o_action').appendTo($actionManager); + + var widget = new Widget(); + // add mock environment: mock server, session, fieldviewget, ... + var mockServer = testUtilsMock.addMockEnvironment(widget, params); + if (!params.viewInfo) { + try { + params.viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + } catch (e) { + // if an error occurs we keep params.viewInfo undefined. + // It will be set to {arch: '', fields: {}} in + // ControlPanelView init function. + } + } + + var viewOptions = _.defaults({}, params.viewOptions, { + context: params.context, + modelName: params.model, + searchMenuTypes: params.searchMenuTypes, + viewInfo: params.viewInfo, + }); + var controlPanelView = new ControlPanelView(viewOptions); + return controlPanelView.getController(widget).then(function (controlPanel) { + // override the controlPanel's 'destroy' so that it calls 'destroy' on + // the widget instead, as the widget is the parent of the controlPanel + // and the mockServer. + controlPanel.__destroy = controlPanel.destroy; + controlPanel.destroy = function () { + // remove the override to properly destroy the controlPanel and its + // children when it will be called the second time (by its parent) + delete controlPanel.destroy; + widget.destroy(); + $webClient.remove(); + }; + + // render the controlPanel in a fragment as it must be able to render + // correctly without being in the DOM + var fragment = document.createDocumentFragment(); + return controlPanel.appendTo(fragment).then(function () { + dom.prepend($action, fragment, { + callbacks: [{ widget: controlPanel }], + in_DOM: true, + }); + return controlPanel; + }); + }); + } + + /** + * Create and return an instance of DebugManager with all rpcs going through a + * mock method, assuming that the user has access rights, and is an admin. + * + * @param {Object} [params={}] + */ + var createDebugManager = function (params) { + params = params || {}; + var mockRPC = params.mockRPC; + _.extend(params, { + mockRPC: function (route, args) { + if (args.method === 'check_access_rights') { + return Promise.resolve(true); + } + if (args.method === 'xmlid_to_res_id') { + return Promise.resolve(true); + } + if (mockRPC) { + return mockRPC.apply(this, arguments); + } + return this._super.apply(this, arguments); + }, + session: { + user_has_group: function (group) { + if (group === 'base.group_no_one') { + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + }, + }); + var debugManager = new DebugManager(); + testUtilsMock.addMockEnvironment(debugManager, params); + return debugManager; }; - return model; -} + /** + * create a model from given parameters. + * + * @param {Object} params This object will be given to addMockEnvironment, so + * any parameters from that method applies + * @param {Class} params.Model the model class to use + * @returns {Model} + */ + function createModel(params) { + var widget = new Widget(); -/** - * create a widget parent from given parameters. - * - * @param {Object} params This object will be given to addMockEnvironment, so - * any parameters from that method applies - * @returns {Widget} - */ -function createParent(params) { - var widget = new Widget(); - testUtilsMock.addMockEnvironment(widget, params); - return widget; -} + var model = new params.Model(widget); -/** - * Get the target (fixture or body) of the document and adds event listeners - * to intercept custom or DOM events. - * - * @param {boolean} [debug=false] if true, the widget will be appended in - * the DOM. Also, RPCs and uncaught OdooEvent will be logged - * @returns {HTMLElement} - */ -function prepareTarget(debug=false) { - document.body.classList.toggle('debug', debug); - return debug ? document.body : document.querySelector("#qunit-fixture"); -} + testUtilsMock.addMockEnvironment(widget, params); -return { - createActionManager: createActionManager, - createCalendarView: createCalendarView, - createControlPanel: createControlPanel, - createDebugManager: createDebugManager, - createModel: createModel, - createParent: createParent, - createView: createView, - prepareTarget: prepareTarget, -}; + // override the model's 'destroy' so that it calls 'destroy' on the widget + // instead, as the widget is the parent of the model and the mockServer. + model.destroy = function () { + // remove the override to properly destroy the model when it will be + // called the second time (by its parent) + delete model.destroy; + widget.destroy(); + }; + return model; + } + + /** + * create a widget parent from given parameters. + * + * @param {Object} params This object will be given to addMockEnvironment, so + * any parameters from that method applies + * @returns {Widget} + */ + function createParent(params) { + var widget = new Widget(); + testUtilsMock.addMockEnvironment(widget, params); + return widget; + } + + /** + * create a view from various parameters. Here, a view means a javascript + * instance of an AbstractView class, such as a form view, a list view or a + * kanban view. + * + * It returns the instance of the view, properly created, with all rpcs going + * through a mock method using the data object as source, and already loaded/ + * started. + * + * @param {Object} params + * @param {string} params.arch the xml (arch) of the view to be instantiated + * @param {any[]} [params.domain] the initial domain for the view + * @param {Object} [params.context] the initial context for the view + * @param {string[]} [params.groupBy] the initial groupBy for the view + * @param {integer} [params.fieldDebounce=0] the debounce value to use for the + * duration of the test. + * @param {AbstractView} params.View the class that will be instantiated + * @param {string} params.model a model name, will be given to the view + * @param {Object} params.intercepts an object with event names as key, and + * callback as value. Each key,value will be used to intercept the event. + * Note that this is particularly useful if you want to intercept events going + * up in the init process of the view, because there are no other way to do it + * after this method returns + * @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true. + * Default is false. + * @returns {Promise} resolves with the instance of the view + */ + async function createView(params) { + const target = prepareTarget(params.debug); + var widget = new Widget(); + // reproduce the DOM environment of views + var $webClient = $('
').addClass('o_web_client').prependTo(target); + var $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); + + + // add mock environment: mock server, session, fieldviewget, ... + var mockServer = testUtilsMock.addMockEnvironment(widget, params); + var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + + // create the view + var View = params.View; + var viewOptions = _.defaults({}, params.viewOptions, { + modelName: params.model || 'foo', + ids: 'res_id' in params ? [params.res_id] : undefined, + currentId: 'res_id' in params ? params.res_id : undefined, + domain: params.domain || [], + context: params.context || {}, + hasSidebar: false, + }); + // patch the View to handle the groupBy given in params, as we can't give it + // in init (unlike the domain and context which can be set in the action) + testUtilsMock.patch(View, { + _updateMVCParams: function () { + this._super.apply(this, arguments); + this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || []; + testUtilsMock.unpatch(View); + }, + }); + if ('hasSelectors' in params) { + viewOptions.hasSelectors = params.hasSelectors; + } + + var view; + if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') { + // TODO: probably needs to create an helper just for that + view = new params.View({ + viewInfo: viewInfo, + modelName: params.model || 'foo', + }); + } else { + viewOptions.controlPanelFieldsView = testUtilsMock.fieldsViewGet(mockServer, { + arch: params.archs && params.archs[params.model + ',false,search'] || '', + fields: viewInfo.fields, + model: params.model, + }); + view = new params.View(viewInfo, viewOptions); + } + + if (params.interceptsPropagate) { + _.each(params.interceptsPropagate, function (cb, name) { + testUtilsMock.intercept(widget, name, cb, true); + }); + } + + return view.getController(widget).then(function (view) { + // override the view's 'destroy' so that it calls 'destroy' on the widget + // instead, as the widget is the parent of the view and the mockServer. + view.__destroy = view.destroy; + view.destroy = function () { + // remove the override to properly destroy the view and its children + // when it will be called the second time (by its parent) + delete view.destroy; + widget.destroy(); + $webClient.remove(); + }; + + // render the view in a fragment as they must be able to render correctly + // without being in the DOM + var fragment = document.createDocumentFragment(); + return view.appendTo(fragment).then(() => { + dom.prepend($actionManager, fragment, { + callbacks: [{ widget: view }], + in_DOM: true, + }); + + if (!params.doNotDisableAHref) { + [...view.el.getElementsByTagName('A')].forEach((elem) => { + elem.addEventListener('click', (ev) => { + ev.preventDefault(); + }); + }); + } + return view; + }); + }); + } + + /** + * Get the target (fixture or body) of the document and adds event listeners + * to intercept custom or DOM events. + * + * @param {boolean} [debug=false] if true, the widget will be appended in + * the DOM. Also, RPCs and uncaught OdooEvent will be logged + * @returns {HTMLElement} + */ + function prepareTarget(debug = false) { + document.body.classList.toggle('debug', debug); + return debug ? document.body : document.querySelector("#qunit-fixture"); + } + + return { + createActionManager: createActionManager, + createCalendarView: createCalendarView, + createControlPanel: createControlPanel, + createDebugManager: createDebugManager, + createModel: createModel, + createParent: createParent, + createView: createView, + prepareTarget: prepareTarget, + }; }); diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js index a5745b83d2c..3511bb398ce 100644 --- a/addons/web/static/tests/helpers/test_utils_dom.js +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -1,475 +1,480 @@ odoo.define('web.test_utils_dom', function (require) { -"use strict"; + "use strict"; -var concurrency = require('web.concurrency'); + var concurrency = require('web.concurrency'); -/** - * DOM Test Utils - * - * This module defines various utility functions to help simulate DOM events. - * - * Note that all methods defined in this module are exported in the main - * testUtils file. - */ + /** + * DOM Test Utils + * + * This module defines various utility functions to help simulate DOM events. + * + * Note that all methods defined in this module are exported in the main + * testUtils file. + */ -/** - * Returns a promise that will be resolved after the nextAnimationFrame after - * the next tick - * - * This is useful to guarantee that OWL has had the time to render - * - * @returns {Promise} - */ -async function returnAfterNextAnimationFrame() { - await concurrency.delay(0); - await new Promise(resolve => { - window.requestAnimationFrame(resolve); - }); -} + //------------------------------------------------------------------------- + // Private + //------------------------------------------------------------------------- -/** - * Check if an object is an instance of EventTarget. - * - * @param {Object} node - * @returns {boolean} - */ -function isEventTarget(node) { - if (node instanceof window.top.EventTarget) { - return true; - } - const contextWindow = node.defaultView || // document - (node.ownerDocument && node.ownerDocument.defaultView); // iframe node - return contextWindow && node instanceof contextWindow.EventTarget; -} - -/** - * simulate a drag and drop operation between 2 jquery nodes: $el and $to. - * This is a crude simulation, with only the mousedown, mousemove and mouseup - * events, but it is enough to help test drag and drop operations with jqueryUI - * sortable. - * - * @todo: remove the withTrailingClick option when the jquery update branch is - * merged. This is not the default as of now, because handlers are triggered - * synchronously, which is not the same as the 'reality'. - * - * @param {jQuery|EventTarget} $el - * @param {jQuery|EventTarget} $to - * @param {Object} [options] - * @param {string|Object} [options.position='center'] target position: - * can either be one of {'top', 'bottom', 'left', 'right'} or - * an object with two attributes (top and left)) - * @param {boolean} [options.disableDrop=false] whether to trigger the drop action - * @param {boolean} [options.continueMove=false] whether to trigger the - * mousedown action (will only work after another call of this function with - * without this option) - * @param {boolean} [options.withTrailingClick=false] if true, this utility - * function will also trigger a click on the target after the mouseup event - * (this is actually what happens when a drag and drop operation is done) - * @param {jQuery|EventTarget} [options.mouseenterTarget=undefined] target of the mouseenter event - * @param {jQuery|EventTarget} [options.mousedownTarget=undefined] target of the mousedown event - * @param {jQuery|EventTarget} [options.mousemoveTarget=undefined] target of the mousemove event - * @param {jQuery|EventTarget} [options.mouseupTarget=undefined] target of the mouseup event - * @param {jQuery|EventTarget} [options.ctrlKey=undefined] if the ctrl key should be considered pressed at the time of mouseup - * @returns {Promise} - */ -async function dragAndDrop($el, $to, options) { - let el = null; - if (isEventTarget($el)) { - el = $el; - $el = $(el); - } - if (isEventTarget($to)) { - $to = $($to); - } - options = options || {}; - var position = options.position || 'center'; - var elementCenter = $el.offset(); - var toOffset = $to.offset(); - - if (_.isObject(position)) { - toOffset.top += position.top + 1; - toOffset.left += position.left + 1; - } else { - toOffset.top += $to.outerHeight() / 2; - toOffset.left += $to.outerWidth() / 2; - var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1; - if (position === 'top') { - toOffset.top -= $to.outerHeight() / 2 + vertical_offset; - } else if (position === 'bottom') { - toOffset.top += $to.outerHeight() / 2 - vertical_offset; - } else if (position === 'left') { - toOffset.left -= $to.outerWidth() / 2; - } else if (position === 'right') { - toOffset.left += $to.outerWidth() / 2; - } + function keyboardEventBubble(args) { + return Object.assign({}, args, { bubbles: true, keyCode: args.which }); } - if ($to[0].ownerDocument !== document) { - // we are in an iframe - var bound = $('iframe')[0].getBoundingClientRect(); - toOffset.left += bound.left; - toOffset.top += bound.top; - } - await triggerEvent(options.mouseenterTarget || el || $el, 'mouseover', {}, true); - if (!(options.continueMove)) { - elementCenter.left += $el.outerWidth() / 2; - elementCenter.top += $el.outerHeight() / 2; - - await triggerEvent(options.mousedownTarget || el || $el, 'mousedown', { - which: 1, - pageX: elementCenter.left, - pageY: elementCenter.top - }, true); - } - await triggerEvent(options.mousemoveTarget || el || $el, 'mousemove', { - which: 1, - pageX: toOffset.left, - pageY: toOffset.top - }, true); - - if (!options.disableDrop) { - await triggerEvent(options.mouseupTarget || el || $el, 'mouseup', { - which: 1, - pageX: toOffset.left, - pageY: toOffset.top, - ctrlKey: options.ctrlKey, - }, true); - if (options.withTrailingClick) { - await triggerEvent(options.mouseupTarget || el || $el, 'click', {}, true); - } - } else { - // It's impossible to drag another element when one is already - // being dragged. So it's necessary to finish the drop when the test is - // over otherwise it's impossible for the next tests to drag and - // drop elements. - $el.on('remove', function () { - triggerEvent($el, 'mouseup', {}, true); + function mouseEventMapping(args) { + return Object.assign({}, args, { + view: window, + bubbles: true, + cancelable: true, + clientX: args ? args.pageX : undefined, + clientY: args ? args.pageY : undefined, }); } - return returnAfterNextAnimationFrame(); -} -/** - * simulate a mouse event with a custom event who add the item position. This is - * sometimes necessary because the basic way to trigger an event (such as - * $el.trigger('mousemove')); ) is too crude for some uses. - * - * @param {jQuery|EventTarget} $el - * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' - * @returns {Promise} - */ -function triggerMouseEvent($el, type) { - const el = $el instanceof jQuery ? $el[0] : $el; - if (!el) { - throw new Error(`no target found to trigger MouseEvent`); - } - const rect = el.getBoundingClientRect(); - // little fix since it seems on chrome, it triggers 1px too on the left - const left = rect.x + 1; - const top = rect.y; - return triggerEvent($el, type, { - which: 1, - pageX: left, layerX: left, screenX: left, - pageY: top, layerY: top, screenY: top, - }); -} - -/** - * simulate a mouse event with a custom event on a position x and y. This is - * sometimes necessary because the basic way to trigger an event (such as - * $el.trigger('mousemove')); ) is too crude for some uses. - * - * @param {integer} x - * @param {integer} y - * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' - * @returns {HTMLElement} - */ -function triggerPositionalMouseEvent(x, y, type) { - var ev = document.createEvent("MouseEvent"); - var el = document.elementFromPoint(x, y); - ev.initMouseEvent( - type, - true /* bubble */, - true /* cancelable */, - window, null, - x, y, x, y, /* coordinates */ - false, false, false, false, /* modifier keys */ - 0 /*left button*/, null - ); - el.dispatchEvent(ev); - return el; -} - -/** - * simulate a keypress event for a given character - * - * @param {string} char the character, or 'ENTER' - * @returns {Promise} - */ -function triggerKeypressEvent(char) { - var keycode; - if (char === 'Enter') { - keycode = $.ui.keyCode.ENTER; - } else if (char === "Tab") { - keycode = $.ui.keyCode.TAB; - } else { - keycode = char.charCodeAt(0); - } - return triggerEvent(document.body, 'keypress', { - key: char, - keyCode: keycode, - which: keycode, - }); -} - -/** - * Click on a specified element. If the option first or last is not specified, - * this method also check the unicity and the visibility of the target. - * - * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector) - * @param {Object} [options={}] click options - * @param {boolean} [options.allowInvisible=false] if true, clicks on the - * element event if it is invisible - * @param {boolean} [options.first=false] if true, clicks on the first element - * @param {boolean} [options.last=false] if true, clicks on the last element - * @returns {Promise} - */ -async function click(el, options = {}) { - let matches, target; - let selectorMsg = ""; - if (typeof el === 'string') { - el = $(el); - } - if (isEventTarget(el)) { - // EventTarget - matches = [el]; - } else { - // Any other iterable object containing EventTarget objects (jQuery, HTMLCollection, etc.) - matches = [...el]; + function mouseEventNoBubble(args) { + return Object.assign({}, args, { + view: window, + bubbles: false, + cancelable: false, + clientX: args ? args.pageX : undefined, + clientY: args ? args.pageY : undefined, + }); } - const validMatches = options.allowInvisible ? - matches : matches.filter(t => $(t).is(':visible')); + function onlyBubble(args) { + return Object.assign({}, args, { + bubbles: true + }); + } - if (options.first) { - if (validMatches.length === 1) { - throw new Error(`There should be more than one visible target ${selectorMsg}. If` + - ' you are sure that there is exactly one target, please use the ' + - 'click function instead of the clickFirst function'); + const eventTypes = { + auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, + click: { constructor: MouseEvent, processParameters: mouseEventMapping }, + contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping }, + dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, + mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping }, + mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping }, + + mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping }, + mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble }, + mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble }, + mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping }, + mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping }, + + focus: { + constructor: FocusEvent, processParameters: (args) => { + return Object.assign({}, args, { bubbles: false }); + } + }, + focusin: { constructor: FocusEvent, processParameters: onlyBubble }, + blur: { + constructor: FocusEvent, processParameters: (args) => { + return Object.assign({}, args, { bubbles: false }); + } + }, + + cut: { constructor: ClipboardEvent, processParameters: onlyBubble }, + copy: { constructor: ClipboardEvent, processParameters: onlyBubble }, + paste: { constructor: ClipboardEvent, processParameters: onlyBubble }, + + keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, + keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, + keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, + + drag: { constructor: DragEvent, processParameters: onlyBubble }, + dragend: { constructor: DragEvent, processParameters: onlyBubble }, + dragenter: { constructor: DragEvent, processParameters: onlyBubble }, + dragstart: { constructor: DragEvent, processParameters: onlyBubble }, + dragleave: { constructor: DragEvent, processParameters: onlyBubble }, + dragover: { constructor: DragEvent, processParameters: onlyBubble }, + drop: { constructor: DragEvent, processParameters: onlyBubble }, + }; + + /** + * Check if an object is an instance of EventTarget. + * + * @param {Object} node + * @returns {boolean} + */ + function isEventTarget(node) { + if (node instanceof window.top.EventTarget) { + return true; } - target = validMatches[0]; - } else if (options.last) { - if (validMatches.length === 1) { - throw new Error(`There should be more than one visible target ${selectorMsg}. If` + - ' you are sure that there is exactly one target, please use the ' + - 'click function instead of the clickLast function'); + const contextWindow = node.defaultView || // document + (node.ownerDocument && node.ownerDocument.defaultView); // iframe node + return contextWindow && node instanceof contextWindow.EventTarget; + } + + //------------------------------------------------------------------------- + // Public + //------------------------------------------------------------------------- + + /** + * Click on a specified element. If the option first or last is not specified, + * this method also check the unicity and the visibility of the target. + * + * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector) + * @param {Object} [options={}] click options + * @param {boolean} [options.allowInvisible=false] if true, clicks on the + * element event if it is invisible + * @param {boolean} [options.first=false] if true, clicks on the first element + * @param {boolean} [options.last=false] if true, clicks on the last element + * @returns {Promise} + */ + async function click(el, options = {}) { + let matches, target; + let selectorMsg = ""; + if (typeof el === 'string') { + el = $(el); } - target = validMatches[validMatches.length - 1]; - } else if (validMatches.length !== 1) { - throw new Error(`Found ${validMatches.length} elements to click on, instead of 1 ${selectorMsg}`); - } else { - target = validMatches[0]; - } - if (validMatches.length === 0 && matches.length > 0) { - throw new Error(`Element to click on is not visible ${selectorMsg}`); - } - - return triggerEvent(target, 'click'); -} - -/** - * Click on the first element of a list of elements. Note that if the list has - * only one visible element, we trigger an error. In that case, it is better to - * use the click helper instead. - * - * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector) - * @param {boolean} [options={}] click options - * @param {boolean} [options.allowInvisible=false] if true, clicks on the - * element event if it is invisible - * @returns {Promise} - */ -function clickFirst(el, options) { - return click(el, _.extend({}, options, { first: true })); -} - -/** - * Click on the last element of a list of elements. Note that if the list has - * only one visible element, we trigger an error. In that case, it is better to - * use the click helper instead. - * - * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector) - * @param {boolean} [options={}] click options - * @param {boolean} [options.allowInvisible=false] if true, clicks on the - * element event if it is invisible - * @returns {Promise} - */ -function clickLast(el, options) { - return click(el, _.extend({}, options, { last: true })); -} - -/** - * Triggers multiple events on the specified element. - * - * @param {EventTarget|EventTarget[]} el - * @param {string[]} events the events you want to trigger - * @returns {Promise} - */ -async function triggerEvents(el, events) { - if (el instanceof jQuery) { - if (el.length !== 1) { - throw new Error(`target has length ${el.length} instead of 1`); - } - } - if (typeof events === 'string') { - events = [events]; - } - - for (let e = 0; e < events.length; e++) { - await triggerEvent(el, events[e], {}); - } -} - -function onlyBubble(args) { - return Object.assign({}, args, { - bubbles: true - }); -} - -function mouseEventMapping(args) { - return Object.assign({}, args, { - view: window, - bubbles: true, - cancelable: true, - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - }); -} - -function mouseEventNoBubble(args) { - return Object.assign({}, args, { - view: window, - bubbles: false, - cancelable: false, - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - }); -} - -function keyboardEventBubble(args) { - return Object.assign({}, args, { bubbles: true, keyCode: args.which }); -} - -const eventTypes = { - auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, - click: { constructor: MouseEvent, processParameters: mouseEventMapping }, - contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping }, - dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping }, - - mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble }, - mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble }, - mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping }, - - focus: { - constructor: FocusEvent, processParameters: (args) => { - return Object.assign({}, args, { bubbles: false }); - } - }, - focusin: { constructor: FocusEvent, processParameters: onlyBubble }, - blur: { - constructor: FocusEvent, processParameters: (args) => { - return Object.assign({}, args, { bubbles: false }); - } - }, - - cut: { constructor: ClipboardEvent, processParameters: onlyBubble }, - copy: { constructor: ClipboardEvent, processParameters: onlyBubble }, - paste: { constructor: ClipboardEvent, processParameters: onlyBubble }, - - keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, - keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, - keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, - - drag: { constructor: DragEvent, processParameters: onlyBubble }, - dragend: { constructor: DragEvent, processParameters: onlyBubble }, - dragenter: { constructor: DragEvent, processParameters: onlyBubble }, - dragstart: { constructor: DragEvent, processParameters: onlyBubble }, - dragleave: { constructor: DragEvent, processParameters: onlyBubble }, - dragover: { constructor: DragEvent, processParameters: onlyBubble }, - drop: { constructor: DragEvent, processParameters: onlyBubble }, -}; - -/** - * Triggers an event on the specified target. - * This function will dispatch a native event to an EventTarget or a - * jQuery event to a jQuery object. This behaviour can be overridden by the - * jquery option. - * - * @param {EventTarget|EventTarget[]} el - * @param {string} eventType event type - * @param {Object} [eventAttrs] event attributes - * on a jQuery element with the `$.fn.trigger` function - * @param {Boolean} [fast=false] true if the trigger event have to wait for a single tick instead of waiting for the next animation frame - * @returns {Promise} - */ -async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) { - let matches; - let selectorMsg = ""; - if (isEventTarget(el)) { - matches = [el]; - } else { - matches = [...el]; - } - - if (matches.length !== 1) { - throw new Error(`Found ${matches.length} elements to trigger "${eventType}" on, instead of 1 ${selectorMsg}`); - } - - const target = matches[0]; - let event; - - if (!eventTypes[eventType] && !eventTypes[eventType.type]) { - event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true })); - } else { - if (typeof eventType === "object") { - const { constructor, processParameters } = eventTypes[eventType.type]; - const eventParameters = processParameters(eventType); - event = new constructor(eventType.type, eventParameters); + if (isEventTarget(el)) { + // EventTarget + matches = [el]; } else { - const { constructor, processParameters } = eventTypes[eventType]; - event = new constructor(eventType, processParameters(eventAttrs)); + // Any other iterable object containing EventTarget objects (jQuery, HTMLCollection, etc.) + matches = [...el]; + } + + const validMatches = options.allowInvisible ? + matches : matches.filter(t => $(t).is(':visible')); + + if (options.first) { + if (validMatches.length === 1) { + throw new Error(`There should be more than one visible target ${selectorMsg}. If` + + ' you are sure that there is exactly one target, please use the ' + + 'click function instead of the clickFirst function'); + } + target = validMatches[0]; + } else if (options.last) { + if (validMatches.length === 1) { + throw new Error(`There should be more than one visible target ${selectorMsg}. If` + + ' you are sure that there is exactly one target, please use the ' + + 'click function instead of the clickLast function'); + } + target = validMatches[validMatches.length - 1]; + } else if (validMatches.length !== 1) { + throw new Error(`Found ${validMatches.length} elements to click on, instead of 1 ${selectorMsg}`); + } else { + target = validMatches[0]; + } + if (validMatches.length === 0 && matches.length > 0) { + throw new Error(`Element to click on is not visible ${selectorMsg}`); + } + + return triggerEvent(target, 'click'); + } + + /** + * Click on the first element of a list of elements. Note that if the list has + * only one visible element, we trigger an error. In that case, it is better to + * use the click helper instead. + * + * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector) + * @param {boolean} [options={}] click options + * @param {boolean} [options.allowInvisible=false] if true, clicks on the + * element event if it is invisible + * @returns {Promise} + */ + function clickFirst(el, options) { + return click(el, _.extend({}, options, { first: true })); + } + + /** + * Click on the last element of a list of elements. Note that if the list has + * only one visible element, we trigger an error. In that case, it is better to + * use the click helper instead. + * + * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector) + * @param {boolean} [options={}] click options + * @param {boolean} [options.allowInvisible=false] if true, clicks on the + * element event if it is invisible + * @returns {Promise} + */ + function clickLast(el, options) { + return click(el, _.extend({}, options, { last: true })); + } + + /** + * simulate a drag and drop operation between 2 jquery nodes: $el and $to. + * This is a crude simulation, with only the mousedown, mousemove and mouseup + * events, but it is enough to help test drag and drop operations with jqueryUI + * sortable. + * + * @todo: remove the withTrailingClick option when the jquery update branch is + * merged. This is not the default as of now, because handlers are triggered + * synchronously, which is not the same as the 'reality'. + * + * @param {jQuery|EventTarget} $el + * @param {jQuery|EventTarget} $to + * @param {Object} [options] + * @param {string|Object} [options.position='center'] target position: + * can either be one of {'top', 'bottom', 'left', 'right'} or + * an object with two attributes (top and left)) + * @param {boolean} [options.disableDrop=false] whether to trigger the drop action + * @param {boolean} [options.continueMove=false] whether to trigger the + * mousedown action (will only work after another call of this function with + * without this option) + * @param {boolean} [options.withTrailingClick=false] if true, this utility + * function will also trigger a click on the target after the mouseup event + * (this is actually what happens when a drag and drop operation is done) + * @param {jQuery|EventTarget} [options.mouseenterTarget=undefined] target of the mouseenter event + * @param {jQuery|EventTarget} [options.mousedownTarget=undefined] target of the mousedown event + * @param {jQuery|EventTarget} [options.mousemoveTarget=undefined] target of the mousemove event + * @param {jQuery|EventTarget} [options.mouseupTarget=undefined] target of the mouseup event + * @param {jQuery|EventTarget} [options.ctrlKey=undefined] if the ctrl key should be considered pressed at the time of mouseup + * @returns {Promise} + */ + async function dragAndDrop($el, $to, options) { + let el = null; + if (isEventTarget($el)) { + el = $el; + $el = $(el); + } + if (isEventTarget($to)) { + $to = $($to); + } + options = options || {}; + var position = options.position || 'center'; + var elementCenter = $el.offset(); + var toOffset = $to.offset(); + + if (_.isObject(position)) { + toOffset.top += position.top + 1; + toOffset.left += position.left + 1; + } else { + toOffset.top += $to.outerHeight() / 2; + toOffset.left += $to.outerWidth() / 2; + var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1; + if (position === 'top') { + toOffset.top -= $to.outerHeight() / 2 + vertical_offset; + } else if (position === 'bottom') { + toOffset.top += $to.outerHeight() / 2 - vertical_offset; + } else if (position === 'left') { + toOffset.left -= $to.outerWidth() / 2; + } else if (position === 'right') { + toOffset.left += $to.outerWidth() / 2; + } + } + + if ($to[0].ownerDocument !== document) { + // we are in an iframe + var bound = $('iframe')[0].getBoundingClientRect(); + toOffset.left += bound.left; + toOffset.top += bound.top; + } + await triggerEvent(options.mouseenterTarget || el || $el, 'mouseover', {}, true); + if (!(options.continueMove)) { + elementCenter.left += $el.outerWidth() / 2; + elementCenter.top += $el.outerHeight() / 2; + + await triggerEvent(options.mousedownTarget || el || $el, 'mousedown', { + which: 1, + pageX: elementCenter.left, + pageY: elementCenter.top + }, true); + } + await triggerEvent(options.mousemoveTarget || el || $el, 'mousemove', { + which: 1, + pageX: toOffset.left, + pageY: toOffset.top + }, true); + + if (!options.disableDrop) { + await triggerEvent(options.mouseupTarget || el || $el, 'mouseup', { + which: 1, + pageX: toOffset.left, + pageY: toOffset.top, + ctrlKey: options.ctrlKey, + }, true); + if (options.withTrailingClick) { + await triggerEvent(options.mouseupTarget || el || $el, 'click', {}, true); + } + } else { + // It's impossible to drag another element when one is already + // being dragged. So it's necessary to finish the drop when the test is + // over otherwise it's impossible for the next tests to drag and + // drop elements. + $el.on('remove', function () { + triggerEvent($el, 'mouseup', {}, true); + }); + } + return returnAfterNextAnimationFrame(); + } + + /** + * Opens the datepicker of a given element. + * + * @param {jQuery} $datepickerEl element to which a datepicker is attached + */ + function openDatepicker($datepickerEl) { + return click($datepickerEl.find('.o_datepicker_input')); + } + + /** + * Returns a promise that will be resolved after the nextAnimationFrame after + * the next tick + * + * This is useful to guarantee that OWL has had the time to render + * + * @returns {Promise} + */ + async function returnAfterNextAnimationFrame() { + await concurrency.delay(0); + await new Promise(resolve => { + window.requestAnimationFrame(resolve); + }); + } + + /** + * Triggers an event on the specified target. + * This function will dispatch a native event to an EventTarget or a + * jQuery event to a jQuery object. This behaviour can be overridden by the + * jquery option. + * + * @param {EventTarget|EventTarget[]} el + * @param {string} eventType event type + * @param {Object} [eventAttrs] event attributes + * on a jQuery element with the `$.fn.trigger` function + * @param {Boolean} [fast=false] true if the trigger event have to wait for a single tick instead of waiting for the next animation frame + * @returns {Promise} + */ + async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) { + let matches; + let selectorMsg = ""; + if (isEventTarget(el)) { + matches = [el]; + } else { + matches = [...el]; + } + + if (matches.length !== 1) { + throw new Error(`Found ${matches.length} elements to trigger "${eventType}" on, instead of 1 ${selectorMsg}`); + } + + const target = matches[0]; + let event; + + if (!eventTypes[eventType] && !eventTypes[eventType.type]) { + event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true })); + } else { + if (typeof eventType === "object") { + const { constructor, processParameters } = eventTypes[eventType.type]; + const eventParameters = processParameters(eventType); + event = new constructor(eventType.type, eventParameters); + } else { + const { constructor, processParameters } = eventTypes[eventType]; + event = new constructor(eventType, processParameters(eventAttrs)); + } + } + target.dispatchEvent(event); + return fast ? undefined : returnAfterNextAnimationFrame(); + } + + /** + * Triggers multiple events on the specified element. + * + * @param {EventTarget|EventTarget[]} el + * @param {string[]} events the events you want to trigger + * @returns {Promise} + */ + async function triggerEvents(el, events) { + if (el instanceof jQuery) { + if (el.length !== 1) { + throw new Error(`target has length ${el.length} instead of 1`); + } + } + if (typeof events === 'string') { + events = [events]; + } + + for (let e = 0; e < events.length; e++) { + await triggerEvent(el, events[e], {}); } } - target.dispatchEvent(event); - return fast ? undefined : returnAfterNextAnimationFrame(); -} + /** + * simulate a mouse event with a custom event who add the item position. This is + * sometimes necessary because the basic way to trigger an event (such as + * $el.trigger('mousemove')); ) is too crude for some uses. + * + * @param {jQuery|EventTarget} $el + * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' + * @returns {Promise} + */ + function triggerMouseEvent($el, type) { + const el = $el instanceof jQuery ? $el[0] : $el; + if (!el) { + throw new Error(`no target found to trigger MouseEvent`); + } + const rect = el.getBoundingClientRect(); + // little fix since it seems on chrome, it triggers 1px too on the left + const left = rect.x + 1; + const top = rect.y; + return triggerEvent($el, type, { + which: 1, + pageX: left, layerX: left, screenX: left, + pageY: top, layerY: top, screenY: top, + }); + } -/** - * Opens the datepicker of a given element. - * - * @param {jQuery} $datepickerEl element to which a datepicker is attached - */ -function openDatepicker($datepickerEl) { - return click($datepickerEl.find('.o_datepicker_input')); -} + /** + * simulate a mouse event with a custom event on a position x and y. This is + * sometimes necessary because the basic way to trigger an event (such as + * $el.trigger('mousemove')); ) is too crude for some uses. + * + * @param {integer} x + * @param {integer} y + * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' + * @returns {HTMLElement} + */ + function triggerPositionalMouseEvent(x, y, type) { + var ev = document.createEvent("MouseEvent"); + var el = document.elementFromPoint(x, y); + ev.initMouseEvent( + type, + true /* bubble */, + true /* cancelable */, + window, null, + x, y, x, y, /* coordinates */ + false, false, false, false, /* modifier keys */ + 0 /*left button*/, null + ); + el.dispatchEvent(ev); + return el; + } + /** + * simulate a keypress event for a given character + * + * @param {string} char the character, or 'ENTER' + * @returns {Promise} + */ + function triggerKeypressEvent(char) { + var keycode; + if (char === 'Enter') { + keycode = $.ui.keyCode.ENTER; + } else if (char === "Tab") { + keycode = $.ui.keyCode.TAB; + } else { + keycode = char.charCodeAt(0); + } + return triggerEvent(document.body, 'keypress', { + key: char, + keyCode: keycode, + which: keycode, + }); + } -return { - triggerKeypressEvent: triggerKeypressEvent, - triggerMouseEvent: triggerMouseEvent, - triggerPositionalMouseEvent: triggerPositionalMouseEvent, - dragAndDrop: dragAndDrop, - openDatepicker: openDatepicker, - click: click, - clickFirst: clickFirst, - clickLast: clickLast, - triggerEvents: triggerEvents, - triggerEvent: triggerEvent, - returnAfterNextAnimationFrame: returnAfterNextAnimationFrame, -}; - + return { + click: click, + clickFirst: clickFirst, + clickLast: clickLast, + dragAndDrop: dragAndDrop, + openDatepicker: openDatepicker, + returnAfterNextAnimationFrame: returnAfterNextAnimationFrame, + triggerEvent: triggerEvent, + triggerEvents: triggerEvents, + triggerKeypressEvent: triggerKeypressEvent, + triggerMouseEvent: triggerMouseEvent, + triggerPositionalMouseEvent: triggerPositionalMouseEvent, + }; }); diff --git a/addons/web/static/tests/helpers/test_utils_fields.js b/addons/web/static/tests/helpers/test_utils_fields.js index 6532aa7033c..4646fd98d83 100644 --- a/addons/web/static/tests/helpers/test_utils_fields.js +++ b/addons/web/static/tests/helpers/test_utils_fields.js @@ -1,231 +1,230 @@ odoo.define('web.test_utils_fields', function (require) { -"use strict"; + "use strict"; -/** - * Field Test Utils - * - * This module defines various utility functions to help testing field widgets. - * - * Note that all methods defined in this module are exported in the main - * testUtils file. - */ + /** + * Field Test Utils + * + * This module defines various utility functions to help testing field widgets. + * + * Note that all methods defined in this module are exported in the main + * testUtils file. + */ -var concurrency = require('web.concurrency'); -var domUtils = require('web.test_utils_dom'); + var concurrency = require('web.concurrency'); + var domUtils = require('web.test_utils_dom'); -//------------------------------------------------------------------------------ -// Public functions -//------------------------------------------------------------------------------ + //------------------------------------------------------------------------- + // Public functions + //------------------------------------------------------------------------- -/** - * Sets the value of an element and then, trigger all specified events. - * Note that this helper also checks the unicity of the target. - * - * Example: - * testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']); - * - * @param {jQuery|EventTarget} el should target an input, textarea or select - * @param {string|number} value - * @param {string[]} events - * @returns {Promise} - */ -function editAndTrigger(el, value, events) { - if (el instanceof jQuery) { - if (el.length !== 1) { - throw new Error(`target ${el.selector} has length ${el.length} instead of 1`); + /** + * Click on the active (highlighted) selection in a m2o dropdown. + * + * @param {string} fieldName + * @param {[string]} selector if set, this will restrict the search for the m2o + * input + */ + function clickM2OHighlightedItem(fieldName, selector) { + var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; + var $dropdown = $(m2oSelector).autocomplete('widget'); + // clicking on an li (no matter which one), will select the focussed one + $dropdown.find('li:first()').click(); + return concurrency.delay(0); + } + + /** + * Click on a menuitem in the m2o dropdown. This helper will target an element + * which contains some specific text. Note that it assumes that the dropdown + * is currently open. + * + * Example: + * testUtils.fields.many2one.clickM2OItem('partner_id', 'George'); + * + * @param {string} fieldName + * @param {string} searchText + * @returns {Promise} + */ + function clickM2OItem(fieldName, searchText) { + var m2oSelector = `.o_field_many2one[name=${fieldName}] input`; + var $dropdown = $(m2oSelector).autocomplete('widget'); + var $target = $dropdown.find(`li:contains(${searchText})`).first(); + if ($target.length !== 1 || !$target.is(':visible')) { + throw new Error('Menu item should be unique and visible'); } - el.val(value); - } else { - el.value = value; - } - return domUtils.triggerEvents(el, events); -} + $target.mouseenter(); + $target.click(); -/** - * Sets the value of an input. - * - * Note that this helper also checks the unicity of the target. - * - * Example: - * testUtils.fields.editInput($('selector'), 'somevalue'); - * - * @param {jQuery|EventTarget} el should target an input, textarea or select - * @param {string|number} value - * @returns {Promise} - */ -function editInput(el, value) { - return editAndTrigger(el, value, ['input']); -} - -/** - * Sets the value of a select. - * - * Note that this helper also checks the unicity of the target. - * - * Example: - * testUtils.fields.editSelect($('selector'), 'somevalue'); - * - * @param {jQuery|EventTarget} el should target an input, textarea or select - * @param {string|number} value - * @returns {Promise} - */ -function editSelect(el, value) { - return editAndTrigger(el, value, ['change']); -} - -/** - * Click to open the dropdown on a many2one - * - * @param {string} fieldName - * @param {[string]} selector if set, this will restrict the search for the m2o - * input - * @returns {HTMLInputElement} the main many2one input - */ -async function clickOpenM2ODropdown(fieldName, selector) { - var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; - var matches = document.querySelectorAll(m2oSelector); - if (matches.length !== 1) { - throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`); + return concurrency.delay(0); } - await domUtils.click(matches[0]); - return matches[0]; -} - -/** - * Click on the active (highlighted) selection in a m2o dropdown. - * - * @param {string} fieldName - * @param {[string]} selector if set, this will restrict the search for the m2o - * input - */ -function clickM2OHighlightedItem(fieldName, selector) { - var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; - var $dropdown = $(m2oSelector).autocomplete('widget'); - // clicking on an li (no matter which one), will select the focussed one - $dropdown.find('li:first()').click(); - return concurrency.delay(0); -} - -/** - * Click on a menuitem in the m2o dropdown. This helper will target an element - * which contains some specific text. Note that it assumes that the dropdown - * is currently open. - * - * Example: - * testUtils.fields.many2one.clickM2OItem('partner_id', 'George'); - * - * @param {string} fieldName - * @param {string} searchText - * @returns {Promise} - */ -function clickM2OItem(fieldName, searchText) { - var m2oSelector = `.o_field_many2one[name=${fieldName}] input`; - var $dropdown = $(m2oSelector).autocomplete('widget'); - var $target = $dropdown.find(`li:contains(${searchText})`).first(); - if ($target.length !== 1 || !$target.is(':visible')) { - throw new Error('Menu item should be unique and visible'); - } - $target.mouseenter(); - $target.click(); - - return concurrency.delay(0); -} - -/** - * This helper is useful to test many2one fields. Here is what it does: - * - click to open the dropdown - * - enter a search string in the input - * - wait for the selection - * - click on the requested menuitem, or the active one by default - * - * Example: - * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', {search: 'George'}); - * - * @param {string} fieldName - * @param {[Object]} options - * @param {[string]} options.selector - * @param {[string]} options.search - * @param {[string]} options.item - * @returns {Promise} - */ -function searchAndClickM2OItem(fieldName, options) { - options = options || {}; - - return clickOpenM2ODropdown(fieldName, options.selector).then(function (input) { - var def; - if (options.search) { - input.value = options.search; - input.dispatchEvent(new Event('input')); - def = concurrency.delay(0); + /** + * Click to open the dropdown on a many2one + * + * @param {string} fieldName + * @param {[string]} selector if set, this will restrict the search for the m2o + * input + * @returns {HTMLInputElement} the main many2one input + */ + async function clickOpenM2ODropdown(fieldName, selector) { + var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; + var matches = document.querySelectorAll(m2oSelector); + if (matches.length !== 1) { + throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`); } - return Promise.resolve(def).then(function () { - if (options.item) { - return clickM2OItem(fieldName, options.item); - } else { - return clickM2OHighlightedItem(fieldName, options.selector); + + await domUtils.click(matches[0]); + return matches[0]; + } + + /** + * Sets the value of an element and then, trigger all specified events. + * Note that this helper also checks the unicity of the target. + * + * Example: + * testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']); + * + * @param {jQuery|EventTarget} el should target an input, textarea or select + * @param {string|number} value + * @param {string[]} events + * @returns {Promise} + */ + function editAndTrigger(el, value, events) { + if (el instanceof jQuery) { + if (el.length !== 1) { + throw new Error(`target ${el.selector} has length ${el.length} instead of 1`); } - }); - }); -} - -/** - * Helper to trigger a key event on an element. - * - * @param {string} type type of key event ('press', 'up' or 'down') - * @param {jQuery} $el - * @param {number|string} keyCode used as number, but if string, it'll check if - * the string corresponds to a key -otherwise it will keep only the first - * char to get a letter key- and convert it into a keyCode. - * @returns {Promise} - */ -function triggerKey(type, $el, keyCode) { - type = 'key' + type; - if (typeof keyCode === 'string') { - if (keyCode.length > 1) { - keyCode = keyCode.toUpperCase(); - keyCode = $.ui.keyCode[keyCode]; + el.val(value); } else { - keyCode = keyCode.charCodeAt(0); + el.value = value; } + return domUtils.triggerEvents(el, events); } - $el.trigger({type: type, which: keyCode, keyCode: keyCode}); - return concurrency.delay(0); -} -/** - * Helper to trigger a keydown event on an element. - * - * @param {jQuery} $el - * @param {number|string} keyCode @see triggerKey - * @returns {Promise} - */ -function triggerKeydown($el, keyCode) { - return triggerKey('down', $el, keyCode); -} + /** + * Sets the value of an input. + * + * Note that this helper also checks the unicity of the target. + * + * Example: + * testUtils.fields.editInput($('selector'), 'somevalue'); + * + * @param {jQuery|EventTarget} el should target an input, textarea or select + * @param {string|number} value + * @returns {Promise} + */ + function editInput(el, value) { + return editAndTrigger(el, value, ['input']); + } -/** - * Helper to trigger a keyup event on an element. - * - * @param {jQuery} $el - * @param {number|string} keyCode @see triggerKey - * @returns {Promise} - */ -function triggerKeyup($el, keyCode) { - return triggerKey('up', $el, keyCode); -} + /** + * Sets the value of a select. + * + * Note that this helper also checks the unicity of the target. + * + * Example: + * testUtils.fields.editSelect($('selector'), 'somevalue'); + * + * @param {jQuery|EventTarget} el should target an input, textarea or select + * @param {string|number} value + * @returns {Promise} + */ + function editSelect(el, value) { + return editAndTrigger(el, value, ['change']); + } -return { - clickOpenM2ODropdown: clickOpenM2ODropdown, - clickM2OHighlightedItem: clickM2OHighlightedItem, - clickM2OItem: clickM2OItem, - editAndTrigger: editAndTrigger, - editInput: editInput, - editSelect: editSelect, - searchAndClickM2OItem: searchAndClickM2OItem, - triggerKey: triggerKey, - triggerKeydown: triggerKeydown, - triggerKeyup: triggerKeyup, -}; + /** + * This helper is useful to test many2one fields. Here is what it does: + * - click to open the dropdown + * - enter a search string in the input + * - wait for the selection + * - click on the requested menuitem, or the active one by default + * + * Example: + * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', {search: 'George'}); + * + * @param {string} fieldName + * @param {[Object]} options + * @param {[string]} options.selector + * @param {[string]} options.search + * @param {[string]} options.item + * @returns {Promise} + */ + function searchAndClickM2OItem(fieldName, options) { + options = options || {}; + return clickOpenM2ODropdown(fieldName, options.selector).then(function (input) { + var def; + if (options.search) { + input.value = options.search; + input.dispatchEvent(new Event('input')); + def = concurrency.delay(0); + } + return Promise.resolve(def).then(function () { + if (options.item) { + return clickM2OItem(fieldName, options.item); + } else { + return clickM2OHighlightedItem(fieldName, options.selector); + } + }); + }); + } + + /** + * Helper to trigger a key event on an element. + * + * @param {string} type type of key event ('press', 'up' or 'down') + * @param {jQuery} $el + * @param {number|string} keyCode used as number, but if string, it'll check if + * the string corresponds to a key -otherwise it will keep only the first + * char to get a letter key- and convert it into a keyCode. + * @returns {Promise} + */ + function triggerKey(type, $el, keyCode) { + type = 'key' + type; + if (typeof keyCode === 'string') { + if (keyCode.length > 1) { + keyCode = keyCode.toUpperCase(); + keyCode = $.ui.keyCode[keyCode]; + } else { + keyCode = keyCode.charCodeAt(0); + } + } + $el.trigger({ type: type, which: keyCode, keyCode: keyCode }); + return concurrency.delay(0); + } + + /** + * Helper to trigger a keydown event on an element. + * + * @param {jQuery} $el + * @param {number|string} keyCode @see triggerKey + * @returns {Promise} + */ + function triggerKeydown($el, keyCode) { + return triggerKey('down', $el, keyCode); + } + + /** + * Helper to trigger a keyup event on an element. + * + * @param {jQuery} $el + * @param {number|string} keyCode @see triggerKey + * @returns {Promise} + */ + function triggerKeyup($el, keyCode) { + return triggerKey('up', $el, keyCode); + } + + return { + clickM2OHighlightedItem: clickM2OHighlightedItem, + clickM2OItem: clickM2OItem, + clickOpenM2ODropdown: clickOpenM2ODropdown, + editAndTrigger: editAndTrigger, + editInput: editInput, + editSelect: editSelect, + searchAndClickM2OItem: searchAndClickM2OItem, + triggerKey: triggerKey, + triggerKeydown: triggerKeydown, + triggerKeyup: triggerKeyup, + }; }); From 384e516218375000461aa3fcb0ac1b9eaa960fb3 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 25 Feb 2020 12:52:41 +0000 Subject: [PATCH 02/10] [REF] web: Update generic files General cleaning of some helpers and generic files. The idea is that they are less likely to be entirely re-written in the near future so this is a way to keep the code clean, readable and compliant to the current standards. Changes include: - Use of async/await when possible - Use of const/let instead of var - Removal of underscore.js - Use of native JS instead of jQuery when possible The logic of the affected code remains the same. --- .../mail/static/tests/helpers/test_utils.js | 2 +- .../web/static/src/js/chrome/action_mixin.js | 8 +- addons/web/static/src/js/core/custom_hooks.js | 10 +- addons/web/static/src/js/main.js | 1 + .../web/static/tests/helpers/qunit_asserts.js | 10 +- addons/web/static/tests/helpers/test_utils.js | 254 ++++++++------- .../static/tests/helpers/test_utils_create.js | 290 +++++++++--------- .../static/tests/helpers/test_utils_dom.js | 194 ++++++------ .../static/tests/helpers/test_utils_fields.js | 119 +++---- .../static/tests/helpers/test_utils_mock.js | 2 +- .../static/tests/helpers/test_utils_modal.js | 48 ++- addons/web_editor/static/tests/test_utils.js | 226 +++++--------- 12 files changed, 541 insertions(+), 623 deletions(-) diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index d3f7081eef6..90317900660 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -26,7 +26,7 @@ var Widget = require('web.Widget'); * This is async due to mail_manager/mail_service that needs to be ready. * * @param {Object} params - * @return {Promise} resolved with the discuss widget + * @returns {Promise} */ async function createDiscuss(params) { var Parent = Widget.extend({ diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js index 1ca87c15ef3..a704e5736a0 100644 --- a/addons/web/static/src/js/chrome/action_mixin.js +++ b/addons/web/static/src/js/chrome/action_mixin.js @@ -20,9 +20,9 @@ odoo.define('web.ActionMixin', function (require) { * @module web.ActionMixin */ - var core = require('web.core'); + const core = require('web.core'); - var ActionMixin = { + const ActionMixin = { template: 'Action', /** @@ -79,7 +79,7 @@ odoo.define('web.ActionMixin', function (require) { renderElement: function () { this._super.apply(this, arguments); if (this.contentTemplate) { - var content = core.qweb.render(this.contentTemplate, { widget: this }); + const content = core.qweb.render(this.contentTemplate, { widget: this }); this.$('.o_content').append(content); } }, @@ -223,7 +223,7 @@ odoo.define('web.ActionMixin', function (require) { */ _onGetControllerQueryParams: function (ev) { ev.stopPropagation(); - var state = this.getOwnedQueryParams(); + const state = this.getOwnedQueryParams(); ev.data.callback(state || {}); }, }; diff --git a/addons/web/static/src/js/core/custom_hooks.js b/addons/web/static/src/js/core/custom_hooks.js index c196dd7f900..69750066a8c 100644 --- a/addons/web/static/src/js/core/custom_hooks.js +++ b/addons/web/static/src/js/core/custom_hooks.js @@ -14,18 +14,18 @@ odoo.define('web.custom_hooks', function () { */ function useFocusOnUpdate() { const component = Component.current; - component.__willFocus = null; + let willFocus = null; function _focusSelector() { - if (component.__willFocus) { - const target = component.el.querySelector(component.__willFocus); + if (willFocus) { + const target = component.el.querySelector(willFocus); if (target) { target.focus(); if (['INPUT', 'TEXTAREA'].includes(target.tagName)) { target.selectionStart = target.selectionEnd = target.value.length; } } - component.__willFocus = null; + willFocus = null; } } @@ -33,7 +33,7 @@ odoo.define('web.custom_hooks', function () { onPatched(_focusSelector); return function focusOnUpdate(selector = '[autofocus]') { - component.__willFocus = selector; + willFocus = selector; }; } diff --git a/addons/web/static/src/js/main.js b/addons/web/static/src/js/main.js index 35f00254ba8..49b90228ba2 100644 --- a/addons/web/static/src/js/main.js +++ b/addons/web/static/src/js/main.js @@ -1,4 +1,5 @@ odoo.define('web.web_client', function (require) { + "use strict"; const env = require('web.env'); const session = require("web.session"); diff --git a/addons/web/static/tests/helpers/qunit_asserts.js b/addons/web/static/tests/helpers/qunit_asserts.js index a57154e416e..0f22acbbf78 100644 --- a/addons/web/static/tests/helpers/qunit_asserts.js +++ b/addons/web/static/tests/helpers/qunit_asserts.js @@ -11,7 +11,7 @@ odoo.define('web.qunit_asserts', function (require) { * matches exactly one element. */ - var Widget = require('web.Widget'); + const Widget = require('web.Widget'); //------------------------------------------------------------------------- // Private functions @@ -56,8 +56,8 @@ odoo.define('web.qunit_asserts', function (require) { */ function _checkVisible(w, shouldBeVisible, msg) { if (w instanceof jQuery && w.length !== 1) { - const assertion = shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'; - QUnit.assert.ok(false, `Assertion '${assertion} ${className}' targets ${w.length} elements instead of 1`); + const assertion = shouldBeVisible ? 'isVisible' : 'isNotVisible'; + QUnit.assert.ok(false, `Assertion '${assertion}' targets ${w.length} elements instead of 1`); } const el = w instanceof Widget || w instanceof owl.Component ? w.el : @@ -184,12 +184,12 @@ odoo.define('web.qunit_asserts', function (require) { * @param {string} [msg] */ function hasAttrValue(w, attr, value, msg) { - var $el = w instanceof Widget ? w.$el : + const $el = w instanceof Widget ? w.$el : w instanceof HTMLElement ? $(w) : w; // jquery element if ($el.length !== 1) { - var descr = `hasAttrValue (${attr}: ${value})`; + const descr = `hasAttrValue (${attr}: ${value})`; QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1` ); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index f2b12eaf705..2ff77de4e49 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -1,4 +1,4 @@ -odoo.define('web.test_utils', function (require) { +odoo.define('web.test_utils', async function (require) { "use strict"; /** @@ -10,26 +10,25 @@ odoo.define('web.test_utils', function (require) { * instance of a view, appended in the dom, ready to be tested. */ - var ajax = require('web.ajax'); - var concurrency = require('web.concurrency'); - var core = require('web.core'); - var relationalFields = require('web.relational_fields'); - var session = require('web.session'); - var testUtilsCreate = require('web.test_utils_create'); - var testUtilsDom = require('web.test_utils_dom'); - var testUtilsFields = require('web.test_utils_fields'); - var testUtilsFile = require('web.test_utils_file'); - var testUtilsForm = require('web.test_utils_form'); - var testUtilsGraph = require('web.test_utils_graph'); - var testUtilsKanban = require('web.test_utils_kanban'); - var testUtilsMock = require('web.test_utils_mock'); - var testUtilsModal = require('web.test_utils_modal'); - var testUtilsPivot = require('web.test_utils_pivot'); - var tools = require('web.tools'); + const ajax = require('web.ajax'); + const core = require('web.core'); + const relationalFields = require('web.relational_fields'); + const session = require('web.session'); + const testUtilsCreate = require('web.test_utils_create'); + const testUtilsDom = require('web.test_utils_dom'); + const testUtilsFields = require('web.test_utils_fields'); + const testUtilsFile = require('web.test_utils_file'); + const testUtilsForm = require('web.test_utils_form'); + const testUtilsGraph = require('web.test_utils_graph'); + const testUtilsKanban = require('web.test_utils_kanban'); + const testUtilsMock = require('web.test_utils_mock'); + const testUtilsModal = require('web.test_utils_modal'); + const testUtilsPivot = require('web.test_utils_pivot'); + const tools = require('web.tools'); function deprecated(fn, type) { - var msg = `Helper 'testUtils.${fn.name}' is deprecated. ` + + const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` + `Please use 'testUtils.${type}.${fn.name}' instead.`; return tools.deprecated(fn, msg); } @@ -41,9 +40,9 @@ odoo.define('web.test_utils', function (require) { * @returns {Promise + resolve and reject function} */ function makeTestPromise() { - var resolve; - var reject; - var promise = new Promise(function (_resolve, _reject) { + let resolve; + let reject; + const promise = new Promise(function (_resolve, _reject) { resolve = _resolve; reject = _reject; }); @@ -69,7 +68,7 @@ odoo.define('web.test_utils', function (require) { * @returns {Promise + resolve and reject function} */ function makeTestPromiseWithAssert(assert, str) { - var prom = makeTestPromise(); + const prom = makeTestPromise(); prom.then(() => assert.step('ok ' + str)).catch(function () { }); prom.catch(() => assert.step('ko ' + str)); return prom; @@ -102,118 +101,117 @@ odoo.define('web.test_utils', function (require) { // before starting the qunit test suite. // (session.js is in charge of loading the static xml bundle and we also have // to load xml files that are normally lazy loaded by specific widgets). - return Promise.all([ + await Promise.all([ session.is_bound, ajax.loadXML('/web/static/src/xml/debug.xml', core.qweb), ajax.loadXML('/web/static/src/xml/dialog.xml', core.qweb), ajax.loadXML('/web/static/src/xml/translation_dialog.xml', core.qweb), - ]).then(function () { - setTimeout(function () { - // jquery autocomplete refines the search in a setTimeout() parameterized - // with a delay, so we force this delay to 0 s.t. the dropdown is filtered - // directly on the next tick - relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + ]); + setTimeout(function () { + // jquery autocomplete refines the search in a setTimeout() parameterized + // with a delay, so we force this delay to 0 s.t. the dropdown is filtered + // directly on the next tick + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; - // this is done with the hope that tests are - // only started all together... - QUnit.start(); - }, 0); - return { - mock: { - addMockEnvironment: testUtilsMock.addMockEnvironment, - getMockedOwlEnv: testUtilsMock.getMockedOwlEnv, - intercept: testUtilsMock.intercept, - patch: testUtilsMock.patch, - patchDate: testUtilsMock.patchDate, - unpatch: testUtilsMock.unpatch, - fieldsViewGet: testUtilsMock.fieldsViewGet, - patchSetTimeout: testUtilsMock.patchSetTimeout, - }, - dom: { - triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, - triggerMouseEvent: testUtilsDom.triggerMouseEvent, - triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, - dragAndDrop: testUtilsDom.dragAndDrop, - openDatepicker: testUtilsDom.openDatepicker, - click: testUtilsDom.click, - clickFirst: testUtilsDom.clickFirst, - clickLast: testUtilsDom.clickLast, - triggerEvents: testUtilsDom.triggerEvents, - triggerEvent: testUtilsDom.triggerEvent, - }, - form: { - clickEdit: testUtilsForm.clickEdit, - clickSave: testUtilsForm.clickSave, - clickCreate: testUtilsForm.clickCreate, - clickDiscard: testUtilsForm.clickDiscard, - reload: testUtilsForm.reload, - }, - graph: { - reload: testUtilsGraph.reload, - }, - kanban: { - reload: testUtilsKanban.reload, - clickCreate: testUtilsKanban.clickCreate, - quickCreate: testUtilsKanban.quickCreate, - toggleGroupSettings: testUtilsKanban.toggleGroupSettings, - toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown, - }, - modal: { - clickButton: testUtilsModal.clickButton, - }, - pivot: { - clickMeasure: testUtilsPivot.clickMeasure, - toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown, - reload: testUtilsPivot.reload, - }, - fields: { - many2one: { - clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown, - clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem, - clickItem: testUtilsFields.clickM2OItem, - searchAndClickItem: testUtilsFields.searchAndClickM2OItem, - }, - editInput: testUtilsFields.editInput, - editSelect: testUtilsFields.editSelect, - editAndTrigger: testUtilsFields.editAndTrigger, - triggerKey: testUtilsFields.triggerKey, - triggerKeydown: testUtilsFields.triggerKeydown, - triggerKeyup: testUtilsFields.triggerKeyup, - }, - file: { - createFile: testUtilsFile.createFile, - dragoverFile: testUtilsFile.dragoverFile, - dropFile: testUtilsFile.dropFile, - dropFiles: testUtilsFile.dropFiles, + // this is done with the hope that tests are + // only started all together... + QUnit.start(); + }, 0); + return { + mock: { + addMockEnvironment: testUtilsMock.addMockEnvironment, + getMockedOwlEnv: testUtilsMock.getMockedOwlEnv, + intercept: testUtilsMock.intercept, + patch: testUtilsMock.patch, + patchDate: testUtilsMock.patchDate, + unpatch: testUtilsMock.unpatch, + fieldsViewGet: testUtilsMock.fieldsViewGet, + patchSetTimeout: testUtilsMock.patchSetTimeout, + }, + dom: { + triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, + triggerMouseEvent: testUtilsDom.triggerMouseEvent, + triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, + dragAndDrop: testUtilsDom.dragAndDrop, + openDatepicker: testUtilsDom.openDatepicker, + click: testUtilsDom.click, + clickFirst: testUtilsDom.clickFirst, + clickLast: testUtilsDom.clickLast, + triggerEvents: testUtilsDom.triggerEvents, + triggerEvent: testUtilsDom.triggerEvent, + }, + form: { + clickEdit: testUtilsForm.clickEdit, + clickSave: testUtilsForm.clickSave, + clickCreate: testUtilsForm.clickCreate, + clickDiscard: testUtilsForm.clickDiscard, + reload: testUtilsForm.reload, + }, + graph: { + reload: testUtilsGraph.reload, + }, + kanban: { + reload: testUtilsKanban.reload, + clickCreate: testUtilsKanban.clickCreate, + quickCreate: testUtilsKanban.quickCreate, + toggleGroupSettings: testUtilsKanban.toggleGroupSettings, + toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown, + }, + modal: { + clickButton: testUtilsModal.clickButton, + }, + pivot: { + clickMeasure: testUtilsPivot.clickMeasure, + toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown, + reload: testUtilsPivot.reload, + }, + fields: { + many2one: { + clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown, + clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem, + clickItem: testUtilsFields.clickM2OItem, + searchAndClickItem: testUtilsFields.searchAndClickM2OItem, }, + editInput: testUtilsFields.editInput, + editSelect: testUtilsFields.editSelect, + editAndTrigger: testUtilsFields.editAndTrigger, + triggerKey: testUtilsFields.triggerKey, + triggerKeydown: testUtilsFields.triggerKeydown, + triggerKeyup: testUtilsFields.triggerKeyup, + }, + file: { + createFile: testUtilsFile.createFile, + dragoverFile: testUtilsFile.dragoverFile, + dropFile: testUtilsFile.dropFile, + dropFiles: testUtilsFile.dropFiles, + }, - createActionManager: testUtilsCreate.createActionManager, - createDebugManager: testUtilsCreate.createDebugManager, - createAsyncView: testUtilsCreate.createView, - createCalendarView: testUtilsCreate.createCalendarView, - createControlPanel: testUtilsCreate.createControlPanel, - createView: testUtilsCreate.createView, - createModel: testUtilsCreate.createModel, - createParent: testUtilsCreate.createParent, - makeTestPromise: makeTestPromise, - makeTestPromiseWithAssert: makeTestPromiseWithAssert, - nextMicrotaskTick: nextMicrotaskTick, - nextTick: nextTick, - prepareTarget: testUtilsCreate.prepareTarget, - returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame, + createActionManager: testUtilsCreate.createActionManager, + createDebugManager: testUtilsCreate.createDebugManager, + createAsyncView: testUtilsCreate.createView, + createCalendarView: testUtilsCreate.createCalendarView, + createControlPanel: testUtilsCreate.createControlPanel, + createView: testUtilsCreate.createView, + createModel: testUtilsCreate.createModel, + createParent: testUtilsCreate.createParent, + makeTestPromise: makeTestPromise, + makeTestPromiseWithAssert: makeTestPromiseWithAssert, + nextMicrotaskTick: nextMicrotaskTick, + nextTick: nextTick, + prepareTarget: testUtilsCreate.prepareTarget, + returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame, - // backward-compatibility - addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'), - dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'), - fieldsViewGet: deprecated(testUtilsMock.fieldsViewGet, 'mock'), - intercept: deprecated(testUtilsMock.intercept, 'mock'), - openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'), - patch: deprecated(testUtilsMock.patch, 'mock'), - patchDate: deprecated(testUtilsMock.patchDate, 'mock'), - triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'), - triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'), - triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'), - unpatch: deprecated(testUtilsMock.unpatch, 'mock'), - }; - }); + // backward-compatibility + addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'), + dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'), + fieldsViewGet: deprecated(testUtilsMock.fieldsViewGet, 'mock'), + intercept: deprecated(testUtilsMock.intercept, 'mock'), + openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'), + patch: deprecated(testUtilsMock.patch, 'mock'), + patchDate: deprecated(testUtilsMock.patchDate, 'mock'), + triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'), + triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'), + triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'), + unpatch: deprecated(testUtilsMock.unpatch, 'mock'), + }; }); diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index 717b845e80e..21bc6cc7850 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -10,70 +10,68 @@ odoo.define('web.test_utils_create', function (require) { * testUtils file. */ - var ActionManager = require('web.ActionManager'); - var config = require('web.config'); - var ControlPanelView = require('web.ControlPanelView'); - var concurrency = require('web.concurrency'); - var DebugManager = require('web.DebugManager.Backend'); - var dom = require('web.dom'); - var testUtilsMock = require('web.test_utils_mock'); - var Widget = require('web.Widget'); + const ActionManager = require('web.ActionManager'); + const concurrency = require('web.concurrency'); + const config = require('web.config'); + const ControlPanelView = require('web.ControlPanelView'); + const DebugManager = require('web.DebugManager.Backend'); + const dom = require('web.dom'); + const testUtilsMock = require('web.test_utils_mock'); + const Widget = require('web.Widget'); /** - * create and return an instance of ActionManager with all rpcs going through a + * Create and return an instance of ActionManager with all rpcs going through a * mock method using the data, actions and archs objects as sources. * - * @param {Object} [params] + * @param {Object} [params={}] * @param {Object} [params.actions] the actions given to the mock server * @param {Object} [params.archs] this archs given to the mock server * @param {Object} [params.data] the business data given to the mock server * @param {function} [params.mockRPC] - * @returns {ActionManager} + * @returns {Promise} */ - async function createActionManager(params) { - params = params || {}; + async function createActionManager(params = {}) { const target = prepareTarget(params.debug); - var widget = new Widget(); + const widget = new Widget(); // when 'document' addon is installed, the sidebar does a 'search_read' on // model 'ir_attachment' each time a record is open, so we monkey-patch // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly, // whether or not 'document' is installed - var mockRPC = params.mockRPC; - _.extend(params, { - mockRPC: function (route, args) { + const mockRPC = params.mockRPC; + Object.assign(params, { + async mockRPC(route, args) { if (args.model === 'ir.attachment') { - return Promise.resolve([]); + return []; } if (mockRPC) { return mockRPC.apply(this, arguments); } - return this._super.apply(this, arguments); + return this._super(...arguments); }, }); - testUtilsMock.addMockEnvironment(widget, _.defaults(params, { debounce: false })); + testUtilsMock.addMockEnvironment(widget, Object.assign({ debounce: false }, params)); await widget.prependTo(target); - widget.$el.addClass('o_web_client'); + widget.el.classList.add('o_web_client'); if (config.device.isMobile) { - widget.$el.addClass('o_touch_device'); + widget.el.classList.add('o_touch_device'); } - var userContext = params.context && params.context.user_context || {}; - var actionManager = new ActionManager(widget, userContext); + const userContext = params.context && params.context.user_context || {}; + const actionManager = new ActionManager(widget, userContext); - var originalDestroy = ActionManager.prototype.destroy; + const originalDestroy = ActionManager.prototype.destroy; actionManager.destroy = function () { actionManager.destroy = originalDestroy; widget.destroy(); }; - var fragment = document.createDocumentFragment(); - return actionManager.appendTo(fragment).then(function () { - dom.append(widget.$el, fragment, { - callbacks: [{ widget: actionManager }], - in_DOM: true, - }); - return actionManager; + const fragment = document.createDocumentFragment(); + await actionManager.appendTo(fragment); + dom.append(widget.el, fragment, { + callbacks: [{ widget: actionManager }], + in_DOM: true, }); + return actionManager; } /** @@ -85,21 +83,22 @@ odoo.define('web.test_utils_create', function (require) { * makes the calendar scroll to 6:00 in a setTimeout(0), which might have an * impact according to where we want to trigger positional clicks. * - * @param {Object} params see @createView + * @param {Object} params @see createView * @param {Object} [options] * @param {boolean} [options.positionalClicks=false] * @returns {Promise} */ async function createCalendarView(params, options) { - var calendar = await createView(params); + const calendar = await createView(params); if (!options || !options.positionalClicks) { return calendar; } - var $view = $('#qunit-fixture').contents(); - $view.prependTo('body'); - var destroy = calendar.destroy; - calendar.destroy = function () { - $view.remove(); + const viewElements = [...document.getElementById('qunit-fixture').children]; + viewElements.forEach(el => document.body.prepend(el)); + + const destroy = calendar.destroy; + calendar.destroy = () => { + viewElements.forEach(el => el.remove()); destroy(); }; await concurrency.delay(0); @@ -107,7 +106,7 @@ odoo.define('web.test_utils_create', function (require) { } /** - * create a controlPanel from various parameters. + * Create a controlPanel from various parameters. * * It returns an instance of ControlPanelController * @@ -138,17 +137,17 @@ odoo.define('web.test_utils_create', function (require) { * * @returns {Promise} resolves with an instance of the ControlPanelController */ - function createControlPanel(params) { + async function createControlPanel(params) { params = params || {}; const target = prepareTarget(params.debug); // reproduce the DOM environment of a view control panel - var $webClient = $('
').addClass('o_web_client').prependTo(target); - var $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); - var $action = $('
').addClass('o_action').appendTo($actionManager); + const $webClient = $('
').addClass('o_web_client').prependTo(target); + const $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); + const $action = $('
').addClass('o_action').appendTo($actionManager); - var widget = new Widget(); + const widget = new Widget(); // add mock environment: mock server, session, fieldviewget, ... - var mockServer = testUtilsMock.addMockEnvironment(widget, params); + const mockServer = testUtilsMock.addMockEnvironment(widget, params); if (!params.viewInfo) { try { params.viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); @@ -159,37 +158,35 @@ odoo.define('web.test_utils_create', function (require) { } } - var viewOptions = _.defaults({}, params.viewOptions, { + const viewOptions = Object.assign({ context: params.context, modelName: params.model, searchMenuTypes: params.searchMenuTypes, viewInfo: params.viewInfo, - }); - var controlPanelView = new ControlPanelView(viewOptions); - return controlPanelView.getController(widget).then(function (controlPanel) { - // override the controlPanel's 'destroy' so that it calls 'destroy' on - // the widget instead, as the widget is the parent of the controlPanel - // and the mockServer. - controlPanel.__destroy = controlPanel.destroy; - controlPanel.destroy = function () { - // remove the override to properly destroy the controlPanel and its - // children when it will be called the second time (by its parent) - delete controlPanel.destroy; - widget.destroy(); - $webClient.remove(); - }; + }, params.viewOptions); + const controlPanelView = new ControlPanelView(viewOptions); + const controlPanel = await controlPanelView.getController(widget); + // override the controlPanel's 'destroy' so that it calls 'destroy' on + // the widget instead, as the widget is the parent of the controlPanel + // and the mockServer. + controlPanel.__destroy = controlPanel.destroy; + controlPanel.destroy = function () { + // remove the override to properly destroy the controlPanel and its + // children when it will be called the second time (by its parent) + delete controlPanel.destroy; + widget.destroy(); + $webClient.remove(); + }; - // render the controlPanel in a fragment as it must be able to render - // correctly without being in the DOM - var fragment = document.createDocumentFragment(); - return controlPanel.appendTo(fragment).then(function () { - dom.prepend($action, fragment, { - callbacks: [{ widget: controlPanel }], - in_DOM: true, - }); - return controlPanel; - }); + // render the controlPanel in a fragment as it must be able to render + // correctly without being in the DOM + const fragment = document.createDocumentFragment(); + await controlPanel.appendTo(fragment); + dom.prepend($action, fragment, { + callbacks: [{ widget: controlPanel }], + in_DOM: true, }); + return controlPanel; } /** @@ -197,39 +194,39 @@ odoo.define('web.test_utils_create', function (require) { * mock method, assuming that the user has access rights, and is an admin. * * @param {Object} [params={}] + * @returns {DebugManager} */ - var createDebugManager = function (params) { - params = params || {}; - var mockRPC = params.mockRPC; - _.extend(params, { - mockRPC: function (route, args) { + function createDebugManager(params = {}) { + const mockRPC = params.mockRPC; + Object.assign(params, { + async mockRPC(route, args) { if (args.method === 'check_access_rights') { - return Promise.resolve(true); + return true; } if (args.method === 'xmlid_to_res_id') { - return Promise.resolve(true); + return true; } if (mockRPC) { return mockRPC.apply(this, arguments); } - return this._super.apply(this, arguments); + return this._super(...arguments); }, session: { - user_has_group: function (group) { + async user_has_group(group) { if (group === 'base.group_no_one') { - return Promise.resolve(true); + return true; } - return this._super.apply(this, arguments); + return this._super(...arguments); }, }, }); - var debugManager = new DebugManager(); + const debugManager = new DebugManager(); testUtilsMock.addMockEnvironment(debugManager, params); return debugManager; - }; + } /** - * create a model from given parameters. + * Create a model from given parameters. * * @param {Object} params This object will be given to addMockEnvironment, so * any parameters from that method applies @@ -237,9 +234,9 @@ odoo.define('web.test_utils_create', function (require) { * @returns {Model} */ function createModel(params) { - var widget = new Widget(); + const widget = new Widget(); - var model = new params.Model(widget); + const model = new params.Model(widget); testUtilsMock.addMockEnvironment(widget, params); @@ -256,20 +253,20 @@ odoo.define('web.test_utils_create', function (require) { } /** - * create a widget parent from given parameters. + * Create a widget parent from given parameters. * * @param {Object} params This object will be given to addMockEnvironment, so * any parameters from that method applies * @returns {Widget} */ function createParent(params) { - var widget = new Widget(); + const widget = new Widget(); testUtilsMock.addMockEnvironment(widget, params); return widget; } /** - * create a view from various parameters. Here, a view means a javascript + * Create a view from various parameters. Here, a view means a javascript * instance of an AbstractView class, such as a form view, a list view or a * kanban view. * @@ -291,37 +288,42 @@ odoo.define('web.test_utils_create', function (require) { * Note that this is particularly useful if you want to intercept events going * up in the init process of the view, because there are no other way to do it * after this method returns - * @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true. + * @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true. * Default is false. - * @returns {Promise} resolves with the instance of the view + * @returns {Promise} the instance of the view */ async function createView(params) { const target = prepareTarget(params.debug); - var widget = new Widget(); + const widget = new Widget(); // reproduce the DOM environment of views - var $webClient = $('
').addClass('o_web_client').prependTo(target); - var $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); - + const webClient = Object.assign(document.createElement('div'), { + className: 'o_web_client', + }); + const actionManager = Object.assign(document.createElement('div'), { + className: 'o_action_manager', + }); + target.prepend(webClient); + webClient.append(actionManager); // add mock environment: mock server, session, fieldviewget, ... - var mockServer = testUtilsMock.addMockEnvironment(widget, params); - var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + const mockServer = testUtilsMock.addMockEnvironment(widget, params); + const viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); // create the view - var View = params.View; - var viewOptions = _.defaults({}, params.viewOptions, { + const View = params.View; + const viewOptions = Object.assign({ modelName: params.model || 'foo', ids: 'res_id' in params ? [params.res_id] : undefined, currentId: 'res_id' in params ? params.res_id : undefined, domain: params.domain || [], context: params.context || {}, hasSidebar: false, - }); + }, params.viewOptions); // patch the View to handle the groupBy given in params, as we can't give it // in init (unlike the domain and context which can be set in the action) testUtilsMock.patch(View, { - _updateMVCParams: function () { - this._super.apply(this, arguments); + _updateMVCParams() { + this._super(...arguments); this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || []; testUtilsMock.unpatch(View); }, @@ -330,7 +332,7 @@ odoo.define('web.test_utils_create', function (require) { viewOptions.hasSelectors = params.hasSelectors; } - var view; + let view; if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') { // TODO: probably needs to create an helper just for that view = new params.View({ @@ -347,42 +349,40 @@ odoo.define('web.test_utils_create', function (require) { } if (params.interceptsPropagate) { - _.each(params.interceptsPropagate, function (cb, name) { - testUtilsMock.intercept(widget, name, cb, true); - }); + for (const name in params.interceptsPropagate) { + testUtilsMock.intercept(widget, name, params.interceptsPropagate[name], true); + } } - return view.getController(widget).then(function (view) { - // override the view's 'destroy' so that it calls 'destroy' on the widget - // instead, as the widget is the parent of the view and the mockServer. - view.__destroy = view.destroy; - view.destroy = function () { - // remove the override to properly destroy the view and its children - // when it will be called the second time (by its parent) - delete view.destroy; - widget.destroy(); - $webClient.remove(); - }; + const viewController = await view.getController(widget); + // override the view's 'destroy' so that it calls 'destroy' on the widget + // instead, as the widget is the parent of the view and the mockServer. + viewController.__destroy = viewController.destroy; + viewController.destroy = function () { + // remove the override to properly destroy the viewController and its children + // when it will be called the second time (by its parent) + delete viewController.destroy; + widget.destroy(); + webClient.remove(); + }; - // render the view in a fragment as they must be able to render correctly - // without being in the DOM - var fragment = document.createDocumentFragment(); - return view.appendTo(fragment).then(() => { - dom.prepend($actionManager, fragment, { - callbacks: [{ widget: view }], - in_DOM: true, - }); - - if (!params.doNotDisableAHref) { - [...view.el.getElementsByTagName('A')].forEach((elem) => { - elem.addEventListener('click', (ev) => { - ev.preventDefault(); - }); - }); - } - return view; - }); + // render the viewController in a fragment as they must be able to render correctly + // without being in the DOM + const fragment = document.createDocumentFragment(); + await viewController.appendTo(fragment); + dom.prepend(actionManager, fragment, { + callbacks: [{ widget: viewController }], + in_DOM: true, }); + + if (!params.doNotDisableAHref) { + [...viewController.el.getElementsByTagName('A')].forEach(elem => { + elem.addEventListener('click', ev => { + ev.preventDefault(); + }); + }); + } + return viewController; } /** @@ -395,17 +395,17 @@ odoo.define('web.test_utils_create', function (require) { */ function prepareTarget(debug = false) { document.body.classList.toggle('debug', debug); - return debug ? document.body : document.querySelector("#qunit-fixture"); + return debug ? document.body : document.getElementById('qunit-fixture'); } return { - createActionManager: createActionManager, - createCalendarView: createCalendarView, - createControlPanel: createControlPanel, - createDebugManager: createDebugManager, - createModel: createModel, - createParent: createParent, - createView: createView, - prepareTarget: prepareTarget, + createActionManager, + createCalendarView, + createControlPanel, + createDebugManager, + createModel, + createParent, + createView, + prepareTarget, }; }); diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js index 3511bb398ce..ceca0d21fc9 100644 --- a/addons/web/static/tests/helpers/test_utils_dom.js +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -1,7 +1,7 @@ odoo.define('web.test_utils_dom', function (require) { "use strict"; - var concurrency = require('web.concurrency'); + const concurrency = require('web.concurrency'); /** * DOM Test Utils @@ -13,40 +13,29 @@ odoo.define('web.test_utils_dom', function (require) { */ //------------------------------------------------------------------------- - // Private + // Private functions //------------------------------------------------------------------------- - function keyboardEventBubble(args) { - return Object.assign({}, args, { bubbles: true, keyCode: args.which }); - } - - function mouseEventMapping(args) { - return Object.assign({}, args, { - view: window, - bubbles: true, - cancelable: true, - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - }); - } - - function mouseEventNoBubble(args) { - return Object.assign({}, args, { - view: window, - bubbles: false, - cancelable: false, - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - }); - } - - function onlyBubble(args) { - return Object.assign({}, args, { - bubbles: true - }); - } - - const eventTypes = { + // TriggerEvent helpers + const keyboardEventBubble = args => Object.assign({}, args, { bubbles: true, keyCode: args.which }); + const mouseEventMapping = args => Object.assign({}, args, { + bubbles: true, + cancelable: true, + clientX: args ? args.pageX : undefined, + clientY: args ? args.pageY : undefined, + view: window, + }); + const mouseEventNoBubble = args => Object.assign({}, args, { + bubbles: false, + cancelable: false, + clientX: args ? args.pageX : undefined, + clientY: args ? args.pageY : undefined, + view: window, + }); + const noBubble = args => Object.assign({}, args, { bubbles: false }); + const onlyBubble = args => Object.assign({}, args, { bubbles: true }); + // TriggerEvent constructor/args processor mapping + const EVENT_TYPES = { auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, click: { constructor: MouseEvent, processParameters: mouseEventMapping }, contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping }, @@ -60,17 +49,9 @@ odoo.define('web.test_utils_dom', function (require) { mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping }, mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping }, - focus: { - constructor: FocusEvent, processParameters: (args) => { - return Object.assign({}, args, { bubbles: false }); - } - }, + focus: { constructor: FocusEvent, processParameters: noBubble }, focusin: { constructor: FocusEvent, processParameters: onlyBubble }, - blur: { - constructor: FocusEvent, processParameters: (args) => { - return Object.assign({}, args, { bubbles: false }); - } - }, + blur: { constructor: FocusEvent, processParameters: noBubble }, cut: { constructor: ClipboardEvent, processParameters: onlyBubble }, copy: { constructor: ClipboardEvent, processParameters: onlyBubble }, @@ -95,7 +76,10 @@ odoo.define('web.test_utils_dom', function (require) { * @param {Object} node * @returns {boolean} */ - function isEventTarget(node) { + function _isEventTarget(node) { + if (!node) { + throw new Error(`Provided node is ${node}.`); + } if (node instanceof window.top.EventTarget) { return true; } @@ -105,7 +89,7 @@ odoo.define('web.test_utils_dom', function (require) { } //------------------------------------------------------------------------- - // Public + // Public functions //------------------------------------------------------------------------- /** @@ -126,7 +110,7 @@ odoo.define('web.test_utils_dom', function (require) { if (typeof el === 'string') { el = $(el); } - if (isEventTarget(el)) { + if (_isEventTarget(el)) { // EventTarget matches = [el]; } else { @@ -174,8 +158,8 @@ odoo.define('web.test_utils_dom', function (require) { * element event if it is invisible * @returns {Promise} */ - function clickFirst(el, options) { - return click(el, _.extend({}, options, { first: true })); + async function clickFirst(el, options) { + return click(el, Object.assign({}, options, { first: true })); } /** @@ -189,12 +173,12 @@ odoo.define('web.test_utils_dom', function (require) { * element event if it is invisible * @returns {Promise} */ - function clickLast(el, options) { - return click(el, _.extend({}, options, { last: true })); + async function clickLast(el, options) { + return click(el, Object.assign({}, options, { last: true })); } /** - * simulate a drag and drop operation between 2 jquery nodes: $el and $to. + * Simulate a drag and drop operation between 2 jquery nodes: $el and $to. * This is a crude simulation, with only the mousedown, mousemove and mouseup * events, but it is enough to help test drag and drop operations with jqueryUI * sortable. @@ -225,25 +209,25 @@ odoo.define('web.test_utils_dom', function (require) { */ async function dragAndDrop($el, $to, options) { let el = null; - if (isEventTarget($el)) { + if (_isEventTarget($el)) { el = $el; $el = $(el); } - if (isEventTarget($to)) { + if (_isEventTarget($to)) { $to = $($to); } options = options || {}; - var position = options.position || 'center'; - var elementCenter = $el.offset(); - var toOffset = $to.offset(); + const position = options.position || 'center'; + const elementCenter = $el.offset(); + const toOffset = $to.offset(); - if (_.isObject(position)) { + if (typeof position === 'object') { toOffset.top += position.top + 1; toOffset.left += position.left + 1; } else { toOffset.top += $to.outerHeight() / 2; toOffset.left += $to.outerWidth() / 2; - var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1; + const vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1; if (position === 'top') { toOffset.top -= $to.outerHeight() / 2 + vertical_offset; } else if (position === 'bottom') { @@ -257,7 +241,7 @@ odoo.define('web.test_utils_dom', function (require) { if ($to[0].ownerDocument !== document) { // we are in an iframe - var bound = $('iframe')[0].getBoundingClientRect(); + const bound = $('iframe')[0].getBoundingClientRect(); toOffset.left += bound.left; toOffset.top += bound.top; } @@ -301,11 +285,11 @@ odoo.define('web.test_utils_dom', function (require) { } /** - * Opens the datepicker of a given element. + * Open the datepicker of a given element. * * @param {jQuery} $datepickerEl element to which a datepicker is attached */ - function openDatepicker($datepickerEl) { + async function openDatepicker($datepickerEl) { return click($datepickerEl.find('.o_datepicker_input')); } @@ -325,7 +309,7 @@ odoo.define('web.test_utils_dom', function (require) { } /** - * Triggers an event on the specified target. + * Trigger an event on the specified target. * This function will dispatch a native event to an EventTarget or a * jQuery event to a jQuery object. This behaviour can be overridden by the * jquery option. @@ -340,7 +324,7 @@ odoo.define('web.test_utils_dom', function (require) { async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) { let matches; let selectorMsg = ""; - if (isEventTarget(el)) { + if (_isEventTarget(el)) { matches = [el]; } else { matches = [...el]; @@ -353,15 +337,15 @@ odoo.define('web.test_utils_dom', function (require) { const target = matches[0]; let event; - if (!eventTypes[eventType] && !eventTypes[eventType.type]) { + if (!EVENT_TYPES[eventType] && !EVENT_TYPES[eventType.type]) { event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true })); } else { if (typeof eventType === "object") { - const { constructor, processParameters } = eventTypes[eventType.type]; + const { constructor, processParameters } = EVENT_TYPES[eventType.type]; const eventParameters = processParameters(eventType); event = new constructor(eventType.type, eventParameters); } else { - const { constructor, processParameters } = eventTypes[eventType]; + const { constructor, processParameters } = EVENT_TYPES[eventType]; event = new constructor(eventType, processParameters(eventAttrs)); } } @@ -370,7 +354,7 @@ odoo.define('web.test_utils_dom', function (require) { } /** - * Triggers multiple events on the specified element. + * Trigger multiple events on the specified element. * * @param {EventTarget|EventTarget[]} el * @param {string[]} events the events you want to trigger @@ -387,10 +371,32 @@ odoo.define('web.test_utils_dom', function (require) { } for (let e = 0; e < events.length; e++) { - await triggerEvent(el, events[e], {}); + await triggerEvent(el, events[e]); } } + /** + * Simulate a keypress event for a given character + * + * @param {string} char the character, or 'ENTER' + * @returns {Promise} + */ + async function triggerKeypressEvent(char) { + let keycode; + if (char === 'Enter') { + keycode = $.ui.keyCode.ENTER; + } else if (char === "Tab") { + keycode = $.ui.keyCode.TAB; + } else { + keycode = char.charCodeAt(0); + } + return triggerEvent(document.body, 'keypress', { + key: char, + keyCode: keycode, + which: keycode, + }); + } + /** * simulate a mouse event with a custom event who add the item position. This is * sometimes necessary because the basic way to trigger an event (such as @@ -400,7 +406,7 @@ odoo.define('web.test_utils_dom', function (require) { * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' * @returns {Promise} */ - function triggerMouseEvent($el, type) { + async function triggerMouseEvent($el, type) { const el = $el instanceof jQuery ? $el[0] : $el; if (!el) { throw new Error(`no target found to trigger MouseEvent`); @@ -426,9 +432,9 @@ odoo.define('web.test_utils_dom', function (require) { * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' * @returns {HTMLElement} */ - function triggerPositionalMouseEvent(x, y, type) { - var ev = document.createEvent("MouseEvent"); - var el = document.elementFromPoint(x, y); + async function triggerPositionalMouseEvent(x, y, type) { + const ev = document.createEvent("MouseEvent"); + const el = document.elementFromPoint(x, y); ev.initMouseEvent( type, true /* bubble */, @@ -442,39 +448,17 @@ odoo.define('web.test_utils_dom', function (require) { return el; } - /** - * simulate a keypress event for a given character - * - * @param {string} char the character, or 'ENTER' - * @returns {Promise} - */ - function triggerKeypressEvent(char) { - var keycode; - if (char === 'Enter') { - keycode = $.ui.keyCode.ENTER; - } else if (char === "Tab") { - keycode = $.ui.keyCode.TAB; - } else { - keycode = char.charCodeAt(0); - } - return triggerEvent(document.body, 'keypress', { - key: char, - keyCode: keycode, - which: keycode, - }); - } - return { - click: click, - clickFirst: clickFirst, - clickLast: clickLast, - dragAndDrop: dragAndDrop, - openDatepicker: openDatepicker, - returnAfterNextAnimationFrame: returnAfterNextAnimationFrame, - triggerEvent: triggerEvent, - triggerEvents: triggerEvents, - triggerKeypressEvent: triggerKeypressEvent, - triggerMouseEvent: triggerMouseEvent, - triggerPositionalMouseEvent: triggerPositionalMouseEvent, + click, + clickFirst, + clickLast, + dragAndDrop, + openDatepicker, + returnAfterNextAnimationFrame, + triggerEvent, + triggerEvents, + triggerKeypressEvent, + triggerMouseEvent, + triggerPositionalMouseEvent, }; }); diff --git a/addons/web/static/tests/helpers/test_utils_fields.js b/addons/web/static/tests/helpers/test_utils_fields.js index 4646fd98d83..060cfc1d437 100644 --- a/addons/web/static/tests/helpers/test_utils_fields.js +++ b/addons/web/static/tests/helpers/test_utils_fields.js @@ -10,8 +10,14 @@ odoo.define('web.test_utils_fields', function (require) { * testUtils file. */ - var concurrency = require('web.concurrency'); - var domUtils = require('web.test_utils_dom'); + const testUtilsDom = require('web.test_utils_dom'); + + const ARROW_KEYS_MAPPING = { + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + up: 'ArrowUp', + }; //------------------------------------------------------------------------- // Public functions @@ -23,13 +29,13 @@ odoo.define('web.test_utils_fields', function (require) { * @param {string} fieldName * @param {[string]} selector if set, this will restrict the search for the m2o * input + * @returns {Promise} */ - function clickM2OHighlightedItem(fieldName, selector) { - var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; - var $dropdown = $(m2oSelector).autocomplete('widget'); + async function clickM2OHighlightedItem(fieldName, selector) { + const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; + const $dropdown = $(m2oSelector).autocomplete('widget'); // clicking on an li (no matter which one), will select the focussed one - $dropdown.find('li:first()').click(); - return concurrency.delay(0); + return testUtilsDom.click($dropdown[0].querySelector('li')); } /** @@ -44,17 +50,15 @@ odoo.define('web.test_utils_fields', function (require) { * @param {string} searchText * @returns {Promise} */ - function clickM2OItem(fieldName, searchText) { - var m2oSelector = `.o_field_many2one[name=${fieldName}] input`; - var $dropdown = $(m2oSelector).autocomplete('widget'); - var $target = $dropdown.find(`li:contains(${searchText})`).first(); + async function clickM2OItem(fieldName, searchText) { + const m2oSelector = `.o_field_many2one[name=${fieldName}] input`; + const $dropdown = $(m2oSelector).autocomplete('widget'); + const $target = $dropdown.find(`li:contains(${searchText})`).first(); if ($target.length !== 1 || !$target.is(':visible')) { - throw new Error('Menu item should be unique and visible'); + throw new Error('Menu item should be visible'); } - $target.mouseenter(); - $target.click(); - - return concurrency.delay(0); + $target.mouseenter(); // This is NOT a mouseenter event. See jquery.js:5516 for more headaches. + return testUtilsDom.click($target); } /** @@ -63,16 +67,16 @@ odoo.define('web.test_utils_fields', function (require) { * @param {string} fieldName * @param {[string]} selector if set, this will restrict the search for the m2o * input - * @returns {HTMLInputElement} the main many2one input + * @returns {Promise} the main many2one input */ async function clickOpenM2ODropdown(fieldName, selector) { - var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; - var matches = document.querySelectorAll(m2oSelector); + const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; + const matches = document.querySelectorAll(m2oSelector); if (matches.length !== 1) { throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`); } - await domUtils.click(matches[0]); + await testUtilsDom.click(matches[0]); return matches[0]; } @@ -88,7 +92,7 @@ odoo.define('web.test_utils_fields', function (require) { * @param {string[]} events * @returns {Promise} */ - function editAndTrigger(el, value, events) { + async function editAndTrigger(el, value, events) { if (el instanceof jQuery) { if (el.length !== 1) { throw new Error(`target ${el.selector} has length ${el.length} instead of 1`); @@ -97,7 +101,7 @@ odoo.define('web.test_utils_fields', function (require) { } else { el.value = value; } - return domUtils.triggerEvents(el, events); + return testUtilsDom.triggerEvents(el, events); } /** @@ -112,7 +116,7 @@ odoo.define('web.test_utils_fields', function (require) { * @param {string|number} value * @returns {Promise} */ - function editInput(el, value) { + async function editInput(el, value) { return editAndTrigger(el, value, ['input']); } @@ -143,30 +147,22 @@ odoo.define('web.test_utils_fields', function (require) { * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', {search: 'George'}); * * @param {string} fieldName - * @param {[Object]} options - * @param {[string]} options.selector - * @param {[string]} options.search - * @param {[string]} options.item + * @param {[Object]} [options = {}] + * @param {[string]} [options.selector] + * @param {[string]} [options.search] + * @param {[string]} [options.item] * @returns {Promise} */ - function searchAndClickM2OItem(fieldName, options) { - options = options || {}; - - return clickOpenM2ODropdown(fieldName, options.selector).then(function (input) { - var def; - if (options.search) { - input.value = options.search; - input.dispatchEvent(new Event('input')); - def = concurrency.delay(0); - } - return Promise.resolve(def).then(function () { - if (options.item) { - return clickM2OItem(fieldName, options.item); - } else { - return clickM2OHighlightedItem(fieldName, options.selector); - } - }); - }); + async function searchAndClickM2OItem(fieldName, options = {}) { + const input = await clickOpenM2ODropdown(fieldName, options.selector); + if (options.search) { + await editInput(input, options.search); + } + if (options.item) { + return clickM2OItem(fieldName, options.item); + } else { + return clickM2OHighlightedItem(fieldName, options.selector); + } } /** @@ -181,7 +177,15 @@ odoo.define('web.test_utils_fields', function (require) { */ function triggerKey(type, $el, keyCode) { type = 'key' + type; + const params = {}; if (typeof keyCode === 'string') { + // Key (new API) + if (keyCode in ARROW_KEYS_MAPPING) { + params.key = ARROW_KEYS_MAPPING[keyCode]; + } else { + params.key = keyCode[0].toUpperCase() + keyCode.slice(1).toLowerCase(); + } + // KeyCode/which (jQuery) if (keyCode.length > 1) { keyCode = keyCode.toUpperCase(); keyCode = $.ui.keyCode[keyCode]; @@ -189,8 +193,9 @@ odoo.define('web.test_utils_fields', function (require) { keyCode = keyCode.charCodeAt(0); } } - $el.trigger({ type: type, which: keyCode, keyCode: keyCode }); - return concurrency.delay(0); + params.keyCode = keyCode; + params.which = keyCode; + return testUtilsDom.triggerEvent($el, type, params); } /** @@ -216,15 +221,15 @@ odoo.define('web.test_utils_fields', function (require) { } return { - clickM2OHighlightedItem: clickM2OHighlightedItem, - clickM2OItem: clickM2OItem, - clickOpenM2ODropdown: clickOpenM2ODropdown, - editAndTrigger: editAndTrigger, - editInput: editInput, - editSelect: editSelect, - searchAndClickM2OItem: searchAndClickM2OItem, - triggerKey: triggerKey, - triggerKeydown: triggerKeydown, - triggerKeyup: triggerKeyup, + clickM2OHighlightedItem, + clickM2OItem, + clickOpenM2ODropdown, + editAndTrigger, + editInput, + editSelect, + searchAndClickM2OItem, + triggerKey, + triggerKeydown, + triggerKeyup, }; }); diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js index e72dcc0d692..bb2d1ef24a5 100644 --- a/addons/web/static/tests/helpers/test_utils_mock.js +++ b/addons/web/static/tests/helpers/test_utils_mock.js @@ -465,7 +465,7 @@ function intercept(widget, eventName, fn, propagate) { * @param {integer} hours the digits for hours (24h) * @param {integer} minutes * @param {integer} seconds - * @returns {function} a callback to unpatch window.Date. + * @returns {Function} a callback to unpatch window.Date. */ function patchDate(year, month, day, hours, minutes, seconds) { var RealDate = window.Date; diff --git a/addons/web/static/tests/helpers/test_utils_modal.js b/addons/web/static/tests/helpers/test_utils_modal.js index 5587eecfc01..ca3eeef11a7 100644 --- a/addons/web/static/tests/helpers/test_utils_modal.js +++ b/addons/web/static/tests/helpers/test_utils_modal.js @@ -1,36 +1,26 @@ odoo.define('web.test_utils_modal', function (require) { -"use strict"; + "use strict"; -/** - * Modal Test Utils - * - * This module defines various utility functions to help test pivot views. - * - * Note that all methods defined in this module are exported in the main - * testUtils file. - */ + /** + * Modal Test Utils + * + * This module defines various utility functions to help test pivot views. + * + * Note that all methods defined in this module are exported in the main + * testUtils file. + */ -var core = require('web.core'); -var concurrency = require('web.concurrency'); + const { _t } = require('web.core'); + const testUtilsDom = require('web.test_utils_dom'); -/** - * Click on a button in the footer of a modal (which contains a given string). - * Note that this method checks the unicity of the button. - * - * @param {string} text (in english: this method will perform the translation) - */ -function clickButton(text) { - var selector = `.modal-footer button:contains(${core._t(text)})`; - var $button = $(selector); - if ($button.length !== 1) { - throw new Error(`Found ${$button.length} button(s) containing '${text}'`); + /** + * Click on a button in the footer of a modal (which contains a given string). + * + * @param {string} text (in english: this method will perform the translation) + */ + function clickButton(text) { + return testUtilsDom.click($(`.modal-footer button:contains(${_t(text)})`)); } - $button.click(); - return concurrency.delay(0); -} - -return { - clickButton: clickButton, -}; + return { clickButton }; }); diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js index a4e814a0f68..b5b14d82d03 100644 --- a/addons/web_editor/static/tests/test_utils.js +++ b/addons/web_editor/static/tests/test_utils.js @@ -8,6 +8,80 @@ var Widget = require('web.Widget'); var Wysiwyg = require('web_editor.wysiwyg'); var options = require('web_editor.snippets.options'); +const COLOR_PICKER_TEMPLATE = ` + + +
+
+
+
+
+ + `; +const SNIPPETS_TEMPLATE = ` +

Add blocks

+
+
+
+ First Panel +
+
+
+
+
+
+
+
+
+
+
+
+

Title

+

Content

+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + Background Image + +
+
+ + Top + Middle + Bottom + Equal height + +
+
`; MockServer.include({ //-------------------------------------------------------------------------- @@ -19,84 +93,16 @@ MockServer.include({ * @private * @returns {Promise} */ - _performRpc: function (route, args) { + async _performRpc(route, args) { if (args.model === "ir.ui.view") { if (args.method === 'read_template' && args.args[0] === "web_editor.colorpicker") { - var template = '' + - '' + - '
' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - '
' + - '
' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - '
' + - '
' + - '
' + - '
'; - return Promise.resolve(template); + return COLOR_PICKER_TEMPLATE; } if (args.method === 'render_template' && args.args[0] === "web_editor.snippets") { - var template = '

Add blocks

' + - '
' + - '
' + - '
' + - ' First Panel' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '

Title

' + - '

Content

' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - ' ' + - '
' + - '
' + - ' Background Image' + - '
' + - '
' + - ' ' + - ' Top' + - ' Middle' + - ' Bottom' + - ' Equal height' + - ' ' + - '
' + - '
'; - return Promise.resolve(template); + return SNIPPETS_TEMPLATE; } } - return this._super.apply(this, arguments); + return this._super(...arguments); }, }); @@ -188,80 +194,14 @@ function wysiwygData(data) { }, }, records: [], - read_template: function (args) { + read_template(args) { if (args[0] === 'web_editor.colorpicker') { - return '' + - '' + - '
' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - '
' + - '
' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + - '
' + - '
' + - '
' + - '
'; + return COLOR_PICKER_TEMPLATE; } }, - render_template: function (args) { + render_template(args) { if (args[0] === 'web_editor.snippets') { - return '

Add blocks

' + - '
' + - '
' + - '
' + - ' First Panel' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '

Title

' + - '

Content

' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - ' ' + - '
' + - '
' + - ' Background Image' + - '
' + - '
' + - ' ' + - ' Top' + - ' Middle' + - ' Bottom' + - ' Equal height' + - ' ' + - '
' + - '
'; + return SNIPPETS_TEMPLATE; } }, }, @@ -758,7 +698,7 @@ var textInput = function (target, char) { if (!ev.defaultPrevented) { document.execCommand("insertText", 0, ev.data); } -} +}; return { wysiwygData: wysiwygData, From da8790f0bb3d561cda9a9db95a3e980f91a8e7fd Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 14 Jan 2020 10:45:41 +0000 Subject: [PATCH 03/10] [IMP] web: Dialog: add option to stop click propagation There are situations where we don't want clicks to be detected behind an opened dialog: for example, when a dialog is opened by a dropdown, we may want to keep the dropdown open while we interact with the dialog. There is now an option (default: true) to stop clicks propagation when they arrive at the dialog container. --- addons/web/static/src/js/core/owl_dialog.js | 28 +++++++++++++++------ addons/web/static/src/xml/base.xml | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/addons/web/static/src/js/core/owl_dialog.js b/addons/web/static/src/js/core/owl_dialog.js index 41b0dbf4562..d376e042ae4 100644 --- a/addons/web/static/src/js/core/owl_dialog.js +++ b/addons/web/static/src/js/core/owl_dialog.js @@ -31,6 +31,8 @@ odoo.define('web.OwlDialog', function () { * should be rendered. * @param {string} [props.size='large'] 'extra-large', 'large', 'medium' * or 'small'. + * @param {string} [props.stopClicks=true] whether the dialog should stop + * the clicks propagation outside of itself. * @param {string} [props.subtitle=''] * @param {string} [props.title='Odoo'] * @param {boolean} [props.technical=true] If set to false, the modal will have @@ -71,7 +73,7 @@ odoo.define('web.OwlDialog', function () { this._removeTooltips(); } - async willUnmount() { + willUnmount() { this.env.bus.off('close_dialogs', this, this._close); this._removeTooltips(); @@ -129,6 +131,16 @@ odoo.define('web.OwlDialog', function () { } } + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (this.props.stopClicks) { + ev.stopPropagation(); + } + } + /** * @private */ @@ -235,19 +247,21 @@ odoo.define('web.OwlDialog', function () { renderFooter: true, renderHeader: true, size: 'large', + stopClicks: true, technical: true, title: "Odoo", }; Dialog.props = { - backdrop: { validate: b => ['static', true, false].includes(b), optional: 1 }, + backdrop: { validate: b => ['static', true, false].includes(b) }, contentClass: { type: String, optional: 1 }, fullscreen: { type: Boolean, optional: 1 }, - renderFooter: { type: Boolean, optional: 1 }, - renderHeader: { type: Boolean, optional: 1 }, - size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s), optional: 1 }, + renderFooter: Boolean, + renderHeader: Boolean, + size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s) }, + stopClicks: Boolean, subtitle: { type: String, optional: 1 }, - technical: { type: Boolean, optional: 1 }, - title: { type: String, optional: 1 }, + technical: Boolean, + title: String, }; Dialog.template = 'OwlDialog'; diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 363097c6d89..07919bcfa84 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -28,7 +28,7 @@ -
+