diff --git a/runbot/static/src/frontend/root.js b/runbot/static/src/frontend/root.js index f4c24be1..04c14406 100644 --- a/runbot/static/src/frontend/root.js +++ b/runbot/static/src/frontend/root.js @@ -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); })(); diff --git a/runbot/static/src/vendored/colibri.js b/runbot/static/src/vendored/colibri.js new file mode 100644 index 00000000..3ee10b9b --- /dev/null +++ b/runbot/static/src/vendored/colibri.js @@ -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 = /^(?.*)\.(?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); + } +} diff --git a/runbot/static/src/vendored/interaction.js b/runbot/static/src/vendored/interaction.js new file mode 100644 index 00000000..35bd5174 --- /dev/null +++ b/runbot/static/src/vendored/interaction.js @@ -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.} + */ + 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); + } +} diff --git a/runbot/static/src/vendored/interaction_service.js b/runbot/static/src/vendored/interaction_service.js new file mode 100644 index 00000000..1852e8cd --- /dev/null +++ b/runbot/static/src/vendored/interaction_service.js @@ -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; +// }, +// }); diff --git a/runbot/static/src/vendored/render.js b/runbot/static/src/vendored/render.js new file mode 100644 index 00000000..3d1750bc --- /dev/null +++ b/runbot/static/src/vendored/render.js @@ -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; +} diff --git a/runbot/static/src/vendored/timing.js b/runbot/static/src/vendored/timing.js new file mode 100644 index 00000000..fda95438 --- /dev/null +++ b/runbot/static/src/vendored/timing.js @@ -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; +} diff --git a/runbot/static/src/vendored/utils.js b/runbot/static/src/vendored/utils.js new file mode 100644 index 00000000..62362f53 --- /dev/null +++ b/runbot/static/src/vendored/utils.js @@ -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); + } + } +}