diff --git a/addons/web/static/src/js/core/custom_hooks.js b/addons/web/static/src/js/core/custom_hooks.js index 81f625349b7..a99576b73ce 100644 --- a/addons/web/static/src/js/core/custom_hooks.js +++ b/addons/web/static/src/js/core/custom_hooks.js @@ -37,7 +37,77 @@ odoo.define('web.custom_hooks', function () { }; } + /** + * The useListener hook offers an alternative to Owl's classical event + * registration mechanism (with attribute 't-on-eventName' in xml). It is + * especially useful for abstract components, meant to be extended by + * specific ones. If those abstract components need to define event handlers, + * but don't have any template (because the template completely depends on + * specific cases), then using the 't-on' mechanism isn't adequate, as the + * handlers would be lost by the template override. In this case, using this + * hook instead is more convenient. + * + * Example: navigation event handling in AbstractField + * + * Usage: like all Owl hooks, this function has to be called in the + * constructor of an Owl component: + * + * useListener('click', () => { console.log('clicked'); }); + * + * An optional native query selector can be specified as second argument for + * event delegation. In this case, the handler is only called if the event + * is triggered on an element matching the given selector. + * + * useListener('click', 'button', () => { console.log('clicked'); }); + * + * Note: components that alter the event's target (e.g. Portal) are not + * expected to behave as expected with event delegation. + * + * @param {string} eventName the name of the event + * @param {string} [querySelector] a JS native selector for event delegation + * @param {function} handler the event handler (will be bound to the component) + */ + function useListener(eventName, querySelector, handler) { + if (arguments.length === 2) { + querySelector = null; + handler = arguments[1]; + } + if (!(typeof handler === 'function')) { + throw new Error('The handler must be a function'); + } + + const comp = Component.current; + let boundHandler; + if (querySelector) { + boundHandler = function (ev) { + let el = ev.target; + let target; + while (el && !target) { + if (el.matches(querySelector)) { + target = el; + } else if (el === comp.el) { + el = null; + } else { + el = el.parentElement; + } + } + if (el) { + handler.call(comp, ev); + } + }; + } else { + boundHandler = handler.bind(comp); + } + onMounted(function () { + comp.el.addEventListener(eventName, boundHandler); + }); + onWillUnmount(function () { + comp.el.removeEventListener(eventName, boundHandler); + }); + } + return { useFocusOnUpdate, + useListener, }; }); diff --git a/addons/web/static/tests/component_extension_tests.js b/addons/web/static/tests/component_extension_tests.js index c9fd5906a9f..f0b33db655c 100644 --- a/addons/web/static/tests/component_extension_tests.js +++ b/addons/web/static/tests/component_extension_tests.js @@ -6,6 +6,7 @@ odoo.define('web.component_extension_tests', function (require) { const { Component, tags } = owl; const { xml } = tags; + const { useListener } = require('web.custom_hooks'); QUnit.module("web", function () { QUnit.module("Component Extension"); @@ -43,5 +44,142 @@ odoo.define('web.component_extension_tests', function (require) { assert.ok(true, "Promise should still be pending"); }); + + QUnit.module("Custom Hooks"); + + QUnit.test("useListener handler type", async function (assert) { + assert.expect(1); + + class Parent extends Component { + constructor() { + super(); + useListener('custom1', '_onCustom1'); + } + } + Parent.env = makeTestEnvironment({}, () => Promise.reject()); + Parent.template = xml`
`; + + assert.throws(() => new Parent(null), 'The handler must be a function'); + }); + + QUnit.test("useListener in inheritance setting", async function (assert) { + assert.expect(12); + const fixture = document.body.querySelector('#qunit-fixture'); + + class Parent extends Component { + constructor() { + super(); + useListener('custom1', this._onCustom1); + useListener('custom2', this._onCustom2); + } + _onCustom1() { + assert.step(`${this.constructor.name} custom1`); + } + _onCustom2() { + assert.step('parent custom2'); + } + } + Parent.env = makeTestEnvironment({}, () => Promise.reject()); + Parent.template = xml`
`; + + class Child extends Parent { + constructor() { + super(); + useListener('custom2', this._onCustom2); + useListener('custom3', this._onCustom3); + } + _onCustom2() { + assert.step('overriden custom2'); + } + _onCustom3() { + assert.step('child custom3'); + } + } + + const parent = new Parent(null); + const child = new Child(null); + await parent.mount(fixture); + await child.mount(fixture); + + parent.trigger('custom1'); + assert.verifySteps(['Parent custom1']); + parent.trigger('custom2'); + assert.verifySteps(['parent custom2']); + parent.trigger('custom3'); + assert.verifySteps([]); + + child.trigger('custom1'); + assert.verifySteps(['Child custom1']); + // There are two handlers for that one (Parent and Child) + // Although the handler is overriden in Child + child.trigger('custom2'); + assert.verifySteps(['overriden custom2', 'overriden custom2']); + child.trigger('custom3'); + assert.verifySteps(['child custom3']); + parent.destroy(); + child.destroy(); + }); + + QUnit.test("useListener with native JS selector", async function (assert) { + assert.expect(3); + const fixture = document.body.querySelector('#qunit-fixture'); + + class Parent extends Component { + constructor() { + super(); + useListener('custom1', 'div .custom-class', this._onCustom1); + } + _onCustom1() { + assert.step(`custom1`); + } + } + Parent.env = makeTestEnvironment({}, () => Promise.reject()); + Parent.template = xml` +
+

no trigger

+

triggers

+
`; + + const parent = new Parent(null); + await parent.mount(fixture); + + parent.el.querySelector('p').dispatchEvent(new Event('custom1', {bubbles: true})); + assert.verifySteps([]); + parent.el.querySelector('h1').dispatchEvent(new Event('custom1', {bubbles: true})); + assert.verifySteps(['custom1']); + parent.destroy(); + }); + + QUnit.test("useListener with native JS selector delegation", async function (assert) { + assert.expect(3); + const fixture = document.body.querySelector('#qunit-fixture'); + + class Parent extends Component { + constructor() { + super(); + useListener('custom1', '.custom-class', this._onCustom1); + } + _onCustom1() { + assert.step(`custom1`); + } + } + Parent.env = makeTestEnvironment({}, () => Promise.reject()); + Parent.template = xml` +
+

no trigger

+

triggers

+
`; + + fixture.classList.add('custom-class'); + const parent = new Parent(null); + await parent.mount(fixture); + + parent.el.querySelector('p').dispatchEvent(new Event('custom1', {bubbles: true})); + assert.verifySteps([]); + parent.el.querySelector('h2').dispatchEvent(new Event('custom1', {bubbles: true})); + assert.verifySteps(['custom1']); + parent.destroy(); + fixture.classList.remove('custom-class'); + }); }); });