We are refactoring 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 (did not feel right, also, 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 is a refactoring of the website builder coming soon (update the code to owl), 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. - 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 before start.
- By default, interactions are not started in "edit" mode, unless they are registered in the
public.interactions.edit
registry
Rough steps:
- import Interaction, and change the public widget to extend Interaction instead of PublicWidget (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 a 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 informations.
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" }] },
".coucou": { "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 or 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)
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
: prevent default the event.stop
: call.stopPropagation
on the event.capture
: add the listener in capture mode (not bubbling).once
: activate theonce
option (so, 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, throttle)
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 },
};
Note that dynamic selectors should only match 0 or 1 element.
Interaction define a few helpers to make it easy to handle common usecase 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(...));
// this line is only executed if the interaction has not been destroyed
this.doSomething(data);
waitForTimeout(fn, delay)
// todo
waitForAnimationFrame(fn)
// todo
debounced(fn, delay)
// todo
throttled(fn)
// todo
locked(fn, useLoadingAnimation=false)
// todo
addListener(target, event, fn, options)
// todo
insert(el, locationEl, position)
// todo
renderAt(template, ctx, el, position)
// todo
registerCleanup(fn)
// todo
mountComponent(el, C, props=null)
// todo