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
selector
value 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
selectorHas
andselectorNotHas
allow to avoid the:has()
pseudo-selector while still benefitting from its power. The goal is to support older browsers. - There is a
dynamicContent
object, 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.env
andthis.services
- The file
web/static/src/public/interaction.js
contains 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.edit
registry - By default, interactions are not started in the "add snippet" dialog, unless they are registered in the
public.interactions.preview
registry
Rough steps:
- import
Interaction
, and change the public widget to extendInteraction
instead 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-out
andt-component
- A falsy value on a class property will remove it.
- An
undefined
value on a style property will remove it. - On others attributes:
false
,undefined
ornull
remove it- other falsy values (
""
,0
) are applied as such (required=""
) - boolean
true
is 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:
.stop
is useful to also stop the propagation of the event for example (see below for more info on suffixes) - A static
INITIAL_VALUE
constant is available on theInteraction
class 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.preventDefault
on the event.stop
: call.stopPropagation
on the event.capture
: add the listener in capture mode (not bubbling).once
: activate theonce
option (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
.