mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[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:
parent
0db0bfcf5b
commit
6ba9aaf411
@ -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 { getTemplate } from '@web/core/templates';
|
||||||
import { registry } from '@web/core/registry';
|
import { registry } from '@web/core/registry';
|
||||||
import { useRegistry } from '@web/core/registry_hook';
|
import { useRegistry } from '@web/core/registry_hook';
|
||||||
|
import { InteractionService } from '@web/public/interaction_service';
|
||||||
|
|
||||||
|
|
||||||
const mainComponents = registry.category('main.components');
|
const mainComponents = registry.category('main.components');
|
||||||
@ -56,9 +57,20 @@ class ComponentContainer extends Component {
|
|||||||
(async function startApp() {
|
(async function startApp() {
|
||||||
await whenReady();
|
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, {
|
const app = new App(ComponentContainer, {
|
||||||
getTemplate,
|
getTemplate,
|
||||||
env: {},
|
env,
|
||||||
});
|
});
|
||||||
await app.mount(document.body);
|
await app.mount(document.body);
|
||||||
|
const Interactions = registry.category('public.interactions').getAll();
|
||||||
|
const service = new InteractionService(document.body, env);
|
||||||
|
service.activate(Interactions);
|
||||||
})();
|
})();
|
||||||
|
360
runbot/static/src/vendored/colibri.js
Normal file
360
runbot/static/src/vendored/colibri.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
409
runbot/static/src/vendored/interaction.js
Normal file
409
runbot/static/src/vendored/interaction.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
206
runbot/static/src/vendored/interaction_service.js
Normal file
206
runbot/static/src/vendored/interaction_service.js
Normal 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;
|
||||||
|
// },
|
||||||
|
// });
|
35
runbot/static/src/vendored/render.js
Normal file
35
runbot/static/src/vendored/render.js
Normal 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;
|
||||||
|
}
|
212
runbot/static/src/vendored/timing.js
Normal file
212
runbot/static/src/vendored/timing.js
Normal 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;
|
||||||
|
}
|
180
runbot/static/src/vendored/utils.js
Normal file
180
runbot/static/src/vendored/utils.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user