We refactored the publicWidget system in the public (website/...) JavaScript codebase. The initial plan was to convert them to OWL components, but after some experiments, the idea was discarded (it did not feel right, and it would prevent serving pages without OWL in the future).
See:
- PR odoo/odoo#185998
- Main file:
web/static/src/public/interaction.js
Silent in the nest,
owl and colibri await,
morning’s gentle glow.
The website and JavaScript framework teams have been collaborating on a comprehensive refactoring of the publicWidget class. This is a project that aims to solve multiple issues:
- modernize public widgets (standard JS class, more idiomatic code, no longer uses jQuery,...)
- make it more declarative (for example, can declare
t-att-attributes) - solve common issues (event handlers are now attached after willstart, not before)
- a lifecycle closer to OWL, and that allows better control (start function is now synchronous, interactions can easily be stopped, started, and cleaned up)
- bring the frontend code closer to the web client code (use same ideas/code, such as registry, helpers, environment, services)
Also, note that there has been a refactoring of the website builder (to update the code to OWL and the new html_editor), so it was the right time to do this.
Here is what an Interaction look like:
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
export class HelloWorld extends Interaction {
static selector = ".o-hello-world";
dynamicContent = {
"button.show-count": {
"t-on-click": this.showNotification,
},
".some-selector": {
"t-att-class": () => ({ "d-none": this.count <= 5 }),
},
};
setup() {
this.count = 0;
}
showNotification() {
this.count++;
this.services.notification.add(`Current count: ${this.count}`);
}
}
registry
.category("public.interactions")
.add("my_addon.HelloWorld", HelloWorld);Notes:
- The static
selectorvalue represents the target for an interaction. When a page is loaded, an interaction will be instantiated for each element matching the selector. - Optional static properties
selectorHasandselectorNotHasallow to avoid the:has()pseudo-selector while still benefitting from its power. The goal is to support older browsers. - There is a
dynamicContentobject, which is useful to define event handlers, dynamic attributes, such as class or style, and other things. - The dynamic content is updated everytime an event is triggered
- Interactions have a lifecycle similar to OWL:
setup,willStart,start,destroy. - like for OWL components, we should not use the constructor.
- Interactions have a direct access to the environment and to all active services, with
this.envandthis.services - The file
web/static/src/public/interaction.jscontains the code. It is well documented. - There are some useful helpers in the class as well (
debounced,throttled,locked, ...) - Note that these helpers generally update properly the dynamic content, and also, clean up properly the DOM after the interaction has been destroyed.
- Event handlers are attached directly on target elements, there is no event delegation.
- Event handlers are now attached after
willStart, and beforestart. - By default, interactions are not started in "edit" mode, unless they are registered in the
public.interactions.editregistry - By default, interactions are not started in the "add snippet" dialog, unless they are registered in the
public.interactions.previewregistry
Rough steps:
- import
Interaction, and change the public widget to extendInteractioninstead ofpublicWidget(it is now a standard JavaScript class!) - adapt the code (see below)
- at the end of the file, register it in the public interaction registry
// before
publicWidget.registry.AccountPortalSidebar = PortalSidebar.extend({
...
});// after
export class AccountSidebar extends Sidebar {
...
}
registry
.category("public.interactions")
.add("account.account_sidebar", AccountSidebar);| publicWidget | Interaction |
|---|---|
this.$el |
this.el (note, this is an HTML element, not jQuery) |
this.$('.selector') |
this.el.querySelector('.selector') or this.el.querySelectorAll('.selector') |
.init(parent, options) |
.setup() (sometimes willStart if async, or even start if it interacts with the DOM) |
.willStart() |
.willStart() |
.start() |
.start() (is synchronous now) |
.destroy() |
.destroy() |
events |
dynamicContent |
custom_events |
removed. Usually replaced by using services or triggering events on a bus |
this.call('ui', 'unblock') |
this.services.ui.unblock() |
this.trigger(...) |
removed. If necessary, can be replaced by el.dispatchEvent(new CustomEvent("myevent, {detail: someInfo})) |
jsLibs |
removed. Can be replaced by await loadJS(myLib) in willStart |
$(el).data("someValue") |
el.dataset.someValue or maybe parseInt(el.dataset.someValue) |
You will find here a lot more information.
The dynamic content of an interaction is an object describing the set of "dynamic elements" managed by the framework: event handlers, dynamic attributes, dynamic content, sub components.
Its syntax looks like the following:
dynamicContent = {
".some-selector": { "t-on-click": (ev) => this.onClick(ev) },
".some-other-selector": {
"t-att-class": () => ({ "some-class": true }),
"t-att-style": () => ({ property: value }),
"t-att-other-attribute": () => value,
"t-out": () => value,
},
_root: { "t-component": () => [Component, { someProp: "value" }] },
".hello": { "t-on-click.stop": this.doSomething },
};- A selector is either a standard CSS selector, or a special keyword
(see dynamicSelectors:
_body,_root,_document,_window) - Accepted directives include:
t-on-,t-att-,t-outandt-component - A falsy value on a class property will remove it.
- An
undefinedvalue on a style property will remove it. - On others attributes:
false,undefinedornullremove it- other falsy values (
"",0) are applied as such (required="") - boolean
trueis applied as the attribute's name (e.g.{ "t-att-required": () => true }appliesrequired="required")
- Note that this is not OWL! It is similar, to make it easy to learn, but it is different, the syntax and semantics are somewhat different.
- like OWL, event handlers can use some special suffix:
.stopis useful to also stop the propagation of the event for example (see below for more info on suffixes) - A static
INITIAL_VALUEconstant is available on theInteractionclass itself to get back to the initial value at some point during the interaction life.
dynamicContent = {
".modal": {
"t-att-class": () => ({ show: this.someCondition ? true : Interaction.INITIAL_VALUE });
},
};The Interaction system supports a few suffixes to make it easy to handle common use cases when adding an event listener.
All these suffixes can be combined in any order. For example, t-on-click.prevent.capture.
.prevent: call.preventDefaulton the event.stop: call.stopPropagationon the event.capture: add the listener in capture mode (not bubbling).once: activate theonceoption (the handler will be removed after it fires once).noUpdate: prevent the interaction from updating the dynamic content. It is quite rare to need to do so..withTarget: add the current event target as an additional argument to the handler. It is useful in some functions that may lose the information because they execute later (debounced,throttled,locked)
Interactions can target "dynamic selectors", which are special keywords that will call a function to target some element that may even be outside the root of the interaction. By convention, they are prefixed with _. One of the most common use for dynamic selectors is to target the root of the interaction:
dynamicContent = {
_root: { "t-on-click": (ev) => this.onClick(ev) },
};By default, they are defined like this:
dynamicSelectors = {
_root: () => this.el,
_body: () => this.el.ownerDocument.body,
_window: () => window,
_document: () => this.el.ownerDocument,
};But the definition can be extended. For exemple, to target a modal element outside the interaction:
dynamicSelectors = Object.assign(this.dynamicSelectors, {
_modal: () => this.el.closest(".modal"),
_bg: () => this.el.querySelector(":scope > .s_parallax_bg"),
});
dynamicContent = {
_modal: { "t-on-shown.bs.modal": this.updateBackgroundHeight },
};Dynamic selectors can match any number of elements.
Interaction defines a few helpers to make it easy to handle common usecases properly, which is sometimes trickier than one might expect. This list is not set in stone. If you believe that you have a useful generic usecase, please reach out to us to see if adding it to the Interaction class is a good idea.
updateContent: force the framework to apply immediately the dynamic content. It is usually done after each event handler is executed, so this function is mostly useful when dealing with asynchronous code
waitFor(promise): wrap a promise into a promise that will only be resolved if the interaction has not been destroyed. It is the primary way to write safe asynchronous code:
const data = await this.waitFor(rpc(...));Note that waitFor should not be used inside willStart: you can just call the promise directly.
protectSyncAfterAsync(fn): after a waitFor, wraps synchronous code that might use the result from the promise. The code is protected even when the promise was cancelled because the interaction was destroyed.
const data = await this.waitFor(rpc(...));
this.protectSyncAfterAsync(() => {
// this line is only executed if the interaction has not been destroyed
this.updateDOM(data);
});waitForTimeout(fn, delay): executes a function after a specific timeout, if the interaction has not been destroyed. The dynamic content is then applied.
waitForAnimationFrame(fn): executes a function after an animation frame, if the interaction has not been destroyed. The dynamic content is then applied.
debounced(fn, delay): debounces a function and makes sure it is cancelled upon destroy.
throttled(fn): throttles a function for animation and makes sure it is cancelled upon destroy.
locked(fn, useLoadingAnimation=false): makes sure the callback is not called again before the current one is completed. A loading animation can be added on the button if the execution takes more than 400ms. locked is useful on events that an unsuspecting user could trigger several times in a row.
addListener(target, event, fn, options): adds a listener to one or a list of targets, and automatically removes it when the interaction is destroyed. The method returns a function to remove
the listeners it attached.
It works the same way as t-on- directives in the dynamicContent, but it is useful in cases where a listener should be added or removed during the interaction's life.
insert(el, locationEl=this.el, position="beforeend", removeOnClean=true): inserts an element at a specific position (beforebegin, afterbegin, beforeend, afterend), and activates interactions if it contains any. If removeOnClean is true (default), the inserted element will be removed when the interaction is destroyed.
renderAt(template, renderContext, locationEl, position="beforeend", callback, removeOnClean=true): renders an OWL template and inserts the rendered elements at a specific position (beforebegin, afterbegin, beforeend, afterend). Interactions are activated.
If a callback is passed, it will be called after rendering the template, but before inserting the elements to the DOM.
If removeOnClean is true (default), the inserted element will be removed when the interaction is destroyed.
removeChildren(el, insertBackOnClean=true): removes the children of an element. If insertBackOnClean is true (default), they will be reintroduced when the interaction is destroyed.
registerCleanup(fn): registers a function to be executed when the interaction is destroyed. This helps keeping the code delimited: we add the cleanup at the place the side effect is created.
mountComponent(el, C, props=null, position="beforeend"): mounts an OWL component (C) within the targeted element. props should either be an object or remain null.