[IMP] runbot: add interaction framework

Copies the colibri + interaction framework from `web` module.
We also bootstrap interactions to be used in the frontend.
This commit is contained in:
William Braeckman 2025-03-10 16:12:14 +01:00
parent 0db0bfcf5b
commit 6ba9aaf411
7 changed files with 1416 additions and 2 deletions

View File

@ -1,8 +1,9 @@
import { whenReady, Component, xml, App, onError } from '@runbot/owl';
import { whenReady, Component, xml, App, onError, EventBus } from '@runbot/owl';
import { getTemplate } from '@web/core/templates';
import { registry } from '@web/core/registry';
import { useRegistry } from '@web/core/registry_hook';
import { InteractionService } from '@web/public/interaction_service';
const mainComponents = registry.category('main.components');
@ -56,9 +57,20 @@ class ComponentContainer extends Component {
(async function startApp() {
await whenReady();
const env = {
// These attributes are required by vendored data
bus: new EventBus(),
isReady: Promise.resolve(true),
services: {},
debug: odoo.debug,
};
const app = new App(ComponentContainer, {
getTemplate,
env: {},
env,
});
await app.mount(document.body);
const Interactions = registry.category('public.interactions').getAll();
const service = new InteractionService(document.body, env);
service.activate(Interactions);
})();

View File

@ -0,0 +1,360 @@
/** @odoo-module alias=@web/public/colibri default=false **/
/**
* This is a mini framework designed to make it easy to describe the dynamic
* content of a "interaction".
*/
let owl = null;
let Markup = null;
// Return this from event handlers to skip updateContent.
export const SKIP_IMPLICIT_UPDATE = Symbol();
export class Colibri {
constructor(core, I, el) {
this.el = el;
this.isReady = false;
this.isUpdating = false;
this.isDestroyed = false;
this.dynamicAttrs = [];
this.tOuts = [];
this.cleanups = [];
this.listeners = new Map();
this.dynamicNodes = new Map();
this.core = core;
this.interaction = new I(el, core.env, this);
this.interaction.setup();
}
async start() {
await this.interaction.willStart();
if (this.isDestroyed) {
return;
}
this.isReady = true;
const content = this.interaction.dynamicContent;
if (content) {
this.processContent(content);
this.updateContent();
}
this.interaction.start();
}
addListener(nodes, event, fn, options) {
if (typeof fn !== "function") {
throw new Error(`Invalid listener for event '${event}' (not a function)`);
}
if (!this.isReady) {
throw new Error(
"this.addListener can only be called after the interaction is started. Maybe move the call in the start method."
);
}
const re = /^(?<event>.*)\.(?<suffix>prevent|stop|capture|once|noUpdate|withTarget)$/;
let groups = re.exec(event)?.groups;
while (groups) {
fn = {
prevent:
(f) =>
(ev, ...args) => {
ev.preventDefault();
return f.call(this.interaction, ev, ...args);
},
stop:
(f) =>
(ev, ...args) => {
ev.stopPropagation();
return f.call(this.interaction, ev, ...args);
},
capture: (f) => {
options ||= {};
options.capture = true;
return f;
},
once: (f) => {
options ||= {};
options.once = true;
return f;
},
noUpdate:
(f) =>
(...args) => {
f.call(this.interaction, ...args);
return SKIP_IMPLICIT_UPDATE;
},
withTarget:
(f) =>
(ev, ...args) => {
const currentTarget = ev.currentTarget;
return f.call(this.interaction, ev, currentTarget, ...args);
},
}[groups.suffix](fn);
event = groups.event;
groups = re.exec(event)?.groups;
}
const handler = fn.isHandler
? fn
: async (...args) => {
if (SKIP_IMPLICIT_UPDATE !== (await fn.call(this.interaction, ...args))) {
if (!this.isDestroyed) {
this.updateContent();
}
}
};
handler.isHandler = true;
for (const node of nodes) {
node.addEventListener(event, handler, options);
this.cleanups.push(() => node.removeEventListener(event, handler, options));
}
return [event, handler, options];
}
refreshListeners() {
for (const sel of this.listeners.keys()) {
const nodes = this.getNodes(sel);
const newNodes = new Set(nodes);
const oldNodes = this.dynamicNodes.get(sel);
const events = this.listeners.get(sel);
const toRemove = new Set();
for (const node of oldNodes) {
if (newNodes.has(node)) {
newNodes.delete(node);
} else {
toRemove.add(node);
}
}
for (const event of Object.keys(events)) {
const [handler, options] = events[event];
for (const node of toRemove) {
node.removeEventListener(event, handler, options);
}
if (newNodes.size) {
this.addListener(newNodes, event, handler, options);
}
}
this.dynamicNodes.set(sel, nodes);
}
}
mapSelectorToListeners(sel, event, handler, options) {
if (this.listeners.has(sel)) {
this.listeners.get(sel)[event] = [handler, options];
} else {
this.listeners.set(sel, { [event]: [handler, options] });
}
}
mountComponent(nodes, C, props) {
for (const node of nodes) {
const root = this.core.prepareRoot(node, C, props);
root.mount();
this.cleanups.push(() => root.destroy());
}
}
applyTOut(el, value) {
if (!Markup) {
owl = odoo.loader.modules.get("@runbot/owl");
if (owl) {
Markup = owl.markup("").constructor;
}
}
if (Markup && value instanceof Markup) {
el.innerHTML = value;
} else {
el.textContent = value;
}
}
applyAttr(el, attr, value) {
if (attr === "class") {
if (typeof value !== "object") {
throw new Error("t-att-class directive expects an object");
}
for (const cl in value) {
for (const c of cl.trim().split(" ")) {
el.classList.toggle(c, value[cl] || false);
}
}
} else if (attr === "style") {
if (typeof value !== "object") {
throw new Error("t-att-style directive expects an object");
}
for (const prop in value) {
let style = value[prop];
if (style === undefined) {
el.style.removeProperty(prop);
} else {
style = String(style);
if (style.endsWith(" !important")) {
el.style.setProperty(
prop,
style.substring(0, style.length - 11),
"important"
);
} else {
el.style.setProperty(prop, style);
}
}
}
} else {
if ([false, undefined, null].includes(value)) {
el.removeAttribute(attr);
} else {
if (value === true) {
value = attr;
}
el.setAttribute(attr, value);
}
}
}
getNodes(sel) {
const selectors = this.interaction.dynamicSelectors;
if (sel in selectors) {
const elem = selectors[sel]();
return elem ? [elem] : [];
}
return this.interaction.el.querySelectorAll(sel);
}
processContent(content) {
for (const sel in content) {
if (sel.startsWith("t-")) {
throw new Error(`Selector missing for key ${sel} in dynamicContent (interaction '${this.interaction.constructor.name}').`);
}
let nodes;
if (this.dynamicNodes.has(sel)) {
nodes = this.dynamicNodes.get(sel);
} else {
nodes = this.getNodes(sel);
this.dynamicNodes.set(sel, nodes);
}
const descr = content[sel];
for (const directive in descr) {
const value = descr[directive];
if (directive.startsWith("t-on-")) {
const ev = directive.slice(5);
const [event, handler, options] = this.addListener(nodes, ev, value);
this.mapSelectorToListeners(sel, event, handler, options);
} else if (directive.startsWith("t-att-")) {
const attr = directive.slice(6);
this.dynamicAttrs.push({ nodes, attr, definition: value, initialValues: null });
} else if (directive === "t-out") {
this.tOuts.push([nodes, value]);
} else if (directive === "t-component") {
const { Component } = odoo.loader.modules.get("@runbot/owl");
if (Object.prototype.isPrototypeOf.call(Component, value)) {
this.mountComponent(nodes, value);
} else {
this.mountComponent(nodes, ...value());
}
} else {
const suffix = directive.startsWith("t-") ? "" : " (should start with t-)";
throw new Error(`Invalid directive: '${directive}'${suffix}`);
}
}
}
}
updateContent() {
if (this.isDestroyed || !this.isReady) {
throw new Error(
"Cannot update content of an interaction that is not ready or is destroyed"
);
}
if (this.isUpdating) {
throw new Error("Updatecontent should not be called while interaction is updating");
}
this.isUpdating = true;
const errors = [];
const interaction = this.interaction;
for (const dynamicAttr of this.dynamicAttrs) {
const { nodes, attr, definition, initialValues } = dynamicAttr;
let valuePerNode;
if (!initialValues) {
valuePerNode = new Map();
dynamicAttr.initialValues = valuePerNode;
}
for (const node of nodes) {
try {
const value = definition.call(interaction, node);
if (!initialValues) {
let attrValue;
switch (attr) {
case "class":
attrValue = [];
for (const classNames of Object.keys(value)) {
attrValue[classNames] = node.classList.contains(classNames);
}
break;
case "style":
attrValue = {};
for (const property of Object.keys(value)) {
const propertyValue = node.style.getPropertyValue(property);
const priority = node.style.getPropertyPriority(property);
attrValue[property] = propertyValue
? propertyValue + (priority ? ` !${priority}` : "")
: "";
}
break;
default:
attrValue = node.getAttribute(attr);
}
valuePerNode.set(node, attrValue);
}
this.applyAttr(node, attr, value);
} catch (e) {
errors.push({ error: e, attribute: attr });
}
}
}
for (const [nodes, definition] of this.tOuts) {
for (const node of nodes) {
this.applyTOut(node, definition.call(interaction, node));
}
}
this.isUpdating = false;
if (errors.length) {
const { attribute, error } = errors[0];
throw Error(
`An error occured while updating dynamic attribute '${attribute}' (in interaction '${this.interaction.constructor.name}')`,
{ cause: error }
);
}
}
destroy() {
// restore t-att to their initial values
for (const dynAttrs of this.dynamicAttrs) {
const { nodes, attr, initialValues } = dynAttrs;
if (!initialValues) {
continue;
}
for (const node of nodes) {
const initialValue = initialValues.get(node);
this.applyAttr(node, attr, initialValue);
}
}
for (const cleanup of this.cleanups.reverse()) {
cleanup();
}
this.cleanups = [];
this.listeners.clear();
this.dynamicNodes.clear();
this.interaction.destroy();
this.core = null;
this.isDestroyed = true;
this.isReady = false;
}
/**
* Patchable mechanism to handle context-specific protection of a specific
* chunk of synchronous code after returning from an asynchronous one.
* This should typically be used around code that follows an
* await waitFor(...).
*/
protectSyncAfterAsync(interaction, name, fn) {
return fn.bind(interaction);
}
}

View File

@ -0,0 +1,409 @@
/** @odoo-module alias=@web/public/interaction default=false **/
import { renderToFragment } from "@web/core/utils/render";
import { debounce, throttleForAnimation } from "@web/core/utils/timing";
import { SKIP_IMPLICIT_UPDATE } from "./colibri";
import { makeAsyncHandler, makeButtonHandler } from "./utils";
/**
* This is the base class to describe interactions. The Interaction class
* provides a good integration with the web framework (env/services), a well
* specified lifecycle, some dynamic content, and a few helper functions
* designed to accomplish common tasks, such as adding dom listener or waiting for
* some task to complete.
*
* Note that even though interactions are not destroyed in the standard workflow
* (a user visiting the website), there are still some cases where it happens:
* for example, when someone switch the website in "edit" mode. This means that
* interactions should gracefully clean up after themselves.
*/
export class Interaction {
/**
* This static property describes the set of html element targeted by this
* interaction. An instance will be created for each match when the website
* framework is initialized.
*
* @type {string}
*/
static selector = "";
/**
* The `selectorHas` attribute, if defined, allows to filter elements found
* through the `selector` attribute by only considering those which contain
* at least an element which matches this `selectorHas` selector.
*
* Note that this is the equivalent of setting up a `selector` using the
* `:has` pseudo-selector but that pseudo-selector is known to not be fully
* supported in all browsers. To prevent useless crashes, using this
* `selectorHas` attribute should be preferred.
*
* @type {string}
*/
static selectorHas = "";
/**
* Note that a dynamic selector is allowed to return a falsy value, for ex
* the result of a querySelector. In that case, the directive will simply be
* ignored.
*
* @type {Object.<string, Function>}
*/
dynamicSelectors = {
_root: () => this.el,
_body: () => this.el.ownerDocument.body,
_window: () => window,
_document: () => this.el.ownerDocument,
};
/**
* 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" }] },
* }
*
* 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 and t-component
*
* A falsy value on a class or style property will remove it.
* On others attributes:
* - `false`, `undefined` or `null` 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 }` applies `required="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.
*
* @type {Object}
*/
dynamicContent = {};
/**
* The constructor is not supposed to be defined in a subclass. Use setup
* instead
*
* @param {HTMLElement} el
* @param {import("@web/env").OdooEnv} env
* @param {Object} metadata
*/
constructor(el, env, metadata) {
this.__colibri__ = metadata;
this.el = el;
this.env = env;
this.services = env.services;
}
/**
* Returns true if the interaction has been started (so, just before the
* start method is called)
*/
get isReady() {
return this.__colibri__.isReady;
}
get isDestroyed() {
return this.__colibri__.isDestroyed;
}
// -------------------------------------------------------------------------
// lifecycle methods
// -------------------------------------------------------------------------
/**
* This is the standard constructor method. This is the proper place to
* initialize everything needed by the interaction. The el element is
* available and can be used. Services are ready and available as well.
*/
setup() {}
/**
* If the interaction needs some asynchronous work to be ready, it should
* be done here. The website framework will wait for this method to complete
* before applying the dynamic content (event handlers, ...)
*/
async willStart() {}
/**
* The start function when we need to execute some code after the interaction
* is ready. It is the equivalent to the "mounted" owl lifecycle hook. At
* this point, event handlers have been attached.
*/
start() {}
/**
* All side effects done should be cleaned up here. Note that like all
* other lifecycle methods, it is not necessary to call the super.destroy
* method (unless you inherit from a concrete subclass)
*/
destroy() {}
// -------------------------------------------------------------------------
// helpers
// -------------------------------------------------------------------------
/**
* This method applies the dynamic content description to the dom. So, if
* a dynamic attribute has been defined with a t-att-, it will be done
* synchronously by this method. Note that updateContent is already being
* called after each event handler, and by most other helpers, so in practice,
* it is not common to need to call it.
*/
updateContent() {
this.__colibri__.updateContent();
}
/**
* Wrap a promise into a promise that will only be resolved if the interaction
* has not been destroyed, and will also call updateContent after the calling
* code has acted.
*/
waitFor(promise) {
const prom = new Promise((resolve, reject) => {
promise
.then((result) => {
if (!this.isDestroyed) {
resolve(result);
prom.then(() => {
if (this.isReady) {
this.updateContent();
}
});
}
})
.catch((e) => {
reject(e);
prom.catch(() => {
if (this.isReady && !this.isDestroyed) {
this.updateContent();
}
});
});
});
return prom;
}
/**
* Mechanism to handle context-specific protection of a specific
* chunk of synchronous code after returning from an asynchronous one.
* This should typically be used around code that follows an
* await waitFor(...).
*/
protectSyncAfterAsync(fn) {
return this.__colibri__.protectSyncAfterAsync(this, "protectSyncAfterAsync", fn);
}
/**
* Wait for a specific timeout, then execute the given function (unless the
* interaction has been destroyed). The dynamic content is then applied.
*/
waitForTimeout(fn, delay) {
fn = this.__colibri__.protectSyncAfterAsync(this, "waitForTimeout", fn);
return setTimeout(() => {
if (!this.isDestroyed) {
fn.call(this);
if (this.isReady) {
this.updateContent();
}
}
}, parseInt(delay));
}
/**
* Wait for a animation frame, then execute the given function (unless the
* interaction has been destroyed). The dynamic content is then applied.
*/
waitForAnimationFrame(fn) {
fn = this.__colibri__.protectSyncAfterAsync(this, "waitForAnimationFrame", fn);
return window.requestAnimationFrame(() => {
if (!this.isDestroyed) {
fn.call(this);
if (this.isReady) {
this.updateContent();
}
}
});
}
/**
* Debounces a function and makes sure it is cancelled upon destroy.
*/
debounced(fn, delay) {
fn = this.__colibri__.protectSyncAfterAsync(this, "debounced", fn);
const debouncedFn = debounce(async (...args) => {
await fn.apply(this, args);
if (this.isReady && !this.isDestroyed) {
this.updateContent();
}
}, delay);
this.registerCleanup(() => {
debouncedFn.cancel();
});
return Object.assign(
{
[debouncedFn.name]: (...args) => {
debouncedFn(...args);
return SKIP_IMPLICIT_UPDATE;
},
}[debouncedFn.name],
{
cancel: debouncedFn.cancel,
}
);
}
/**
* Throttles a function for animation and makes sure it is cancelled upon destroy.
*/
throttled(fn) {
fn = this.__colibri__.protectSyncAfterAsync(this, "throttled", fn);
const throttledFn = throttleForAnimation(async (...args) => {
await fn.apply(this, args);
if (this.isReady && !this.isDestroyed) {
this.updateContent();
}
});
this.registerCleanup(() => {
throttledFn.cancel();
});
return Object.assign(
{
[throttledFn.name]: (...args) => {
throttledFn(...args);
return SKIP_IMPLICIT_UPDATE;
},
}[throttledFn.name],
{
cancel: throttledFn.cancel,
}
);
}
/**
* Make sure the function is not started again before it is completed.
* If required, add a loading animation on button if the execution takes
* more than 400ms.
*/
locked(fn, useLoadingAnimation = false) {
fn = this.__colibri__.protectSyncAfterAsync(this, "locked", fn);
if (useLoadingAnimation) {
return makeButtonHandler(fn);
} else {
return makeAsyncHandler(fn);
}
}
/**
* Add a listener to the target. Whenever the listener is executed, the
* dynamic content will be applied. Also, the listener will automatically be
* cleaned up when the interaction is destroyed.
* Returns a function to remove the listener(s).
*
* @param {EventTarget|EventTarget[]|NodeList} target one or more element(s) / bus
* @param {string} event
* @param {Function} fn
* @param {Object} [options]
* @returns {Function} removes the listeners
*/
addListener(target, event, fn, options) {
const nodes = target[Symbol.iterator] ? target : [target];
const [ev, handler, opts] = this.__colibri__.addListener(nodes, event, fn, options);
return () => nodes.forEach((node) => node.removeEventListener(ev, handler, opts));
}
/**
* Recomputes eventListeners registered through `dynamicContent`.
* Interaction listeners are static. If the DOM is updated at some point,
* you should call `refreshListeners` so that events are:
* - removed on nodes that don't match the selector anymore
* - added on new nodes or on nodes that didn't match it before but do now.
*/
refreshListeners() {
this.__colibri__.refreshListeners();
}
/**
* Insert and activate an element at a specific location (default position:
* "beforeend").
* The inserted element will be removed when the interaction is destroyed.
*
* @param { HTMLElement } el
* @param { HTMLElement } [locationEl] the target
* @param { "afterbegin" | "afterend" | "beforebegin" | "beforeend" } [position]
* @param { boolean } [removeOnClean]
*/
insert(el, locationEl = this.el, position = "beforeend", removeOnClean = true) {
locationEl.insertAdjacentElement(position, el);
if (removeOnClean) {
this.registerCleanup(() => el.remove());
}
this.services["public.interactions"].startInteractions(el);
this.refreshListeners();
}
/**
* Renders, insert and activate an element at a specific location.
* The inserted element will be removed when the interaction is destroyed.
*
* @param { string } template
* @param { Object } renderContext
* @param { HTMLElement } [locationEl] the target
* @param { "afterbegin" | "afterend" | "beforebegin" | "beforeend" } [position]
* @param { Function } callback called with rendered elements before insertion
* @param { boolean } [removeOnClean]
* @returns { HTMLElement[] } rendered elements
*/
renderAt(
template,
renderContext,
locationEl,
position = "beforeend",
callback,
removeOnClean = true
) {
const fragment = renderToFragment(template, renderContext);
const result = [...fragment.children];
const els = [...fragment.children];
callback?.(els);
if (["afterend", "afterbegin"].includes(position)) {
els.reverse();
}
for (const el of els) {
this.insert(el, locationEl, position, removeOnClean);
}
return result;
}
/**
* Register a function that will be executed when the interaction is
* destroyed. It is sometimes useful, so we can explicitely add the cleanup
* at the location where the side effect is created.
*
* @param {Function} fn
*/
registerCleanup(fn) {
this.__colibri__.cleanups.push(fn.bind(this));
}
/**
* @param {HTMLElement} el
* @param {import("@odoo/owl").Component} C
* @param {Object|null} [props]
*/
mountComponent(el, C, props = null) {
this.__colibri__.mountComponent([el], C, props);
}
}

View File

@ -0,0 +1,206 @@
/** @odoo-module alias=@web/public/interaction_service default=false **/
import { Interaction } from "./interaction";
import { getTemplate } from "@web/core/templates";
import { PairSet } from "./utils";
import { Colibri } from "./colibri";
/**
* Website Core
*
* This service handles the core interactions for the website codebase.
* It will replace public root, publicroot instance, and all that stuff
*
* We have 2 kinds of interactions:
* - simple interactions (subclasses of Interaction)
* - components
*
* The Interaction class is designed to be a simple class that provides access
* to the framework (env and services), and a minimalist declarative framework
* that allows manipulating dom, attaching event handlers and updating it
* properly. It does not depend on owl.
*
* The Component kind of interaction is used for more complicated interface needs.
* It provides full access to Owl features, but is rendered browser side.
*
*/
export class InteractionService {
/**
*
* @param {HTMLElement} el
* @param {Object} env
*/
constructor(el, env) {
this.Interactions = [];
this.el = el;
this.isActive = false;
// relation el <--> Interaction
this.activeInteractions = new PairSet();
this.env = env;
this.interactions = [];
this.roots = [];
this.owlApp = null;
this.proms = [];
this.registry = null;
}
/**
*
* @param {Interaction[]} Interactions
*/
activate(Interactions) {
this.Interactions = Interactions;
const startProm = this.env.isReady.then(() => this.startInteractions());
this.proms.push(startProm);
}
prepareRoot(el, C, props) {
if (!this.owlApp) {
const { App } = odoo.loader.modules.get("@runbot/owl");
const appConfig = {
name: "Odoo Website",
getTemplate,
env: this.env,
dev: this.env.debug,
warnIfNoStaticProps: this.env.debug,
};
this.owlApp = new App(null, appConfig);
}
const root = this.owlApp.createRoot(C, { props, env: this.env });
const compElem = document.createElement("owl-component");
compElem.setAttribute("contenteditable", "false");
compElem.dataset.oeProtected = "true";
el.appendChild(compElem);
return {
C,
root,
el: compElem,
mount: () => root.mount(compElem),
destroy: () => {
root.destroy();
compElem.remove();
},
};
}
async _mountComponent(el, C) {
const root = this.prepareRoot(el, C);
this.roots.push(root);
return root.mount();
}
startInteractions(el = this.el) {
if (!el.isConnected) {
return Promise.resolve();
}
const proms = [];
for (const I of this.Interactions) {
if (I.selector === "") {
throw new Error(
`The selector should be defined as a static property on the class ${I.name}, not on the instance`
);
}
if (I.dynamicContent) {
throw new Error(
`The dynamic content object should be defined on the instance, not on the class (${I.name})`
);
}
let targets;
try {
const isMatch = el.matches(I.selector);
targets = isMatch
? [el, ...el.querySelectorAll(I.selector)]
: el.querySelectorAll(I.selector);
if (I.selectorHas) {
targets = [...targets].filter((el) => !!el.querySelector(I.selectorHas));
}
} catch {
const selectorHasError = I.selectorHas ? ` or selectorHas: '${I.selectorHas}'` : "";
const error = new Error(
`Could not start interaction ${I.name} (invalid selector: '${I.selector}'${selectorHasError})`
);
proms.push(Promise.reject(error));
continue;
}
for (const _el of targets) {
this._startInteraction(_el, I, proms);
}
}
if (el === this.el) {
this.isActive = true;
}
const prom = Promise.all(proms);
this.proms.push(prom);
return prom;
}
_startInteraction(el, I, proms) {
if (this.activeInteractions.has(el, I)) {
return;
}
this.activeInteractions.add(el, I);
if (I.prototype instanceof Interaction) {
try {
const interaction = new Colibri(this, I, el);
this.interactions.push(interaction);
proms.push(interaction.start());
} catch (e) {
this.proms.push(Promise.reject(e));
}
} else {
proms.push(this._mountComponent(el, I));
}
}
stopInteractions(el = this.el) {
const interactions = [];
for (const interaction of this.interactions.slice().reverse()) {
if (el === interaction.el || el.contains(interaction.el)) {
interaction.destroy();
this.activeInteractions.delete(interaction.el, interaction.interaction.constructor);
} else {
interactions.push(interaction);
}
}
this.interactions = interactions;
const roots = [];
for (const root of this.roots.slice().reverse()) {
if (el === root.el || el.contains(root.el)) {
root.destroy();
this.activeInteractions.delete(root.el, root.C);
} else {
roots.push(root);
}
}
this.roots = roots;
if (el === this.el) {
this.isActive = false;
}
}
/**
* @returns { Promise } returns a promise that is resolved when all current
* interactions are started. Note that it does not take into account possible
* future interactions.
*/
get isReady() {
const proms = this.proms.slice();
return Promise.all(proms);
}
}
// registry.category("services").add("public.interactions", {
// dependencies: ["localization"],
// async start(env) {
// const el = document.querySelector("#wrapwrap");
// if (!el) {
// // if this is an issue, maybe we should make the wrapwrap configurable
// return null;
// }
// const Interactions = registry.category("public.interactions").getAll();
// const service = new InteractionService(el, env);
// service.activate(Interactions);
// return service;
// },
// });

View File

@ -0,0 +1,35 @@
/** @odoo-module alias=@web/core/utils/render default=false **/
import { App, blockDom, Component } from "@runbot/owl";
import { getTemplate } from "@web/core/templates";
export function renderToFragment(template, context = {}) {
const frag = document.createDocumentFragment();
for (const el of [...render(template, context).children]) {
frag.appendChild(el);
}
return frag;
}
let app;
Object.defineProperty(renderToFragment, "app", {
get: () => {
if (!app) {
app = new App(Component, {
name: "renderToFragment",
getTemplate,
});
}
return app;
},
});
function render(template, context = {}) {
const app = renderToFragment.app;
const templateFn = app.getTemplate(template);
const bdom = templateFn(context, {});
const div = document.createElement("div");
blockDom.mount(bdom, div);
return div;
}

View File

@ -0,0 +1,212 @@
/** @odoo-module alias=@web/core/utils/timing default=false **/
import { onWillUnmount, useComponent } from "@runbot/owl";
/**
* Creates a batched version of a callback so that all calls to it in the same
* time frame will only call the original callback once.
* @param callback the callback to batch
* @param synchronize this function decides the granularity of the batch (a microtick by default)
* @returns a batched version of the original callback
*/
export function batched(callback, synchronize = () => Promise.resolve()) {
let scheduled = false;
return async (...args) => {
if (!scheduled) {
scheduled = true;
await synchronize();
scheduled = false;
callback(...args);
}
};
}
/**
* Creates and returns a new debounced version of the passed function (func)
* which will postpone its execution until after 'delay' milliseconds
* have elapsed since the last time it was invoked. The debounced function
* will return a Promise that will be resolved when the function (func)
* has been fully executed.
*
* If both `options.trailing` and `options.leading` are true, the function
* will only be invoked at the trailing edge if the debounced function was
* called at least once more during the wait time.
*
* @template {Function} T the return type of the original function
* @param {T} func the function to debounce
* @param {number | "animationFrame"} delay how long should elapse before the function
* is called. If 'animationFrame' is given instead of a number, 'requestAnimationFrame'
* will be used instead of 'setTimeout'.
* @param {boolean} [options] if true, equivalent to exclusive leading. If false, equivalent to exclusive trailing.
* @param {object} [options]
* @param {boolean} [options.leading=false] whether the function should be invoked at the leading edge of the timeout
* @param {boolean} [options.trailing=true] whether the function should be invoked at the trailing edge of the timeout
* @returns {T & { cancel: () => void }} the debounced function
*/
export function debounce(func, delay, options) {
let handle;
const funcName = func.name ? func.name + " (debounce)" : "debounce";
const useAnimationFrame = delay === "animationFrame";
const setFnName = useAnimationFrame ? "requestAnimationFrame" : "setTimeout";
const clearFnName = useAnimationFrame ? "cancelAnimationFrame" : "clearTimeout";
let lastArgs;
let leading = false;
let trailing = true;
if (typeof options === "boolean") {
leading = options;
trailing = !options;
} else if (options) {
leading = options.leading ?? leading;
trailing = options.trailing ?? trailing;
}
return Object.assign(
{
/** @type {any} */
[funcName](...args) {
return new Promise((resolve) => {
if (leading && !handle) {
Promise.resolve(func.apply(this, args)).then(resolve);
} else {
lastArgs = args;
}
window[clearFnName](handle);
handle = window[setFnName](() => {
handle = null;
if (trailing && lastArgs) {
Promise.resolve(func.apply(this, lastArgs)).then(resolve);
lastArgs = null;
}
}, delay);
});
},
}[funcName],
{
cancel(execNow = false) {
window[clearFnName](handle);
if (execNow && lastArgs) {
func.apply(this, lastArgs);
}
},
}
);
}
/**
* Function that calls recursively a request to an animation frame.
* Useful to call a function repetitively, until asked to stop, that needs constant rerendering.
* The provided callback gets as argument the time the last frame took.
* @param {(deltaTime: number) => void} callback
* @returns {() => void} stop function
*/
export function setRecurringAnimationFrame(callback) {
const handler = (timestamp) => {
callback(timestamp - lastTimestamp);
lastTimestamp = timestamp;
handle = window.requestAnimationFrame(handler);
};
const stop = () => {
window.cancelAnimationFrame(handle);
};
let lastTimestamp = window.performance.now();
let handle = window.requestAnimationFrame(handler);
return stop;
}
/**
* Creates a version of the function where only the last call between two
* animation frames is executed before the browser's next repaint. This
* effectively throttles the function to the display's refresh rate.
* Note that the throttled function can be any callback. It is not
* specifically an event handler, no assumption is made about its
* signature.
* NB: The first call is always called immediately (leading edge).
*
* @template {Function} T
* @param {T} func the function to throttle
* @returns {T & { cancel: () => void }} the throttled function
*/
export function throttleForAnimation(func) {
let handle = null;
const calls = new Set();
const funcName = func.name ? `${func.name} (throttleForAnimation)` : "throttleForAnimation";
const pending = () => {
if (calls.size) {
handle = window.requestAnimationFrame(pending);
const { args, resolve } = [...calls].pop();
calls.clear();
Promise.resolve(func.apply(this, args)).then(resolve);
} else {
handle = null;
}
};
return Object.assign(
{
/** @type {any} */
[funcName](...args) {
return new Promise((resolve) => {
const isNew = handle === null;
if (isNew) {
handle = window.requestAnimationFrame(pending);
Promise.resolve(func.apply(this, args)).then(resolve);
} else {
calls.add({ args, resolve });
}
});
},
}[funcName],
{
cancel() {
window.cancelAnimationFrame(handle);
calls.clear();
handle = null;
},
}
);
}
// ----------------------------------- HOOKS -----------------------------------
/**
* Hook that returns a debounced version of the given function, and cancels
* the potential pending execution on willUnmount.
* @see debounce
* @template {Function} T
* @param {T} callback
* @param {number | "animationFrame"} delay
* @param {Object} [options]
* @param {string} [options.execBeforeUnmount=false] executes the callback if the debounced function
* has been called and not resolved before destroying the component.
* @param {boolean} [options.immediate=false] whether the function should be called on
* the leading edge instead of the trailing edge.
* @returns {T & { cancel: () => void }}
*/
export function useDebounced(
callback,
delay,
{ execBeforeUnmount = false, immediate = false } = {}
) {
const component = useComponent();
const debounced = debounce(callback.bind(component), delay, immediate);
onWillUnmount(() => debounced.cancel(execBeforeUnmount));
return debounced;
}
/**
* Hook that returns a throttled for animation version of the given function,
* and cancels the potential pending execution on willUnmount.
* @see throttleForAnimation
* @template {Function} T
* @param {T} func the function to throttle
* @returns {T & { cancel: () => void }} the throttled function
*/
export function useThrottleForAnimation(func) {
const component = useComponent();
const throttledForAnimation = throttleForAnimation(func.bind(component));
onWillUnmount(() => throttledForAnimation.cancel());
return throttledForAnimation;
}

View File

@ -0,0 +1,180 @@
/** @odoo-module alias=@web/public/utils default=false **/
export class PairSet {
constructor() {
this.map = new Map(); // map of [1] => Set<[2]>
}
add(elem1, elem2) {
if (!this.map.has(elem1)) {
this.map.set(elem1, new Set());
}
this.map.get(elem1).add(elem2);
}
has(elem1, elem2) {
if (!this.map.has(elem1)) {
return false;
}
return this.map.get(elem1).has(elem2);
}
delete(elem1, elem2) {
if (!this.map.has(elem1)) {
return;
}
const s = this.map.get(elem1);
s.delete(elem2);
if (!s.size) {
this.map.delete(elem1);
}
}
}
import { addLoadingEffect } from "@web/core/utils/ui";
export const DEBOUNCE = 400;
export const BUTTON_HANDLER_SELECTOR =
'a, button, input[type="submit"], input[type="button"], .btn';
/**
* Protects a function which is to be used as a handler by preventing its
* execution for the duration of a previous call to it (including async
* parts of that call).
*
* @param {function} fct
* The function which is to be used as a handler. If a promise
* is returned, it is used to determine when the handler's action is
* finished. Otherwise, the return is used as jQuery uses it.
*/
export function makeAsyncHandler(fct) {
let pending = false;
function _isLocked() {
return pending;
}
function _lock() {
pending = true;
}
function _unlock() {
pending = false;
}
return function () {
if (_isLocked()) {
// If a previous call to this handler is still pending, ignore
// the new call.
return;
}
_lock();
const result = fct.apply(this, arguments);
Promise.resolve(result).finally(_unlock);
return result;
};
}
/**
* Creates a debounced version of a function to be used as a button click
* handler. Also improves the handler to disable the button for the time of
* the debounce and/or the time of the async actions it performs.
*
* Limitation: if two handlers are put on the same button, the button will
* become enabled again once any handler's action finishes (multiple click
* handlers should however not be bound to the same button).
*
* @param {function} fct
* The function which is to be used as a button click handler. If a
* promise is returned, it is used to determine when the button can be
* re-enabled. Otherwise, the return is used as jQuery uses it.
*/
export function makeButtonHandler(fct) {
// Fallback: if the final handler is not bound to a button, at least
// make it an async handler (also handles the case where some events
// might ignore the disabled state of the button).
fct = makeAsyncHandler(fct);
return function (ev) {
const result = fct.apply(this, arguments);
const buttonEl = ev.target.closest(BUTTON_HANDLER_SELECTOR);
if (!(buttonEl instanceof HTMLElement)) {
return result;
}
// Disable the button for the duration of the handler's action
// or at least for the duration of the click debounce. This makes
// a 'real' debounce creation useless. Also, during the debouncing
// part, the button is disabled without any visual effect.
buttonEl.classList.add("pe-none");
new Promise((resolve) => setTimeout(resolve, DEBOUNCE)).then(() => {
buttonEl.classList.remove("pe-none");
const restore = addLoadingEffect(buttonEl);
return Promise.resolve(result).then(restore, restore);
});
return result;
};
}
/**
* Patches a "t-" entry of a dynamic content.
*
* @param {Object} dynamicContent
* @param {string} selector
* @param {string} t
* @param {any|function} replacement, if a function, takes the element and the
* replaced's function output as parameters
*/
export function patchDynamicContentEntry(dynamicContent, selector, t, replacement) {
dynamicContent[selector] = dynamicContent[selector] || {};
const forSelector = dynamicContent[selector];
if (replacement === undefined) {
delete forSelector[t];
} else if (typeof replacement === "function" && t !== "t-component") {
if (!forSelector[t]) {
forSelector[t] = () => {};
}
const oldFn = forSelector[t];
if (["t-att-class", "t-att-style"].includes(t)) {
forSelector[t] = (el, oldResult) => {
const result = oldResult || {};
Object.assign(result, oldFn(el, result));
Object.assign(result, replacement(el, result));
return result;
};
} else if (t.startsWith("t-on-")) {
forSelector[t] = (el, ...args) => replacement(el, oldFn, ...args);
} else {
forSelector[t] = (el, oldResult) => {
let result = oldResult;
result = oldFn(el, result);
result = replacement(el, result);
return result;
};
}
} else {
forSelector[t] = replacement;
}
}
/**
* Patches several entries in a dynamicContent.
* Example usage:
* patchDynamicContent(this.dynamicContent, {
* _root: {
* "t-att-class": (el, old) => ({
* "test": this.condition && old.test,
* }),
* "t-on-click": (el, oldFn) => {
* oldFn(el);
* this.doMoreStuff();
* },
* },
* })
*
* @param {Object} dynamicContent
* @param {Object} replacement
*/
export function patchDynamicContent(dynamicContent, replacement) {
for (const [selector, forSelector] of Object.entries(replacement)) {
for (const [t, forT] of Object.entries(forSelector)) {
patchDynamicContentEntry(dynamicContent, selector, t, forT);
}
}
}