mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 23:45:44 +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 { 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);
|
||||
})();
|
||||
|
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