From b98c50528059812a0d39e53eaf9234b693495053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Tue, 17 Dec 2024 11:20:53 +0100 Subject: [PATCH 001/150] [POC] introduce Interaction framework --- addons/web/__manifest__.py | 11 +- .../web/static/lib/hoot-dom/helpers/events.js | 5 + addons/web/static/src/@types/misc.d.ts | 11 + .../src/legacy/js/public/public_root.js | 22 +- addons/web/static/src/public/colibri.js | 323 +++ addons/web/static/src/public/interaction.js | 310 +++ .../static/src/public/interaction_service.js | 194 ++ .../src/{legacy/js => }/public/signin.js | 29 +- addons/web/static/src/public/utils.js | 111 + addons/web/static/tests/public/helpers.js | 98 + .../static/tests/public/interaction.test.js | 2035 +++++++++++++++++ .../tests/public/interaction_service.test.js | 232 ++ addons/web/static/tests/public/signin.test.js | 18 + addons/web/static/tests/public/utils.test.js | 51 + addons/web/tests/test_js.py | 4 +- 15 files changed, 3432 insertions(+), 22 deletions(-) create mode 100644 addons/web/static/src/@types/misc.d.ts create mode 100644 addons/web/static/src/public/colibri.js create mode 100644 addons/web/static/src/public/interaction.js create mode 100644 addons/web/static/src/public/interaction_service.js rename addons/web/static/src/{legacy/js => }/public/signin.js (61%) create mode 100644 addons/web/static/src/public/utils.js create mode 100644 addons/web/static/tests/public/helpers.js create mode 100644 addons/web/static/tests/public/interaction.test.js create mode 100644 addons/web/static/tests/public/interaction_service.test.js create mode 100644 addons/web/static/tests/public/signin.test.js create mode 100644 addons/web/static/tests/public/utils.test.js diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index c6d3654e94245..16061a204a3f2 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -228,15 +228,12 @@ ('remove', 'web/static/src/core/emoji_picker/emoji_data.js'), 'web/static/src/core/commands/default_providers.js', 'web/static/src/core/commands/command_palette.js', - 'web/static/src/public/error_notifications.js', - 'web/static/src/public/public_component_service.js', - 'web/static/src/public/datetime_picker_widget.js', + 'web/static/src/public/**/*.js', + ('remove', 'web/static/src/public/database_manager.js'), 'web/static/src/legacy/js/public/public_root.js', 'web/static/src/legacy/js/public/public_root_instance.js', 'web/static/src/legacy/js/public/public_widget.js', - 'web/static/src/legacy/js/public/signin.js', - ], 'web.assets_frontend_lazy': [ ('include', 'web.assets_frontend'), @@ -449,6 +446,10 @@ ('include', 'web.assets_backend'), ('include', 'web.assets_backend_lazy'), + 'web/static/src/public/**/*.js', + ('remove', 'web/static/src/public/database_manager.js'), + ('remove', 'web/static/src/public/datetime_picker_widget.js'), # remove this remove when it has been converted + ('remove', 'web/static/src/public/error_notifications.js'), 'web/static/src/public/public_component_service.js', 'web/static/src/webclient/clickbot/clickbot.js', ], diff --git a/addons/web/static/lib/hoot-dom/helpers/events.js b/addons/web/static/lib/hoot-dom/helpers/events.js index e6a623da59670..4267f7bab6571 100644 --- a/addons/web/static/lib/hoot-dom/helpers/events.js +++ b/addons/web/static/lib/hoot-dom/helpers/events.js @@ -113,6 +113,7 @@ const { ErrorEvent, Event, FocusEvent, + HashChangeEvent, KeyboardEvent, Math: { ceil: $ceil, max: $max, min: $min }, MouseEvent, @@ -399,6 +400,10 @@ const getEventConstructor = (eventType) => { case "unload": return [Event, mapEvent]; + // URL events + case "hashchange": + return [HashChangeEvent, mapEvent]; + // Default: base Event constructor default: return [Event, mapEvent, BUBBLES]; diff --git a/addons/web/static/src/@types/misc.d.ts b/addons/web/static/src/@types/misc.d.ts new file mode 100644 index 0000000000000..44ac7de6f0acf --- /dev/null +++ b/addons/web/static/src/@types/misc.d.ts @@ -0,0 +1,11 @@ +// this is technically wrong, but in practice, it is correct. + +interface Element { + querySelector( + selectors: string + ): E | null; + + querySelectorAll( + selectors: stringj + ): NodeListOf; +} diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index 4296e9db04d3f..6a524643f3fff 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -56,7 +56,7 @@ export const PublicRoot = publicWidget.Widget.extend({ start: function () { var defs = [ this._super.apply(this, arguments), - this._startWidgets() + this._startWidgets(undefined, { starting: true }) ]; // Display image thumbnail @@ -112,6 +112,18 @@ export const PublicRoot = publicWidget.Widget.extend({ _getPublicWidgetsRegistry: function (options) { return publicWidget.registry; }, + /** + * Restarts interactions from the specified targetEl, or from #wrapwrap. + * + * @private + * @param {HTMLElement} targetEl + * @param {Object} [options] + */ + _restartInteractions(targetEl, options) { + const publicInteractions = this.bindService("public.interactions"); + publicInteractions.stopInteractions(targetEl); + publicInteractions.startInteractions(targetEl); + }, /** * Creates an PublicWidget instance for each DOM element which matches the * `selector` key of one of the registered widgets @@ -141,6 +153,10 @@ export const PublicRoot = publicWidget.Widget.extend({ }); this._stopWidgets($from); + if (!options?.starting) { + const targetEl = $from ? $from[0] : undefined; + this._restartInteractions(targetEl, options); + } var defs = Object.values(this._getPublicWidgetsRegistry(options)).map((PublicWidget) => { const selector = PublicWidget.prototype.selector; @@ -270,6 +286,10 @@ export const PublicRoot = publicWidget.Widget.extend({ */ _onWidgetsStopRequest: function (ev) { this._stopWidgets(ev.data.$target); + // also stops interactions + const targetEl = ev.data.$target ? ev.data.$target[0] : undefined; + const publicInteractions = this.bindService("public.interactions"); + publicInteractions.stopInteractions(targetEl); }, /** * @todo review diff --git a/addons/web/static/src/public/colibri.js b/addons/web/static/src/public/colibri.js new file mode 100644 index 0000000000000..fd99f7030a1bb --- /dev/null +++ b/addons/web/static/src/public/colibri.js @@ -0,0 +1,323 @@ +/** + * 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|noupdate)$/; + let groups = re.exec(event)?.groups; + while (groups) { + fn = { + prevent: (f) => (ev) => { + ev.preventDefault(); + return f(ev); + }, + stop: (f) => (ev) => { + ev.stopPropagation(); + return f(ev); + }, + capture: (f) => { + options ||= {}; + options.capture = true; + return f; + }, + noupdate: (f) => (ev) => { + f(ev); + return SKIP_IMPLICIT_UPDATE; + }, + }[groups.suffix](fn); + event = groups.event; + groups = re.exec(event)?.groups; + } + const handler = fn.isHandler + ? fn + : (ev) => { + if (SKIP_IMPLICIT_UPDATE !== fn.call(this.interaction, ev)) { + 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("@odoo/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 (value) { + el.setAttribute(attr, value); + } else { + el.removeAttribute(attr); + } + } + } + + 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) { + 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("@odoo/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; + } +} diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js new file mode 100644 index 0000000000000..d690564dee9b9 --- /dev/null +++ b/addons/web/static/src/public/interaction.js @@ -0,0 +1,310 @@ +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 = ""; + + /** + * 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}), + * "_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 includes: t-on-, t-att-, t-out and t-component + * + * 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) => { + promise.then((result) => { + if (!this.isDestroyed) { + resolve(result); + prom.then(() => { + if (this.isReady) { + this.updateContent(); + } + }); + } + }); + }); + return prom; + } + + /** + * 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) { + 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) { + 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) { + const debouncedFn = debounce((...args) => { + fn.apply(this, args); + if (this.isReady) { + this.updateContent(); + } + }, delay); + this.registerCleanup(() => { + debouncedFn.cancel(); + }); + return Object.assign( + { + [debouncedFn.name]: (...args) => { + debouncedFn(...args); + return SKIP_IMPLICIT_UPDATE; + }, + }[debouncedFn.name], + { + cancel: debouncedFn.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. + */ + blockedUntilDone(fn, useLoadingAnimation = false) { + if (useLoadingAnimation) { + return makeButtonHandler(fn); + } else { + return makeAsyncHandler(fn); + } + } + + /** + * Throttles a function for animation and makes sure it is cancelled upon destroy. + */ + throttledForAnimation(fn) { + const throttledFn = throttleForAnimation(() => { + fn.call(this); + if (this.isReady) { + this.updateContent(); + } + }); + this.registerCleanup(() => { + throttledFn.cancel(); + }); + return Object.assign( + { + [throttledFn.name]: () => { + throttledFn(); + return SKIP_IMPLICIT_UPDATE; + }, + }[throttledFn.name], + { + cancel: throttledFn.cancel, + } + ); + } + + /** + * 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)); + } + + refreshListeners() { + this.__colibri__.refreshListeners(); + } + + /** + * Insert an node at a specific location. The inserted node will be removed + * when the interaction is destroyed. + * + * @param { HTMLElement } el + * @param { HTMLElement } [locationEl] the target + * @param { "afterbegin" | "afterend" | "beforebegin" | "beforeend" } [position] + */ + insert(el, locationEl = this.el, position = "beforeend") { + locationEl.insertAdjacentElement(position, el); + this.registerCleanup(() => el.remove()); + } + + /** + * 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/addons/web/static/src/public/interaction_service.js b/addons/web/static/src/public/interaction_service.js new file mode 100644 index 0000000000000..2603cb2fa6843 --- /dev/null +++ b/addons/web/static/src/public/interaction_service.js @@ -0,0 +1,194 @@ +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +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. + * + */ + +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("@odoo/owl"); + const appConfig = { + name: "Odoo Website", + getTemplate, + env: this.env, + dev: this.env.debug, + translateFn: _t, + warnIfNoStaticProps: this.env.debug, + translatableAttributes: ["data-tooltip"], + }; + 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) { + 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})` + ); + } + if (el.matches(I.selector)) { + this._startInteraction(el, I, proms); + } else { + for (const _el of el.querySelectorAll(I.selector)) { + 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 { + // console.log(`[colibri] starting ${I.name}`); + 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)) { + // console.log(`[colibri] stopping ${interaction.interaction.constructor.name}`); + 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/addons/web/static/src/legacy/js/public/signin.js b/addons/web/static/src/public/signin.js similarity index 61% rename from addons/web/static/src/legacy/js/public/signin.js rename to addons/web/static/src/public/signin.js index ee3139302fd7b..9f6314df50ba3 100644 --- a/addons/web/static/src/legacy/js/public/signin.js +++ b/addons/web/static/src/public/signin.js @@ -1,15 +1,14 @@ -import publicWidget from '@web/legacy/js/public/public_widget'; -import { addLoadingEffect } from '@web/core/utils/ui'; +import { addLoadingEffect } from "@web/core/utils/ui"; +import { Interaction } from "./interaction"; +import { registry } from "@web/core/registry"; -publicWidget.registry.login = publicWidget.Widget.extend({ - selector: '.oe_login_form', - events: { - 'submit': '_onSubmit', - }, - - //------------------------------------------------------------------------- - // Handlers - //------------------------------------------------------------------------- +class Signin extends Interaction { + static selector = ".oe_login_form"; + dynamicContent = { + _root: { + "t-on-submit": this.onSubmit, + }, + }; /** * Prevents the user from crazy clicking: @@ -21,7 +20,7 @@ publicWidget.registry.login = publicWidget.Widget.extend({ * @private * @param {Event} ev */ - _onSubmit(ev) { + onSubmit(ev) { if (!ev.defaultPrevented) { const btnEl = ev.currentTarget.querySelector('button[type="submit"]'); const removeLoadingEffect = addLoadingEffect(btnEl); @@ -31,5 +30,7 @@ publicWidget.registry.login = publicWidget.Widget.extend({ oldPreventDefault(); }; } - }, -}); + } +} + +registry.category("public.interactions").add("public.signin", Signin); diff --git a/addons/web/static/src/public/utils.js b/addons/web/static/src/public/utils.js new file mode 100644 index 0000000000000..bbf020833080d --- /dev/null +++ b/addons/web/static/src/public/utils.js @@ -0,0 +1,111 @@ +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; + }; +} diff --git a/addons/web/static/tests/public/helpers.js b/addons/web/static/tests/public/helpers.js new file mode 100644 index 0000000000000..40620fd540032 --- /dev/null +++ b/addons/web/static/tests/public/helpers.js @@ -0,0 +1,98 @@ +import { getFixture, after } from "@odoo/hoot"; +import { clearRegistry, makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; + +let activeInteractions = null; +const elementRegistry = registry.category("public.interactions"); +const content = elementRegistry.content; + +export function setupInteractionWhiteList(interactions) { + if (typeof interactions === "string") { + interactions = [interactions]; + } + activeInteractions = interactions; +} + +setupInteractionWhiteList.getWhiteList = () => activeInteractions; + +export async function startInteraction(I, html, options) { + clearRegistry(elementRegistry); + for (const Interaction of Array.isArray(I) ? I : [I]) { + elementRegistry.add(Interaction.name, Interaction); + } + return startInteractions(html, options); +} + +export async function startInteractions( + html, + options = { waitForStart: true, editMode: false, translateMode: false } +) { + if (odoo.loader.modules.has("@mail/../tests/mail_test_helpers")) { + const { defineMailModels } = odoo.loader.modules.get("@mail/../tests/mail_test_helpers"); + defineMailModels(); + } + const fixture = getFixture(); + if (!html.includes("wrapwrap")) { + html = `
${html}
`; + } + fixture.innerHTML = html; + if (options.translateMode) { + fixture.closest("html").dataset.edit_translations = "1"; + } + if (activeInteractions) { + clearRegistry(elementRegistry); + if (!options.editMode) { + for (const name of activeInteractions) { + if (name in content) { + elementRegistry.add(name, content[name][1]); + } else { + throw new Error(`White-listed Interaction does not exist: ${name}.`); + } + } + } + } + const env = await makeMockEnv(); + const core = env.services["public.interactions"]; + if (options.waitForStart) { + await core.isReady; + } + after(() => { + delete fixture.closest("html").dataset.edit_translations; + core.stopInteractions(); + }); + + return { + el: fixture, + core, + }; +} + +export function mockSendRequests() { + const requests = []; + patchWithCleanup(HTMLFormElement.prototype, { + submit: function () { + requests.push({ + url: this.getAttribute("action"), + method: this.getAttribute("method"), + }); + }, + }); + return requests; +} + +export function isElementInViewport(el) { + const rect = el.getBoundingClientRect(); + const width = window.innerWidth || document.documentElement.clientWidth; + const height = window.innerHeight || document.documentElement.clientHeight; + return ( + Math.round(rect.top) >= 0 && + Math.round(rect.left) >= 0 && + Math.round(rect.right) <= width && + Math.round(rect.bottom) <= height + ); +} + +export function isElementVerticallyInViewportOf(el, scrollEl) { + const rect = el.getBoundingClientRect(); + return rect.top <= scrollEl.clientHeight && rect.bottom >= 0; +} diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js new file mode 100644 index 0000000000000..d2245706909d0 --- /dev/null +++ b/addons/web/static/tests/public/interaction.test.js @@ -0,0 +1,2035 @@ +import { beforeEach, describe, expect, test } from "@odoo/hoot"; + +import { animationFrame, click, dblclick, queryAll } from "@odoo/hoot-dom"; +import { advanceTime, Deferred } from "@odoo/hoot-mock"; +import { patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { Colibri } from "@web/public/colibri"; +import { Interaction } from "@web/public/interaction"; +import { startInteraction } from "./helpers"; +import { Component, onWillDestroy, xml } from "@odoo/owl"; + +describe.current.tags("interaction_dev"); + +const TemplateBase = ` +
+ coucou +
`; + +const TemplateTest = ` +
+ coucou +
`; + +const TemplateTestDoubleSpan = ` +
+ span1 + span2 +
`; + +const TemplateTestDoubleButton = ` +
+ + +
`; + +const getTemplateWithAttribute = function (attribute) { + return ` +
+ coucou +
`; +}; + +describe("adding listeners", () => { + test("can add a listener on a single element", async () => { + let clicked = 0; + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { "t-on-click": () => clicked++ }, + }; + } + await startInteraction(Test, TemplateTest); + expect(clicked).toBe(0); + await click("span"); + expect(clicked).toBe(1); + }); + + test("can add a listener on multiple elements", async () => { + let clicked = 0; + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { "t-on-click": () => clicked++ }, + }; + } + const { el } = await startInteraction(Test, TemplateTestDoubleSpan); + expect(clicked).toBe(0); + const spans = el.querySelectorAll("span"); + await click(spans[0]); + await click(spans[1]); + expect(clicked).toBe(2); + }); + + test.tags("desktop")("can add multiple listeners on an element", async () => { + let clicked = 0; + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { + "t-on-click": () => clicked++, + "t-on-dblclick": () => clicked++, + }, + }; + } + await startInteraction(Test, TemplateTest); + expect(clicked).toBe(0); + await dblclick("span"); + expect(clicked).toBe(3); // event dblclick = click + click + dblclick + }); + + test("can use addListener on HTMLCollection", async () => { + let clicked = 0; + class Test extends Interaction { + static selector = ".test"; + start() { + this.addListener(this.el.querySelectorAll("span"), "click", () => clicked++); + } + } + await startInteraction(Test, TemplateTestDoubleSpan); + expect(clicked).toBe(0); + const spans = queryAll("span"); + await click(spans[0]); + await click(spans[1]); + expect(clicked).toBe(2); + }); + + test("listener is added between willstart and start", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { "t-on-click": () => expect.step("click") }, + }; + setup() { + expect.step("setup"); + } + async willStart() { + await click("span"); + expect.step("willStart"); + } + start() { + expect.step("start"); + } + } + await startInteraction(Test, TemplateTest); + await click("span"); + expect.verifySteps(["setup", "willStart", "start", "click"]); + }); + + test("listener is added on iframe single element", async () => { + class Test extends Interaction { + static selector = "iframe"; + start() { + const spanEl = this.el.contentDocument.createElement("span"); + spanEl.textContent = "abc"; + this.el.contentDocument.body.appendChild(spanEl); + this.addListener(spanEl, "click", () => expect.step("click")); + spanEl.click(); + } + } + await startInteraction(Test, ` + + `); + expect(core.interactions).toHaveLength(2); + const iframeEl = queryOne("iframe"); + expect(iframeEl).toHaveClass("d-none"); + expect(iframeEl.nextElementSibling).not.toBe(null); + const warningEl = queryOne(".o_no_optional_cookie"); + expect(iframeEl.nextElementSibling).toBe(warningEl); +}); + +test("show cookies bar after clicking on warning", async () => { + const { core } = await startInteractions(` +
+
+ +
+ ${cookiesBarTemplate} +
+ `); + expect(core.interactions).toHaveLength(3); + const cookiesBarEl = queryOne("#website_cookies_bar .modal"); + expect("iframe").toHaveAttribute("src", "about:blank"); + expect("iframe").toHaveAttribute("data-nocookie-src", "/"); + await waitFor(cookiesBarEl, { visible: true }); + await click("#cookies-consent-essential"); + expect(cookiesBarEl).not.toBeVisible(); + expect("iframe").not.toBeVisible(); + expect(".o_no_optional_cookie").toBeVisible(); + expect("iframe").toHaveAttribute("src", "about:blank"); + expect("iframe").toHaveAttribute("data-nocookie-src", "/"); + await click(".o_no_optional_cookie"); + expect(cookiesBarEl).toBeVisible(); +}); + +test("remove warning, show and update iframe src after accepting cookies", async () => { + const { core } = await startInteractions(` +
+
+ +
+ ${cookiesBarTemplate} +
+ `); + expect(core.interactions).toHaveLength(3); + expect("iframe").toHaveAttribute("src", "about:blank"); + expect("iframe").toHaveAttribute("data-nocookie-src", "/"); + await waitFor("#website_cookies_bar .modal", { visible: true }); + await click("#cookies-consent-all"); + expect(".o_no_optional_cookie").not.toBeVisible(); + expect("iframe").toBeVisible(); + expect("iframe").toHaveAttribute("src", "/"); + expect("iframe").not.toHaveAttribute("data-nocookie-src"); + expect("[data-need-cookies-approval]").not.toHaveAttribute("data-need-cookies-approval"); +}); diff --git a/addons/website/static/tests/interactions/snippets/countdown.test.js b/addons/website/static/tests/interactions/snippets/countdown.test.js new file mode 100644 index 0000000000000..13674ff3e0cf5 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/countdown.test.js @@ -0,0 +1,133 @@ +import { describe, expect, test } from "@odoo/hoot"; + +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { advanceTime } from "@odoo/hoot-mock"; + +setupInteractionWhiteList("website.countdown"); +describe.current.tags("interaction_dev"); + +const getTemplate = function (options = {}) { + return ` +
+
+
+
+
+
+
+
+ ` +} + +const getCommonLength = function (data1, data2, data3) { + const length1 = data1.length; + const length2 = data2.length; + const length3 = data3.length; + if (length1 == length2 && length2 == length3) { + return length1; + } else { + return 0; + } +} + +const wasDataChanged = function (data1, data2, l) { + for (let i = 0; i < l; i++) { + if (Math.abs(data1[i] - data2[i]) > 1) { + return true; + } + } + return false; +} + +test("countdown is started when there is an element .s_countdown", async () => { + const { core } = await startInteractions(getTemplate()); + expect(core.interactions.length).toBe(1); +}); + +/** + * This test use 2 timestamps because in the rare case when the + * countdown is at xx:xx:00, the next frame will update the multiple + * canvases, including the hours one. It won't happen a second time. + * We compare the canvases twice to prevent the issue. + */ +test("[time] countdown display is updated correctly when time pass", async () => { + const { el } = await startInteractions(getTemplate()); + + // time T + + const canvas1Els = el.querySelectorAll('canvas'); + const canvas1Hours = canvas1Els[1]; + const data1Hours = canvas1Hours.getContext('2d').getImageData(0, 0, canvas1Hours.width, canvas1Hours.height).data; + const canvas1Seconds = canvas1Els[3]; + const data1Seconds = canvas1Seconds.getContext('2d').getImageData(0, 0, canvas1Seconds.width, canvas1Seconds.height).data; + + await advanceTime(1000); + + // time T + 1s + + const canvas2Els = el.querySelectorAll('canvas'); + const canvas2Hours = canvas2Els[1]; + const data2Hours = canvas2Hours.getContext('2d').getImageData(0, 0, canvas2Hours.width, canvas2Hours.height).data; + const canvas2Seconds = canvas2Els[3]; + const data2Seconds = canvas2Seconds.getContext('2d').getImageData(0, 0, canvas2Seconds.width, canvas2Seconds.height).data; + + await advanceTime(1000); + + // time T + 2s + + const canvas3Els = el.querySelectorAll('canvas'); + const canvas3Hours = canvas3Els[1]; + const data3Hours = canvas3Hours.getContext('2d').getImageData(0, 0, canvas3Hours.width, canvas3Hours.height).data; + const canvas3Seconds = canvas3Els[3]; + const data3Seconds = canvas3Seconds.getContext('2d').getImageData(0, 0, canvas3Seconds.width, canvas3Seconds.height).data; + + // Check data size and get common length + + const dataHoursLength = getCommonLength(data1Hours, data2Hours, data3Hours) + expect(dataHoursLength).not.toBe(0); + + const dataSecondsLength = getCommonLength(data1Seconds, data2Seconds, data3Seconds) + expect(dataSecondsLength).not.toBe(0); + + // Compare data + + const hoursUpdate12 = wasDataChanged(data1Hours, data2Hours, dataHoursLength); + const hoursUpdate23 = wasDataChanged(data2Hours, data3Hours, dataHoursLength); + const secondsUpdate12 = wasDataChanged(data1Seconds, data2Seconds, dataHoursLength); + const secondsUpdate23 = wasDataChanged(data2Seconds, data3Seconds, dataHoursLength); + + // Hour canvas must not have changed twice + expect(hoursUpdate12 && hoursUpdate23).toBe(false); + + // Second canvas must have changed twice + expect(secondsUpdate12 && secondsUpdate23).toBe(true); +}); + +test("countdown is stopped correctly", async () => { + const { core, el } = await startInteractions(getTemplate()); + const wrapEl = el.querySelector(".s_countdown_canvas_wrapper"); + await advanceTime(0); + expect(wrapEl.querySelectorAll(".s_countdown_canvas_flex")).toHaveLength(4); + core.stopInteractions(); + expect(!!wrapEl).toBe(true); + expect(wrapEl.querySelectorAll(".s_countdown_canvas_flex")).toHaveLength(0); + expect(wrapEl.querySelectorAll(".s_countdown_end_message")).toHaveLength(0); + expect(wrapEl.querySelectorAll(".s_countdown_text_wrapper")).toHaveLength(0); + expect(wrapEl.querySelectorAll(".s_countdown_end_redirect_message")).toHaveLength(0); +}); diff --git a/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js b/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js new file mode 100644 index 0000000000000..3ad73ac3dd65f --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js @@ -0,0 +1,83 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { onRpc } from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +class TestItem extends Interaction { + static selector = ".s_test_item"; + dynamicContent = { + "_root": { + "t-att-data-started": (el) => `*${el.dataset.testParam}*`, + }, + }; +} +registry.category("public.interactions").add("website.test_dynamic_item", TestItem); + +setupInteractionWhiteList(["website.dynamic_snippet", "website.test_dynamic_item"]); +describe.current.tags("interaction_dev"); + +test("dynamic snippet loads items and displays them through template", async () => { + onRpc("/website/snippet/filters", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.filter_id).toBe(1); + expect(json.params.template_key).toBe( + "website.dynamic_filter_template_test_item", + ); + expect(json.params.limit).toBe(16); + expect(json.params.search_domain).toEqual([]); + } + return [ + ` +
+ Some test record +
+ `, + ` +
+ Another test record +
+ `, + ]; + }); + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ Your Dynamic Snippet will be displayed here... This message is displayed because you did not provide both a filter and a template to use. +
+
+
+
+
+
+
+
+
+ `); + expect(core.interactions.length).toBe(3); + const contentEl = el.querySelector(".dynamic_snippet_template"); + const itemEls = contentEl.querySelectorAll(".s_test_item"); + expect(itemEls[0].dataset.testParam).toBe("test"); + expect(itemEls[1].dataset.testParam).toBe("test2"); + // Make sure element interactions are started. + expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[1].dataset.started).toBe("*test2*"); + core.stopInteractions(); + // Make sure element interactions are stopped. + expect(core.interactions.length).toBe(0); +}); diff --git a/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js b/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js new file mode 100644 index 0000000000000..e994f9521f457 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js @@ -0,0 +1,171 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { + onRpc, +} from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +class TestItem extends Interaction { + static selector = ".s_test_item"; + dynamicContent = { + "_root": { + "t-att-data-started": (el) => `*${el.dataset.testParam}*`, + }, + }; +} +registry.category("public.interactions").add("website.test_dynamic_carousel_item", TestItem); + +setupInteractionWhiteList(["website.dynamic_snippet_carousel", "website.test_dynamic_carousel_item"]); +describe.current.tags("interaction_dev"); + +const Template = ` +
+ +
` + +test.tags("desktop")("dynamic snippet carousel loads items and displays them through template (desktop)", async () => { + onRpc("/website/snippet/filters", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.filter_id).toBe(1); + expect(json.params.template_key).toBe("website.dynamic_filter_template_test_item"); + expect(json.params.limit).toBe(16); + expect(json.params.search_domain).toEqual([]); + } + return [` +
+ Some test record +
+ `, ` +
+ Another test record +
+ `, ` +
+ Yet another test record +
+ `, ` +
+ Last test record of first page +
+ `, ` +
+ Test record in second page +
+ `]; + }); + const { core, el } = await startInteractions(Template); + expect(core.interactions.length).toBe(6); + const carouselEl = el.querySelector(".carousel"); + // Neutralize carousel automatic sliding. + carouselEl.dataset.bsRide = "false"; + const itemEls = carouselEl.querySelectorAll(".s_test_item"); + expect(itemEls[0].dataset.testParam).toBe("test"); + expect(itemEls[1].dataset.testParam).toBe("test2"); + expect(itemEls[2].dataset.testParam).toBe("test3"); + expect(itemEls[3].dataset.testParam).toBe("test4"); + expect(itemEls[4].dataset.testParam).toBe("test5"); + expect(itemEls[3].closest(".carousel-item")).toHaveClass("active"); + expect(itemEls[4].closest(".carousel-item")).not.toHaveClass("active"); + await animationFrame(); + const nextEl = el.querySelector(".carousel-control-next .oi"); + await click(nextEl); + await animationFrame(); + await advanceTime(1000); // Slide duration. + expect(itemEls[3].closest(".carousel-item")).not.toHaveClass("active"); + expect(itemEls[4].closest(".carousel-item")).toHaveClass("active"); + // Make sure element interactions are started. + expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[1].dataset.started).toBe("*test2*"); + expect(itemEls[2].dataset.started).toBe("*test3*"); + expect(itemEls[3].dataset.started).toBe("*test4*"); + expect(itemEls[4].dataset.started).toBe("*test5*"); + core.stopInteractions(); + // Make sure element interactions are stopped. + expect(core.interactions.length).toBe(0); +}); + +test.tags("mobile")("dynamic snippet carousel loads items and displays them through template (mobile)", async () => { + onRpc("/website/snippet/filters", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.filter_id).toBe(1); + expect(json.params.template_key).toBe("website.dynamic_filter_template_test_item"); + expect(json.params.limit).toBe(16); + expect(json.params.search_domain).toEqual([]); + } + return [` +
+ Some test record +
+ `, ` +
+ Another test record +
+ `, ` +
+ Yet another test record +
+ `, ` +
+ Last test record of first page +
+ `, ` +
+ Test record in second page +
+ `]; + }); + const { core, el } = await startInteractions(Template); + expect(core.interactions.length).toBe(6); + const carouselEl = el.querySelector(".carousel"); + // Neutralize carousel automatic sliding. + carouselEl.dataset.bsRide = "false"; + const itemEls = carouselEl.querySelectorAll(".s_test_item"); + expect(itemEls[0].dataset.testParam).toBe("test"); + expect(itemEls[1].dataset.testParam).toBe("test2"); + expect(itemEls[2].dataset.testParam).toBe("test3"); + expect(itemEls[3].dataset.testParam).toBe("test4"); + expect(itemEls[4].dataset.testParam).toBe("test5"); + expect(itemEls[0].closest(".carousel-item")).toHaveClass("active"); + expect(itemEls[1].closest(".carousel-item")).not.toHaveClass("active"); + await animationFrame(); + const nextEl = el.querySelector(".carousel-control-next .oi"); + await click(nextEl); + await animationFrame(); + await advanceTime(1000); // Slide duration. + expect(itemEls[0].closest(".carousel-item")).not.toHaveClass("active"); + expect(itemEls[1].closest(".carousel-item")).toHaveClass("active"); + // Make sure element interactions are started. + expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[1].dataset.started).toBe("*test2*"); + expect(itemEls[2].dataset.started).toBe("*test3*"); + expect(itemEls[3].dataset.started).toBe("*test4*"); + expect(itemEls[4].dataset.started).toBe("*test5*"); + core.stopInteractions(); + // Make sure element interactions are stopped. + expect(core.interactions.length).toBe(0); +}); diff --git a/addons/website/static/tests/interactions/snippets/embed_code.test.js b/addons/website/static/tests/interactions/snippets/embed_code.test.js new file mode 100644 index 0000000000000..81883aaa4e590 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/embed_code.test.js @@ -0,0 +1,38 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.embed_code"); +describe.current.tags("interaction_dev"); + +/* TODO Requires a way to inject a script in a fixture. +test("embed code executed only once", async () => { + const { core, el } = await startInteractions(` +
+ +
+
+ `); + expect(core.interactions.length).toBe(1); + expect.verifySteps(["div"]); +}); +*/ + +test("embed code resets on stop", async () => { + const { core, el } = await startInteractions(` +
+ +
original
+
+ `); + expect(core.interactions.length).toBe(1); + let embeddedEl = el.querySelector(".s_embed_code_embedded div"); + embeddedEl.textContent = "changed"; + expect(embeddedEl.textContent).toBe("changed"); + core.stopInteractions(); + expect(core.interactions.length).toBe(0); + embeddedEl = el.querySelector(".s_embed_code_embedded div"); + expect(embeddedEl.textContent).toBe("original"); +}); diff --git a/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js b/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js new file mode 100644 index 0000000000000..e4649f7eee14c --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js @@ -0,0 +1,190 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame } from "@odoo/hoot-dom"; + +import { + setupTest, + customScroll, +} from "../header/helpers"; + +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList([ + "website.header_standard", + "website.header_fixed", + "website.header_disappears", + "website.header_fade_out", + "website.faq_horizontal", +]); +describe.current.tags("interaction_dev"); + +const getTemplate = function (headerType, useHiddenOnScroll) { + return ` +
+ ${useHiddenOnScroll ? `
` : ""} +
+
+
+
+
+
+
+
+
+

Getting Started

+

Getting started with our product is a breeze, thanks to our well-structured and comprehensive onboarding process.

+
+
+ +

We understand that the initial setup can be daunting, especially if you are new to our platform, so we have designed a step-by-step guide to walk you through every stage, ensuring that you can hit the ground running.

+ +


The first step in the onboarding process is account creation. This involves signing up on our platform using your email address or social media accounts. Once you’ve created an account, you will receive a confirmation email with a link to activate your account. Upon activation, you’ll be prompted to complete your profile, which includes setting up your preferences, adding any necessary payment information, and selecting the initial features or modules you wish to use.

+

Next, you will be introduced to our setup wizard, which is designed to guide you through the basic configuration of the platform. The wizard will help you configure essential settings such as language, time zone, and notifications.

+

Read More

+
+
+
+
+
+
+
+

Updates and Improvements

+

We are committed to continuous improvement, regularly releasing updates and new features based on user feedback and technological advancements.

+
+
+ +

Our development team works tirelessly to enhance the platform's performance, security, and functionality, ensuring it remains at the cutting edge of innovation.

+

Each update is thoroughly tested to guarantee compatibility and reliability, and we provide detailed release notes to keep you informed of new features and improvements.

+
+ +
+


Users can participate in beta testing programs, providing feedback on upcoming releases and influencing the future direction of the platform. By staying current with updates, you can take advantage of the latest tools and features, ensuring your business remains competitive and efficient.

+
+
+
+
+
+
+
+

Support and Resources

+

We are committed to providing exceptional support and resources to help you succeed with our platform.

+
+
+ +

Our support team is available 24/7 to assist with any issues or questions you may have, ensuring that help is always within reach.

+

Additionally, we offer a comprehensive knowledge base, including detailed documentation, video tutorials, and community forums where you can connect with other users and share insights.

+

We also provide regular updates and new features based on user feedback, ensuring that our platform continues to evolve to meet your needs.

+

Documentation

+
+
+
+
+
+
+ ` +} + +const HEADER_SIZE = 50 +const DEFAULT_OFFSET = 16 + +test("faq_horizontal is started when there is an element .s_faq_horizontal", async () => { + const { core } = await startInteractions(getTemplate("", false)); + expect(core.interactions.length).toBe(1); +}); + +test.tags("desktop")("faq_horizontal updates titles position with a o_header_standard", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_standard", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_faq_horizontal_entry_title"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, we have to take into + // account the scroll in the test when checking where the bottom + // of the header is (ie. when the header is shown and scroll != 0). + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) + await customScroll(wrapwrap, 0, 40); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 40) + await customScroll(wrapwrap, 40, 200); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 200, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 400) + await customScroll(wrapwrap, 400, 200); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 200, 40); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 40) + await customScroll(wrapwrap, 40, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); + +test.tags("desktop")("faq_horizontal updates titles position with a o_header_fixed", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_fixed", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_faq_horizontal_entry_title"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, the first scroll we do + // create a scroll offset we have to take into account when checking + // where the bottom of the header is (ie. when the header is shown + // and scroll != 0). + // + // TODO Investigate where this issue comes from (might be like to + // the fact the state "atTop" is updated and there is a transform + // applied to the header). + const offset = 10; + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) + await customScroll(wrapwrap, 0, offset); + document.dispatchEvent(new Event("scroll")); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, offset, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, 15, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, 400, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, 15, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); + +test.tags("desktop")("faq_horizontal updates titles position with a o_header_disappears", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_disappears", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_faq_horizontal_entry_title"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, we have to take into + // account the scroll in the test when checking where the bottom + // of the header is (ie. when the header is shown and scroll != 0). + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET); + await customScroll(wrapwrap, 0, 10); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 10) + await customScroll(wrapwrap, 10, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 400, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 15) + await customScroll(wrapwrap, 15, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); + +test.tags("desktop")("faq_horizontal updates titles position with a o_header_fade_out", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_fade_out", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_faq_horizontal_entry_title"); + console.log(wrapwrap.getBoundingClientRect().top); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, we have to take into + // account the scroll in the test when checking where the bottom + // of the header is (ie. when the header is shown and scroll != 0). + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET); + await customScroll(wrapwrap, 0, 10); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 10) + await customScroll(wrapwrap, 10, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET); + await customScroll(wrapwrap, 400, 15); + await animationFrame(); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 15) + await customScroll(wrapwrap, 15, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); diff --git a/addons/website/static/tests/interactions/snippets/form.edit.test.js b/addons/website/static/tests/interactions/snippets/form.edit.test.js new file mode 100644 index 0000000000000..7dec6e1a84382 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/form.edit.test.js @@ -0,0 +1,99 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { MockServer, patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; +import { switchToEditMode } from "../../helpers"; + +describe.current.tags("interaction_dev"); + +const formXml = ` +
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ + Submit +
+
+ +
+
+
+`; + +function setupUser() { + patchWithCleanup(MockServer.prototype, { + callOrm(params) { + expect(params.model).toBe("res.users"); + expect(params.method).toBe("read"); + const result = super.callOrm(...arguments); + result[0].commercial_company_name = "TestCompany"; + return result; + } + }); +} + + +test("form formats date in edit mode", async () => { + setupInteractionWhiteList("website.form_date_formatter"); + const { core, el } = await startInteractions(formXml, { waitForStart: true, editMode: true }); + await switchToEditMode(core); + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const dateEl = formEl.querySelector("input[name=When]"); + expect(dateEl.value).toBe("01/01/2025 10:00:00"); + // Verify that non-edit code did not run. + const dateField = dateEl.closest(".s_website_form_datetime"); + expect(dateField).not.toHaveClass("s_website_form_datepicker_initialized"); +}); + +test("form is NOT prefilled in edit mode", async () => { + setupInteractionWhiteList("website.form_date_formatter"); + setupUser(); + const { core, el } = await startInteractions(formXml, { waitForStart: true, editMode: true }); + await switchToEditMode(core) + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const companyEl = formEl.querySelector("input[name=company]"); + expect(companyEl.value).toBe(""); +}); + +test("form is NOT prefilled in translate mode", async () => { + setupInteractionWhiteList("website.form"); + setupUser(); + const { core, el } = await startInteractions(formXml, { waitForStart: true, translateMode: true }); + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const companyEl = formEl.querySelector("input[name=company]"); + expect(companyEl.value).toBe(""); +}); diff --git a/addons/website/static/tests/interactions/snippets/form.test.js b/addons/website/static/tests/interactions/snippets/form.test.js new file mode 100644 index 0000000000000..e982aa544aba5 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/form.test.js @@ -0,0 +1,252 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { click, fill } from "@odoo/hoot-dom"; +import { advanceTime, Deferred } from "@odoo/hoot-mock"; +import { MockServer, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.form", "website.post_link"); +describe.current.tags("interaction_dev"); + +function field(inputEl) { + return inputEl.closest(".s_website_form_field"); +} + +// TODO Split in distinct tests. + +test("form checks conditions", async () => { + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ + Submit +
+
+ +
+
+
+ `); + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const nameEl = formEl.querySelector("input[name=name]"); + const mailEl = formEl.querySelector("input[name=email_from]"); + const subjectEl = formEl.querySelector("input[name=subject]"); + const questionEl = formEl.querySelector("textarea[name=description]"); + const submitEl = formEl.querySelector("a.s_website_form_send"); + + function checkVisibility( + isNameVisible, + isMailVisible, + isSubjectVisible, + isQuestionVisible, + ) { + function checkSingle(isVisible, el) { + const fieldEl = field(el); + if (isVisible) { + expect(fieldEl).not.toHaveClass("d-none"); + expect(el.disabled).not.toBe(undefined); + } else { + expect(fieldEl).toHaveClass("d-none"); + expect(el.disabled).toBe(true); + } + } + checkSingle(isNameVisible, nameEl); + checkSingle(isMailVisible, mailEl); + checkSingle(isSubjectVisible, subjectEl); + checkSingle(isQuestionVisible, questionEl); + } + function checkError( + hasNameError, + hasMailError, + hasSubjectError, + hasQuestionError, + ) { + function checkSingle(hasError, el) { + const fieldEl = field(el); + if (hasError) { + expect(el).toHaveClass("is-invalid"); + expect(fieldEl).toHaveClass("o_has_error"); + } else { + expect(el).not.toHaveClass("is-invalid"); + expect(fieldEl).not.toHaveClass("o_has_error"); + } + } + checkSingle(hasNameError, nameEl); + checkSingle(hasMailError, mailEl); + checkSingle(hasSubjectError, subjectEl); + checkSingle(hasQuestionError, questionEl); + } + expect(nameEl).not.toBe(undefined); + expect(nameEl.value).toBe("Mitchell Admin"); + expect(mailEl).not.toBe(undefined); + expect(mailEl.value).toBe(""); + expect(subjectEl).not.toBe(undefined); + expect(questionEl).not.toBe(undefined); + expect(submitEl).not.toBe(undefined); + checkVisibility(true, true, false, false); + checkError(false, false, false, false); + // Submit => same visibility, error on mail. + await click(submitEl); + checkVisibility(true, true, false, false); + checkError(false, true, false, false); + // Fill mail => subject becomes visible. + await click(mailEl); + await fill("a@b.com"); + await advanceTime(400); // Debounce delay. + checkVisibility(true, true, true, false); + checkError(false, true, false, false); + // Submit => same visibility, error on subject. + await click(submitEl); + checkVisibility(true, true, true, false); + checkError(false, false, true, false); + // Fill subject => question becomes visible. + await click(subjectEl); + await fill("Subject"); + await advanceTime(400); // Debounce delay. + checkVisibility(true, true, true, true); + checkError(false, false, true, false); + // Submit => same visibility, error on question. + await click(submitEl); + checkVisibility(true, true, true, true); + checkError(false, false, false, true); + // Fill question. + await click(questionEl); + await fill("Question"); + await advanceTime(400); // Debounce delay. + checkVisibility(true, true, true, true); + checkError(false, false, false, true); + // Submit => no error & RPC. + let didRpc = false; + const rpcDone = new Deferred(); + onRpc("/website/form/mail.mail", async (args) => { + didRpc = true; + rpcDone.resolve(); + return {}; + }); + await click(submitEl); + await rpcDone; + checkVisibility(true, true, true, true); + checkError(false, false, false, false); + expect(didRpc).toBe(true); +}); + +test("form prefilled conditional", async () => { + patchWithCleanup(MockServer.prototype, { + callOrm(params) { + expect(params.model).toBe("res.users"); + expect(params.method).toBe("read"); + const result = super.callOrm(...arguments); + result[0].phone = "+1-555-5555"; + return result; + } + }); + + // Phone number is only visible if name is filled. + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ `); + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const nameEl = formEl.querySelector("input[name=name]"); + const phoneEl = formEl.querySelector("input[name=phone]"); + + expect(nameEl).not.toBe(undefined); + expect(nameEl.value).toBe("Mitchell Admin"); + expect(phoneEl).not.toBe(undefined); + expect(phoneEl.value).toBe("+1-555-5555"); +}); diff --git a/addons/website/static/tests/interactions/snippets/gallery.test.js b/addons/website/static/tests/interactions/snippets/gallery.test.js new file mode 100644 index 0000000000000..4c85716c4f379 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/gallery.test.js @@ -0,0 +1,109 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click, press } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; + +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.gallery"); +describe.current.tags("interaction_dev"); + +// TODO Obtain rendering from `website.s_images_wall` template ? +const defaultGallery = ` +
+ +
+`; + +test("gallery does nothing if there is no non-slideshow s_image_gallery", async () => { + const { core } = await startInteractions(` +
+
+ `); + expect(core.interactions.length).toBe(0); +}); + +async function checkLightbox({ next, previous, close }) { + const { core, el } = await startInteractions(defaultGallery); + expect(core.interactions.length).toBe(1); + const imgEls = el.querySelectorAll("img"); + await click(imgEls[3]); + await animationFrame(); + await advanceTime(1000); + const lightboxEl = el.ownerDocument.querySelector(".s_gallery_lightbox"); + expect(lightboxEl).not.toBe(null); + + async function checkActiveImage(expectedIndex) { + await animationFrame(); + await advanceTime(1000); + let lightboxActiveImgEl = lightboxEl.querySelector(".active img"); + expect(lightboxActiveImgEl).not.toBe(null); + expect(imgEls[expectedIndex].src).toMatch( + lightboxActiveImgEl.dataset.src, + ); + } + + await checkActiveImage(3); + await next(lightboxEl); + await checkActiveImage(4); + await next(lightboxEl); + await checkActiveImage(5); + await next(lightboxEl); + await checkActiveImage(0); + await previous(lightboxEl); + await checkActiveImage(5); + await previous(lightboxEl); + await checkActiveImage(4); + await close(lightboxEl); + await animationFrame(); + await advanceTime(1000); + expect(el.ownerDocument.querySelector(".s_gallery_lightbox")).toBe(null); +} + +test("gallery interaction opens lightbox on click, then use keyboard", async () => { + await checkLightbox({ + next: async () => { + await press("ArrowRight", { code: "ArrowRight" }); + }, + previous: async () => { + await press("ArrowLeft", { code: "ArrowLeft" }); + }, + close: async () => { + await press("Escape", { code: "Escape" }); + }, + }); +}); + +test("gallery interaction opens lightbox on click, then use mouse", async () => { + await checkLightbox({ + next: async (lightboxEl) => { + await click(lightboxEl.querySelector(".carousel-control-next")); + }, + previous: async (lightboxEl) => { + await click(lightboxEl.querySelector(".carousel-control-prev")); + }, + close: async (lightboxEl) => { + await click(lightboxEl.querySelector(".btn-close")); + }, + }); +}); diff --git a/addons/website/static/tests/interactions/snippets/gallery_slider.test.js b/addons/website/static/tests/interactions/snippets/gallery_slider.test.js new file mode 100644 index 0000000000000..f78f7e21be0ad --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/gallery_slider.test.js @@ -0,0 +1,217 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; + +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; +import { onceAllImagesLoaded } from "@website/utils/images"; + +setupInteractionWhiteList("website.gallery_slider"); +describe.current.tags("interaction_dev"); + +const SLIDE_DURATION = 1000; + +// TODO Obtain rendering from `website.s_images_gallery` template ? +const defaultGallery = ` +
+ +
+`; + +// TODO Obtain rendering from `website.gallery.s_image_gallery_mirror.lightbox` template ? +const defaultLightbox = ` +
+ + +
+`; + +// TODO Maybe recover from `website.gallery.slideshow` template. +const defaultOldLightbox = ` +
+ + +
+`; +test("gallery slider does nothing if there is no o_slideshow s_image_gallery", async () => { + const { core } = await startInteractions(` +
+
+ `); + expect(core.interactions.length).toBe(0); +}); + +test("gallery slider interaction on image gallery", async () => { + const { core, el } = await startInteractions(defaultGallery); + expect(core.interactions.length).toBe(1); + await animationFrame(); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const imgEl = el.querySelector(".carousel-item.active img"); + const goToEls = el.querySelectorAll("button[data-bs-slide-to]"); + await click(goToEls[2]); + await animationFrame(); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const img2El = el.querySelector(".carousel-item.active img"); + expect(imgEl).not.toBe(img2El); +}); + +test("gallery slider interaction on lightbox", async () => { + const { core, el } = await startInteractions(defaultLightbox); + expect(core.interactions.length).toBe(1); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const imgEl = el.querySelector(".carousel-item.active img"); + const nextEl = el.querySelector(".carousel-control-next"); + const goToEls = el.querySelectorAll("button[data-bs-slide-to]"); + await click(nextEl); + await animationFrame(); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const img2El = el.querySelector(".carousel-item.active img"); + expect(imgEl).not.toBe(img2El); + await click(goToEls[2]); + await animationFrame(); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const img3El = el.querySelector(".carousel-item.active img"); + expect(imgEl).not.toBe(img3El); + expect(img2El).not.toBe(img3El); +}); + +test("gallery slider interaction on old lightbox", async () => { + const { core, el } = await startInteractions(defaultOldLightbox); + expect(core.interactions.length).toBe(1); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + // Fix parameters that are based on sizes. + core.interactions[0].interaction.page = 0; + core.interactions[0].interaction.nbPages = 6; + core.interactions[0].interaction.realNbPerPage = 1; + const imgEl = el.querySelector(".carousel-item.active img"); + const nextEl = el.querySelector(".o_indicators_right"); + const goToEls = el.querySelectorAll("li[data-bs-slide-to]"); + await click(nextEl); + await animationFrame(); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const img2El = el.querySelector(".carousel-item.active img"); + expect(imgEl).not.toBe(img2El); + await click(goToEls[2]); + await animationFrame(); + await onceAllImagesLoaded(el); + await advanceTime(SLIDE_DURATION); + const img3El = el.querySelector(".carousel-item.active img"); + expect(imgEl).not.toBe(img3El); + expect(img2El).not.toBe(img3El); +}); diff --git a/addons/website/static/tests/interactions/snippets/popup.test.js b/addons/website/static/tests/interactions/snippets/popup.test.js new file mode 100644 index 0000000000000..6fe48944b08a4 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/popup.test.js @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { + animationFrame, + click, + hover, + leave, + manuallyDispatchProgrammaticEvent, + pointerDown, + press, + tick, +} from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { browser } from "@web/core/browser/browser"; +import { cookie } from "@web/core/browser/cookie"; +import { defineStyle } from "@web/../tests/web_test_helpers"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.popup"); +describe.current.tags("interaction_dev"); + +/** + * Remove the CSS transitions because Bootstrap transitions don't work well with + * Hoot. + */ +function removeTransitions() { + defineStyle(/* css */ ` + * { + transition: none !important; + } + `); +} + +/** + * @param {Object} [options] + * @param {number} [options.showAfter] - delay + * @param {string} [options.display] - one of "afterDelay", "onClick", "mouseExit" + * @param {boolean} [options.backdrop] + * @param {string} [options.extraPrimaryBtnClasses] + * @param {string} [options.modalId] + * @returns {string} - popup template + */ +function getPopupTemplate(options = {}) { + const { + showAfter = 0, + display = "afterDelay", + backdrop = true, + extraPrimaryBtnClasses = "", + modalId = "", + } = options; + return ` +
+ +
+ `; +} + +test("popup interaction does not activate without .s_popup", async () => { + const { core } = await startInteractions(``); + expect(core.interactions).toHaveLength(0); +}); + +describe("close popup", () => { + beforeEach(removeTransitions); + test("close popup with close button and check cookies", async () => { + const { core } = await startInteractions(getPopupTemplate()); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup .modal"; + expect(cookie.get("sPopup")).not.toBe("true"); + await tick(); + await animationFrame(); + expect(modal).toBeVisible(); + await tick(); + await click(".js_close_popup"); + expect(modal).not.toBeVisible(); + expect(cookie.get("sPopup")).toBe("true"); + }); + + test("close popup by pressing escape", async () => { + const { core } = await startInteractions(getPopupTemplate()); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup .modal"; + await tick(); + await animationFrame(); + expect(modal).toBeVisible(); + // Focus the modal so that the escape is dispatched on the right element. + await pointerDown(modal); + await tick(); + await press("Escape"); + expect(modal).not.toBeVisible(); + }); + + test("click on primary button closes popup", async () => { + const { core } = await startInteractions(getPopupTemplate()); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup .modal"; + await tick(); + await animationFrame(); + expect(modal).toBeVisible(); + await tick(); + await click(".btn-primary"); + expect(modal).not.toBeVisible(); + }); + + test("click on primary button which is a form submit doesn't close popup", async () => { + const { core } = await startInteractions(getPopupTemplate({ extraPrimaryBtnClasses: "o_website_form_send" })); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup .modal"; + await tick(); + await animationFrame(); + expect(modal).toBeVisible(); + await click(".btn-primary.o_website_form_send"); + expect(modal).toBeVisible(); + }); +}); + +describe("show popup", () => { + beforeEach(removeTransitions); + test("popup shows after 5000ms", async () => { + const { core } = await startInteractions(getPopupTemplate({ showAfter: 5000 })); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup .modal"; + expect(modal).not.toBeVisible(); + await advanceTime(4500); + expect(modal).not.toBeVisible(); + await advanceTime(1000); + expect(modal).toBeVisible(); + }); + + test("show popup after click on link", async () => { + const { core } = await startInteractions(` + Show popup + ${getPopupTemplate({ display: "onClick", modalId: "modal" })} + `); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup #modal[data-display='onClick']"; + expect(modal).not.toBeVisible(); + await click("a[href='#modal']"); + await manuallyDispatchProgrammaticEvent(window, "hashchange", { newURL: browser.location.hash }); + expect(modal).toBeVisible(); + }); + + test.tags`desktop`("show popup when mouse leaves document", async () => { + const { core, el } = await startInteractions(getPopupTemplate({ display: "mouseExit" })); + expect(core.interactions).toHaveLength(1); + const modalEl = el.querySelector("#sPopup .modal"); + expect(modalEl).not.toBeVisible(); + await hover(modalEl.ownerDocument.body); + await leave(modalEl.ownerDocument.body); + expect(modalEl).toBeVisible(); + }); +}); diff --git a/addons/website/static/tests/interactions/snippets/search_bar.test.js b/addons/website/static/tests/interactions/snippets/search_bar.test.js new file mode 100644 index 0000000000000..3031be50a4fea --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/search_bar.test.js @@ -0,0 +1,141 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { + click, + press, +} from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { onRpc } from "@web/../tests/web_test_helpers"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.search_bar"); +describe.current.tags("interaction_dev"); + +const searchTemplate = ` +
+ + +
+`; + +function supportAutocomplete() { + onRpc("/website/snippet/autocomplete", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.search_type).toBe("test"); + expect(json.params.term).toBe("xyz"); + expect(json.params.order).toBe("test desc"); + expect(json.params.limit).toBe(3); + expect(json.params.options.displayImage).toBe("false"); + expect(json.params.options.displayDescription).toBe("false"); + expect(json.params.options.displayExtraLink).toBe("true"); + expect(json.params.options.displayDetail).toBe("false"); + } + return { + "results": [ + { + "_fa": "fa-file-o", + "name": "Xyz 1", + "website_url": "/website/test/xyz-1", + }, + { + "_fa": "fa-file-o", + "name": "Xyz 2", + "website_url": "/website/test/xyz-2", + }, + { + "_fa": "fa-file-o", + "name": "Xyz 3", + "website_url": "/website/test/xyz-3", + } + ], + "results_count": 3, + "parts": { + "name": true, + "website_url": true, + }, + "fuzzy_search": false + }; + }); +} + +test("searchbar triggers a search when text is entered", async () => { + supportAutocomplete(); + const { core, el } = await startInteractions(searchTemplate); + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const inputEl = formEl.querySelector("input[type=search]"); + await click(inputEl); + await press("x"); + await advanceTime(200); + await press("y"); + await advanceTime(200); + await press("z"); + await advanceTime(400); + const resultEls = formEl.querySelectorAll(".o_search_result_item"); + expect(resultEls.length).toBe(3); +}); + +test("searchbar selects first result on cursor down", async () => { + supportAutocomplete(); + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const inputEl = formEl.querySelector("input[type=search]"); + await click(inputEl); + await press("x"); + await press("y"); + await press("z"); + await advanceTime(400); + const resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + expect(resultEls.length).toBe(3); + expect(document.activeElement).toBe(inputEl); + await press("down"); + expect(document.activeElement).toBe(resultEls[0]); +}); + +test("searchbar selects last result on cursor up", async () => { + supportAutocomplete(); + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const inputEl = formEl.querySelector("input[type=search]"); + await click(inputEl); + await press("x"); + await press("y"); + await press("z"); + await advanceTime(400); + const resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + expect(resultEls.length).toBe(3); + expect(document.activeElement).toBe(inputEl); + await press("up"); + expect(document.activeElement).toBe(resultEls[2]); +}); + +test("searchbar removes results on escape", async () => { + supportAutocomplete(); + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const inputEl = formEl.querySelector("input[type=search]"); + await click(inputEl); + await press("x"); + await press("y"); + await press("z"); + await advanceTime(400); + let resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + expect(resultEls.length).toBe(3); + await press("escape"); + resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + expect(resultEls.length).toBe(0); +}); diff --git a/addons/website/static/tests/interactions/snippets/search_bar_results.test.js b/addons/website/static/tests/interactions/snippets/search_bar_results.test.js new file mode 100644 index 0000000000000..e41c3348b8e74 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/search_bar_results.test.js @@ -0,0 +1,77 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { press } from "@odoo/hoot-dom"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.search_bar_results"); +describe.current.tags("interaction_dev"); + +const searchTemplate = ` +
+ + + +
+`; + +test("searchbar selects next result on cursor down", async () => { + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + resultEls[0].focus(); + await press("down"); + expect(document.activeElement).toBe(resultEls[1]); +}); + +test("searchbar selects input on cursor down on last result", async () => { + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const inputEl = formEl.querySelector("input[type=search]"); + const resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + resultEls[1].focus(); + await press("down"); + expect(document.activeElement).toBe(inputEl); +}); + +test("searchbar selects previous result on cursor up", async () => { + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + resultEls[1].focus(); + await press("up"); + expect(document.activeElement).toBe(resultEls[0]); +}); + +test("searchbar selects input on cursor up on first result", async () => { + const { el } = await startInteractions(searchTemplate); + const formEl = el.querySelector("form"); + const inputEl = formEl.querySelector("input[type=search]"); + const resultEls = formEl.querySelectorAll("a:has(.o_search_result_item)"); + resultEls[0].focus(); + await press("up"); + expect(document.activeElement).toBe(inputEl); +}); diff --git a/addons/website/static/tests/interactions/snippets/table_of_content.test.js b/addons/website/static/tests/interactions/snippets/table_of_content.test.js new file mode 100644 index 0000000000000..43890d6adc527 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/table_of_content.test.js @@ -0,0 +1,311 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click, scroll } from "@odoo/hoot-dom"; + +import { + isElementVerticallyInViewportOf, + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +import { + setupTest, + customScroll, +} from "../header/helpers"; + +setupInteractionWhiteList([ + "website.header_standard", + "website.header_fixed", + "website.header_disappears", + "website.header_fade_out", + "website.table_of_content", +]); +describe.current.tags("interaction_dev"); + +// TODO Maybe recover from `website.s_table_of_content`. +const defaultToc = ` +
+
+
+ +
+
+
+

Intuitive system

+
+
+
+

+ Our intuitive system ensures effortless navigation for users of all skill levels. Its clean interface and logical organization make tasks easy to complete. With tooltips and contextual help, users quickly become productive, enjoying a smooth and efficient experience. +

+
+
+

What you see is what you get

+

+ Insert text styles like headers, bold, italic, lists, and fonts with a simple WYSIWYG editor. Flexible and easy to use, it lets you design and format documents in real time. No coding knowledge is needed, making content creation straightforward and enjoyable for everyone. +

+
+
+

Customization tool

+

+ Click and change content directly from the front-end, avoiding complex backend processes. This tool allows quick updates to text, images, and elements right on the page, streamlining your workflow and maintaining control over your content. +

+
+
+
+
+

Design features

+
+
+
+

+ Our design features offer a range of tools to create visually stunning websites. Utilize WYSIWYG editors, drag-and-drop building blocks, and Bootstrap-based templates for effortless customization. With professional themes and an intuitive system, you can design with ease and precision, ensuring a polished, responsive result. +

+
+
+

Building blocks system

+

+ Create pages from scratch by dragging and dropping customizable building blocks. This system simplifies web design, making it accessible to all skill levels. Combine headers, images, and text sections to build cohesive layouts quickly and efficiently. +

+
+
+

Bootstrap-Based Templates

+

+ Design Odoo templates easily with clean HTML and Bootstrap CSS. These templates offer a responsive, mobile-first design, making them simple to customize and perfect for any web project, from corporate sites to personal blogs. +

+
+
+
+
+
+
+
+
+`; + +test("table of content does nothing if there is no s_table_of_content_navbar_sticky", async () => { + const { core } = await startInteractions(` +
+
+
+ `); + expect(core.interactions.length).toBe(0); +}); + +test.tags("desktop")("table of content scrolls to targetted location (desktop)", async () => { + const { core, el } = await startInteractions(` +
+ ${defaultToc} +
+ `); + expect(core.interactions.length).toBe(1); + const wrapEl = el.querySelector("#wrapwrap"); + const aEls = el.querySelectorAll("a[href]"); + const h2Els = el.querySelectorAll("h2[id]"); + // Only works if the elements are displayed + expect(aEls[0]).toHaveClass("active"); + expect(aEls[1]).not.toHaveClass("active"); + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(false); + await click(aEls[1]); + await animationFrame(); + // Only works if the elements are displayed + expect(aEls[0]).not.toHaveClass("active"); + expect(aEls[1]).toHaveClass("active"); + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(false); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(true); +}); + +test.tags("mobile")("table of content scrolls to targetted location (mobile)", async () => { + const { core, el } = await startInteractions(` +
+ ${defaultToc} +
+ `); + expect(core.interactions.length).toBe(1); + const wrapEl = el.querySelector("#wrapwrap"); + const aEls = el.querySelectorAll("a[href]"); + const h2Els = el.querySelectorAll("h2[id]"); + // Only works if the elements are displayed + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(false); + await click(aEls[1]); + await animationFrame(); + // Only works if the elements are displayed + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(false); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(true); +}); + +test.tags("desktop")("table of content highlights reached header (desktop)", async () => { + const { core, el } = await startInteractions(` +
+ ${defaultToc} +
+ `); + expect(core.interactions.length).toBe(1); + const wrapEl = el.querySelector("#wrapwrap"); + const aEls = el.querySelectorAll("a[href]"); + const h2Els = el.querySelectorAll("h2[id]"); + // Only works if the elements are displayed + expect(aEls[0]).toHaveClass("active"); + expect(aEls[1]).not.toHaveClass("active"); + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(false); + await scroll(wrapEl, { top: h2Els[1].getBoundingClientRect().top }); + await animationFrame(); + // Only works if the elements are displayed + expect(aEls[0]).not.toHaveClass("active"); + expect(aEls[1]).toHaveClass("active"); + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(false); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(true); +}); + +test.tags("mobile")("table of content highlights reached header (mobile)", async () => { + const { core, el } = await startInteractions(` +
+ ${defaultToc} +
+ `); + expect(core.interactions.length).toBe(1); + const wrapEl = el.querySelector("#wrapwrap"); + const aEls = el.querySelectorAll("a[href]"); + const h2Els = el.querySelectorAll("h2[id]"); + // Only works if the elements are displayed + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(false); + await scroll(wrapEl, { top: h2Els[1].getBoundingClientRect().top }); + await animationFrame(); + // Only works if the elements are displayed + expect(isElementVerticallyInViewportOf(aEls[0], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(aEls[1], wrapEl)).toBe(true); + expect(isElementVerticallyInViewportOf(h2Els[0], wrapEl)).toBe(false); + expect(isElementVerticallyInViewportOf(h2Els[1], wrapEl)).toBe(true); +}); + +const getTemplate = function (headerType, useHiddenOnScroll) { + return ` +
+ ${useHiddenOnScroll ? `
` : ""} +
+
+
+ ${defaultToc} +
+ ` +} + +const HEADER_SIZE = 50 +const DEFAULT_OFFSET = 20 + +test.tags("desktop")("table_of_content updates titles position with a o_header_standard", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_standard", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_table_of_content_navbar"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, we have to take into + // account the scroll in the test when checking where the bottom + // of the header is (ie. when the header is shown and scroll != 0). + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) + await customScroll(wrapwrap, 0, 40); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 40) + await customScroll(wrapwrap, 40, 200); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 200, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 400) + await customScroll(wrapwrap, 400, 200); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 200, 40); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 40) + await customScroll(wrapwrap, 40, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); + +test.tags("desktop")("table_of_content updates titles position with a o_header_fixed", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_fixed", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_table_of_content_navbar"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, the first scroll we do + // create a scroll offset we have to take into account when checking + // where the bottom of the header is (ie. when the header is shown + // and scroll != 0). + // + // TODO Investigate where this issue comes from (might be like to + // the fact the state "atTop" is updated and there is a transform + // applied to the header). + const offset = 10; + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) + await customScroll(wrapwrap, 0, offset); + document.dispatchEvent(new Event("scroll")); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, offset, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, 15, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, 400, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - offset) + await customScroll(wrapwrap, 15, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); + +test.tags("desktop")("table_of_content updates titles position with a o_header_disappears", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_disappears", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_table_of_content_navbar"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, we have to take into + // account the scroll in the test when checking where the bottom + // of the header is (ie. when the header is shown and scroll != 0). + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET); + await customScroll(wrapwrap, 0, 10); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 10) + await customScroll(wrapwrap, 10, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 400, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 15) + await customScroll(wrapwrap, 15, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); + +test.tags("desktop")("table_of_content updates titles position with a o_header_fade_out", async () => { + const { el, core } = await startInteractions(getTemplate("o_header_fade_out", false)); + expect(core.interactions.length).toBe(2); + const wrapwrap = el.querySelector("#wrapwrap"); + const title = el.querySelector(".s_table_of_content_navbar"); + await setupTest(core, wrapwrap); + // Since the header does not move in Hoot, we have to take into + // account the scroll in the test when checking where the bottom + // of the header is (ie. when the header is shown and scroll != 0). + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET); + await customScroll(wrapwrap, 0, 10); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 10) + await customScroll(wrapwrap, 10, 400); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(DEFAULT_OFFSET) + await customScroll(wrapwrap, 400, 15); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET - 15) + await customScroll(wrapwrap, 15, 0); + expect(Math.round(parseFloat(title.style.top) - wrapwrap.getBoundingClientRect().top)).toBe(HEADER_SIZE + DEFAULT_OFFSET) +}); diff --git a/addons/website/static/tests/interactions/text_highlight.test.js b/addons/website/static/tests/interactions/text_highlight.test.js new file mode 100644 index 0000000000000..398bffb0581b0 --- /dev/null +++ b/addons/website/static/tests/interactions/text_highlight.test.js @@ -0,0 +1,51 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame } from "@odoo/hoot-dom"; + +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.text_highlight"); +describe.current.tags("interaction_dev"); + +const getTemplate = function (options = {}) { + return ` +

+ Great stories have a personality. + + + Consider telling a great story that provides personality. + + + + + + Writing a story with personality for potential clients will assist with making a relationship connection. This shows up in small quirks like word choices or phrases. Write from your point of view, not from someone else's experience. +

+ ` +} + +test("text_highlight is started when there is an element #wrapwrap", async () => { + const { core } = await startInteractions(getTemplate()); + expect(core.interactions.length).toBe(1); +}); + +test("[resize] update the number of highlight items when necessary", async () => { + const { el } = await startInteractions(getTemplate()); + el.querySelector("div").style.width = "1000px"; + + // Ensure the update is finished + await animationFrame(); + await animationFrame(); + const numberOfItems1 = el.querySelectorAll(".o_text_highlight_item").length; + + el.querySelector("div").style.width = "200px"; + + // Ensure the update is finished + await animationFrame(); + await animationFrame(); + const numberOfItems2 = el.querySelectorAll(".o_text_highlight_item").length; + + expect(numberOfItems1 < numberOfItems2).toBe(true); +}); diff --git a/addons/website/static/tests/interactions/website_animate_overflow.test.js b/addons/website/static/tests/interactions/website_animate_overflow.test.js new file mode 100644 index 0000000000000..66dfd8a5b0258 --- /dev/null +++ b/addons/website/static/tests/interactions/website_animate_overflow.test.js @@ -0,0 +1,38 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.website_animate_overflow"); +describe.current.tags("interaction_dev"); + +test("website animate overflow adds class during animations", async () => { + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions.length).toBe(1); + const htmlEl = el.closest("html"); + expect(htmlEl).not.toHaveClass("o_wanim_overflow_xy_hidden"); + const animatedEl = el.querySelector(".o_animate"); + animatedEl.classList.add("o_animating"); + await manuallyDispatchProgrammaticEvent(animatedEl, "updatecontent"); + expect(htmlEl).toHaveClass("o_wanim_overflow_xy_hidden"); + animatedEl.classList.remove("o_animating"); + await manuallyDispatchProgrammaticEvent(animatedEl, "updatecontent"); + expect(htmlEl).not.toHaveClass("o_wanim_overflow_xy_hidden"); +}); + +test("website animate overflow always adds class if there are transforms", async () => { + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions.length).toBe(1); + const htmlEl = el.closest("html"); + expect(htmlEl).toHaveClass("o_wanim_overflow_xy_hidden"); +}); diff --git a/addons/website/static/tests/interactions/website_controller_page_listing_layout.test.js b/addons/website/static/tests/interactions/website_controller_page_listing_layout.test.js new file mode 100644 index 0000000000000..2afdcd2997e39 --- /dev/null +++ b/addons/website/static/tests/interactions/website_controller_page_listing_layout.test.js @@ -0,0 +1,99 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; +import { Deferred } from "@odoo/hoot-mock"; +import { MockServer } from "@web/../tests/_framework/mock_server/mock_server"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.website_controller_page_listing_layout"); +describe.current.tags("interaction_dev"); + +test("website controller page listing layout toggle to list mode", async () => { + const deferred = new Deferred(); + const { el } = await startInteractions(` +
+
+
+ + + + +
+
+ +
+ `); + MockServer.current.onRoute(["/website/save_session_layout_mode"], async (request) => { + const jsonResponse = await request.body.getReader().read(); + const jsonParams = JSON.parse(new TextDecoder("utf-8").decode(jsonResponse.value)).params; + expect.step("rpc"); + expect(jsonParams.layout_mode).toBe("list"); + expect(jsonParams.view_id).toBe("123"); + deferred.resolve(); + }); + const gridEl = el.querySelector(".o_website_grid"); + const cellEl = el.querySelector(".o_website_grid > div"); + const toListEl = el.querySelector("#apply_list"); + await click(toListEl); + expect(gridEl).toHaveClass("o_website_list"); + expect(gridEl).not.toHaveClass("o_website_grid"); + expect(cellEl).not.toHaveClass("col-lg-3 col-md-4 col-sm-6 px-2 col-xs-12"); + await deferred; + expect.verifySteps(["rpc"]); +}); + +test("website controller page listing layout toggle to grid mode", async () => { + const deferred = new Deferred(); + const { el } = await startInteractions(` +
+
+
+ + + + +
+
+ +
+ `); + MockServer.current.onRoute(["/website/save_session_layout_mode"], async (request) => { + const jsonResponse = await request.body.getReader().read(); + const jsonParams = JSON.parse(new TextDecoder("utf-8").decode(jsonResponse.value)).params; + expect.step("rpc"); + expect(jsonParams.layout_mode).toBe("grid"); + expect(jsonParams.view_id).toBe("123"); + deferred.resolve(); + }); + const listEl = el.querySelector(".o_website_list"); + const cellEl = el.querySelector(".o_website_list > div"); + const toGridEl = el.querySelector("#apply_grid"); + await click(toGridEl); + expect(listEl).toHaveClass("o_website_grid"); + expect(listEl).not.toHaveClass("o_website_list"); + expect(cellEl).toHaveClass("col-lg-3 col-md-4 col-sm-6 px-2 col-xs-12"); + await deferred; + expect.verifySteps(["rpc"]); +}); diff --git a/addons/website/static/tests/interactions/zoomed_background_shape.test.js b/addons/website/static/tests/interactions/zoomed_background_shape.test.js new file mode 100644 index 0000000000000..0bd419d7f1edd --- /dev/null +++ b/addons/website/static/tests/interactions/zoomed_background_shape.test.js @@ -0,0 +1,37 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.zoomed_background_shape"); +describe.current.tags("interaction_dev"); + +test("zoomed background shape test is not needed without zoom", async () => { + const { core, el } = await startInteractions(` +
+
+
+
Some content
+
+
+ `); + expect(core.interactions.length).toBe(1); + const shapeEl = el.querySelector(".o_we_shape"); + expect(shapeEl.style.left).toBe(""); + expect(shapeEl.style.right).toBe(""); +}); + +test("zoomed background shape test applies correction on zoom", async () => { + const { core, el } = await startInteractions(` +
+
+
+
Some content
+
+
+ `); + expect(core.interactions.length).toBe(1); + const shapeEl = el.querySelector(".o_we_shape"); + // Adjustment depends on window size during test. + expect(shapeEl.style.left).toMatch(/\d+\.\d+px/); + expect(shapeEl.style.right).toMatch(/\d+\.\d+px/); + expect(shapeEl.style.left).toBe(shapeEl.style.right); +}); diff --git a/addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js b/addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js index 0e57742e97857..67641445eab83 100644 --- a/addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js +++ b/addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js @@ -1,12 +1,13 @@ +import { registry } from "@web/core/registry"; import { browser } from "@web/core/browser/browser"; const localStorage = browser.localStorage; odoo.loader.bus.addEventListener("module-started", (e) => { - if (e.detail.moduleName !== "@web/legacy/js/public/public_widget") { + if (e.detail.moduleName !== "@web/public/interaction") { return; } - const publicWidget = e.detail.module[Symbol.for("default")]; + const { Interaction } = e.detail.module; const localStorageKey = 'widgetAndWysiwygLifecycle'; if (!localStorage.getItem(localStorageKey)) { @@ -19,25 +20,28 @@ odoo.loader.bus.addEventListener("module-started", (e) => { localStorage.setItem(localStorageKey, newValue); } - publicWidget.registry.CountdownPatch = publicWidget.Widget.extend({ - selector: ".s_countdown", - disabledInEditableMode: false, + // TODO Re-evaluate: possibly became obsolete. + class CountdownPatch extends Interaction { + static selector = ".s_countdown"; + dynamicContent = { + "_root": { + // TODO Adapt naming if still needed. + "t-att-class": () => ({ "public_widget_started": true }), + }, + }; + // TODO Handle edit mode. + disabledInEditableMode = false; - /** - * @override - */ - async start() { + start() { addLifecycleStep('widgetStart'); - await this._super(...arguments); - this.el.classList.add("public_widget_started"); - }, - /** - * @override - */ + } + destroy() { - this.el.classList.remove("public_widget_started"); addLifecycleStep('widgetStop'); - this._super(...arguments); - }, - }); + } + } + + registry + .category("public.interactions") + .add("website.countdown_patch", CountdownPatch); }); diff --git a/addons/website/views/snippets/s_chart.xml b/addons/website/views/snippets/s_chart.xml index 80c0783cb03a1..63fa8a100fc3c 100644 --- a/addons/website/views/snippets/s_chart.xml +++ b/addons/website/views/snippets/s_chart.xml @@ -68,10 +68,10 @@ - - Chart 000 JS + + Chart JS web.assets_frontend - website/static/src/snippets/s_chart/000.js + website/static/src/snippets/s_chart/chart.js diff --git a/addons/website/views/snippets/s_countdown.xml b/addons/website/views/snippets/s_countdown.xml index 2903ce96b880d..6e00578d67f14 100644 --- a/addons/website/views/snippets/s_countdown.xml +++ b/addons/website/views/snippets/s_countdown.xml @@ -80,10 +80,10 @@ - - Countdown 000 JS + + Countdown JS web.assets_frontend - website/static/src/snippets/s_countdown/000.js + website/static/src/snippets/s_countdown/countdown.js diff --git a/addons/website/views/snippets/s_dynamic_snippet.xml b/addons/website/views/snippets/s_dynamic_snippet.xml index 8b912439f3531..20cb86dd68461 100644 --- a/addons/website/views/snippets/s_dynamic_snippet.xml +++ b/addons/website/views/snippets/s_dynamic_snippet.xml @@ -67,10 +67,10 @@ website/static/src/snippets/s_dynamic_snippet/000.scss - - Dynamic snippet 000 JS + + Dynamic snippet JS web.assets_frontend - website/static/src/snippets/s_dynamic_snippet/000.js + website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js diff --git a/addons/website/views/snippets/s_dynamic_snippet_carousel.xml b/addons/website/views/snippets/s_dynamic_snippet_carousel.xml index 289d807a1f865..7576fa31e04d4 100644 --- a/addons/website/views/snippets/s_dynamic_snippet_carousel.xml +++ b/addons/website/views/snippets/s_dynamic_snippet_carousel.xml @@ -30,10 +30,10 @@ website/static/src/snippets/s_dynamic_snippet_carousel/000.scss - - Dynamic snippet carousel 000 JS + + Dynamic snippet carousel JS web.assets_frontend - website/static/src/snippets/s_dynamic_snippet_carousel/000.js + website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js diff --git a/addons/website/views/snippets/s_embed_code.xml b/addons/website/views/snippets/s_embed_code.xml index 39beffdbb410f..ccad9adfaf981 100644 --- a/addons/website/views/snippets/s_embed_code.xml +++ b/addons/website/views/snippets/s_embed_code.xml @@ -37,10 +37,10 @@ - - Embed Code 000 JS + + Embed Code JS web.assets_frontend - website/static/src/snippets/s_embed_code/000.js + website/static/src/snippets/s_embed_code/embed_code.js diff --git a/addons/website/views/snippets/s_facebook_page.xml b/addons/website/views/snippets/s_facebook_page.xml index 26a8472e19189..f3e8f05308481 100644 --- a/addons/website/views/snippets/s_facebook_page.xml +++ b/addons/website/views/snippets/s_facebook_page.xml @@ -23,10 +23,10 @@ - - Facebook page 000 JS + + Facebook page JS web.assets_frontend - website/static/src/snippets/s_facebook_page/000.js + website/static/src/snippets/s_facebook_page/facebook_page.js diff --git a/addons/website/views/snippets/s_faq_horizontal.xml b/addons/website/views/snippets/s_faq_horizontal.xml index 59009d147b590..65eb461ac8521 100644 --- a/addons/website/views/snippets/s_faq_horizontal.xml +++ b/addons/website/views/snippets/s_faq_horizontal.xml @@ -89,10 +89,10 @@ - - Faq Horizontal 000 JS + + Faq Horizontal JS web.assets_frontend - website/static/src/snippets/s_faq_horizontal/000.js + website/static/src/snippets/s_faq_horizontal/faq_horizontal.js diff --git a/addons/website/views/snippets/s_image_gallery.xml b/addons/website/views/snippets/s_image_gallery.xml index 0616be03a307b..948c1c5755e39 100644 --- a/addons/website/views/snippets/s_image_gallery.xml +++ b/addons/website/views/snippets/s_image_gallery.xml @@ -123,10 +123,16 @@ - - Image gallery 000 JS + + Image gallery JS web.assets_frontend - website/static/src/snippets/s_image_gallery/000.js + website/static/src/snippets/s_image_gallery/gallery.js + + + + Image gallery slider JS + web.assets_frontend + website/static/src/snippets/s_image_gallery/gallery_slider.js diff --git a/addons/website/views/snippets/s_instagram_page.xml b/addons/website/views/snippets/s_instagram_page.xml index 03246bb38019e..978b0c4507604 100644 --- a/addons/website/views/snippets/s_instagram_page.xml +++ b/addons/website/views/snippets/s_instagram_page.xml @@ -25,10 +25,10 @@ - - Instagram Page 000 JS + + Instagram Page JS web.assets_frontend - website/static/src/snippets/s_instagram_page/000.js + website/static/src/snippets/s_instagram_page/instagram_page.js diff --git a/addons/website/views/snippets/s_map.xml b/addons/website/views/snippets/s_map.xml index 78dc2adf8b567..6d9577321c413 100644 --- a/addons/website/views/snippets/s_map.xml +++ b/addons/website/views/snippets/s_map.xml @@ -59,10 +59,10 @@ website/static/src/snippets/s_map/000.scss - - Map 000 JS + + Map JS web.assets_frontend - website/static/src/snippets/s_map/000.js + website/static/src/snippets/s_map/map.js diff --git a/addons/website/views/snippets/s_popup.xml b/addons/website/views/snippets/s_popup.xml index 012bcb7b64138..a875d0306538c 100644 --- a/addons/website/views/snippets/s_popup.xml +++ b/addons/website/views/snippets/s_popup.xml @@ -83,12 +83,6 @@ - - Popup 000 JS - web.assets_frontend - website/static/src/snippets/s_popup/000.js - - Popup 001 SCSS web.assets_frontend diff --git a/addons/website/views/snippets/s_searchbar.xml b/addons/website/views/snippets/s_searchbar.xml index 702b3a2d7fba4..74f5ba527f984 100644 --- a/addons/website/views/snippets/s_searchbar.xml +++ b/addons/website/views/snippets/s_searchbar.xml @@ -63,10 +63,16 @@ , .s_searchbar_input - - Searchbar 000 JS + + Searchbar JS web.assets_frontend - website/static/src/snippets/s_searchbar/000.js + website/static/src/snippets/s_searchbar/search_bar.js + + + + Searchbar results JS + web.assets_frontend + website/static/src/snippets/s_searchbar/search_bar_results.js diff --git a/addons/website/views/snippets/s_share.xml b/addons/website/views/snippets/s_share.xml index b1e72ccdd4772..22d1453075648 100644 --- a/addons/website/views/snippets/s_share.xml +++ b/addons/website/views/snippets/s_share.xml @@ -31,10 +31,10 @@ website/static/src/snippets/s_share/000.scss - - Share 000 JS + + Share JS web.assets_frontend - website/static/src/snippets/s_share/000.js + website/static/src/snippets/s_share/share.js diff --git a/addons/website/views/snippets/s_table_of_content.xml b/addons/website/views/snippets/s_table_of_content.xml index 0693f36d4d9be..c18fce8887c6e 100644 --- a/addons/website/views/snippets/s_table_of_content.xml +++ b/addons/website/views/snippets/s_table_of_content.xml @@ -98,10 +98,10 @@ website/static/src/snippets/s_table_of_content/000.scss - - Table of content 000 JS + + Table of content JS web.assets_frontend - website/static/src/snippets/s_table_of_content/000.js + website/static/src/snippets/s_table_of_content/table_of_content.js diff --git a/addons/website/views/snippets/s_website_form.xml b/addons/website/views/snippets/s_website_form.xml index fe5fc1e11f513..ce3c8c61dfcd7 100644 --- a/addons/website/views/snippets/s_website_form.xml +++ b/addons/website/views/snippets/s_website_form.xml @@ -340,10 +340,10 @@ website/static/src/snippets/s_website_form/001.scss - - Website form 000 JS + + Website form JS web.assets_frontend - website/static/src/snippets/s_website_form/000.js + website/static/src/snippets/s_website_form/form.js diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index c1992f844c959..d8a1be07c5f93 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -2389,7 +2389,7 @@ website.ripple_effect_js Ripple effect JS web.assets_frontend - /website/static/src/js/content/ripple_effect.js + /website/static/src/interactions/ripple_effect.js From 802509573240fa81a4522518982704806d3c53fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Tue, 17 Dec 2024 11:21:56 +0100 Subject: [PATCH 003/150] website_*: adapt code to Interactions --- .../static/src/js/wysiwyg/wysiwyg.js | 4 + addons/website_blog/__manifest__.py | 19 +- .../src/interactions/contentshare.edit.js | 16 + .../static/src/interactions/contentshare.js | 160 ++++ .../static/src/interactions/website_blog.js | 87 +++ .../static/src/js/contentshare.js | 141 ---- .../static/src/js/website_blog.js | 102 --- .../static/src/snippets/s_blog_posts/000.js | 36 - .../src/snippets/s_blog_posts/blog_posts.js | 34 + .../interactions/snippets/blog_posts.test.js | 80 ++ .../snippets/website_blog.test.js | 26 + .../views/snippets/s_blog_posts.xml | 6 +- addons/website_cf_turnstile/__manifest__.py | 9 +- .../src/{js => interactions}/error_handler.js | 0 .../static/src/interactions/form.js | 40 + .../static/src/interactions/turnstile.js | 60 ++ .../src/interactions/turnstile_captcha.js | 40 + .../static/src/js/turnstile.js | 122 --- .../static/tests/interactions/form.test.js | 25 + .../interactions/turnstile_captcha.test.js | 24 + addons/website_event/__manifest__.py | 11 +- .../static/src/snippets/s_events/000.js | 36 - .../static/src/snippets/s_events/events.js | 39 + .../static/src/snippets/s_searchbar/000.js | 11 - .../src/snippets/s_searchbar/search_bar.js | 8 + .../interactions/snippets/events.test.js | 80 ++ .../website_event/views/snippets/s_events.xml | 6 +- .../website_event/views/snippets/snippets.xml | 6 +- .../static/src/snippets/s_searchbar/000.js | 12 - .../src/snippets/s_searchbar/search_bar.js | 11 + addons/website_event_track/views/snippets.xml | 6 +- addons/website_forum/__manifest__.py | 10 +- .../components/website_forum_tags_wrapper.js | 70 ++ .../static/src/interactions/website_forum.js | 549 ++++++++++++++ .../src/interactions/website_forum_spam.js | 70 ++ .../static/src/js/website_forum.js | 716 ------------------ .../interactions/website_forum_spam.test.js | 109 +++ addons/website_mass_mailing/__manifest__.py | 10 +- .../static/src/interactions/popup.js | 30 + .../static/src/snippets/s_popup/000.js | 34 - .../static/tests/interactions/popup.test.js | 56 ++ .../views/snippets/s_popup.xml | 10 - addons/website_sale/__manifest__.py | 20 +- .../static/src/interactions/popup.js | 14 + .../static/src/js/website_sale.js | 14 +- .../s_dynamic_snippet_products/000.js | 203 ----- .../dynamic_snippet_products.js | 109 +++ .../product_card.js | 87 +++ .../static/src/snippets/s_popup/000.js | 23 - .../static/tests/interactions/popup.test.js | 44 ++ .../snippets/dynamic_snippet_products.test.js | 117 +++ .../snippets/s_dynamic_snippet_products.xml | 12 +- .../website_sale/views/snippets/s_popup.xml | 10 - 53 files changed, 2086 insertions(+), 1488 deletions(-) create mode 100644 addons/website_blog/static/src/interactions/contentshare.edit.js create mode 100644 addons/website_blog/static/src/interactions/contentshare.js create mode 100644 addons/website_blog/static/src/interactions/website_blog.js delete mode 100644 addons/website_blog/static/src/js/contentshare.js delete mode 100644 addons/website_blog/static/src/js/website_blog.js delete mode 100644 addons/website_blog/static/src/snippets/s_blog_posts/000.js create mode 100644 addons/website_blog/static/src/snippets/s_blog_posts/blog_posts.js create mode 100644 addons/website_blog/static/tests/interactions/snippets/blog_posts.test.js create mode 100644 addons/website_blog/static/tests/interactions/snippets/website_blog.test.js rename addons/website_cf_turnstile/static/src/{js => interactions}/error_handler.js (100%) create mode 100644 addons/website_cf_turnstile/static/src/interactions/form.js create mode 100644 addons/website_cf_turnstile/static/src/interactions/turnstile.js create mode 100644 addons/website_cf_turnstile/static/src/interactions/turnstile_captcha.js delete mode 100644 addons/website_cf_turnstile/static/src/js/turnstile.js create mode 100644 addons/website_cf_turnstile/static/tests/interactions/form.test.js create mode 100644 addons/website_cf_turnstile/static/tests/interactions/turnstile_captcha.test.js delete mode 100644 addons/website_event/static/src/snippets/s_events/000.js create mode 100644 addons/website_event/static/src/snippets/s_events/events.js delete mode 100644 addons/website_event/static/src/snippets/s_searchbar/000.js create mode 100644 addons/website_event/static/src/snippets/s_searchbar/search_bar.js create mode 100644 addons/website_event/static/tests/interactions/snippets/events.test.js delete mode 100644 addons/website_event_track/static/src/snippets/s_searchbar/000.js create mode 100644 addons/website_event_track/static/src/snippets/s_searchbar/search_bar.js create mode 100644 addons/website_forum/static/src/components/website_forum_tags_wrapper.js create mode 100644 addons/website_forum/static/src/interactions/website_forum.js create mode 100644 addons/website_forum/static/src/interactions/website_forum_spam.js delete mode 100644 addons/website_forum/static/src/js/website_forum.js create mode 100644 addons/website_forum/static/tests/interactions/website_forum_spam.test.js create mode 100644 addons/website_mass_mailing/static/src/interactions/popup.js delete mode 100644 addons/website_mass_mailing/static/src/snippets/s_popup/000.js create mode 100644 addons/website_mass_mailing/static/tests/interactions/popup.test.js delete mode 100644 addons/website_mass_mailing/views/snippets/s_popup.xml create mode 100644 addons/website_sale/static/src/interactions/popup.js delete mode 100644 addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js create mode 100644 addons/website_sale/static/src/snippets/s_dynamic_snippet_products/dynamic_snippet_products.js create mode 100644 addons/website_sale/static/src/snippets/s_dynamic_snippet_products/product_card.js delete mode 100644 addons/website_sale/static/src/snippets/s_popup/000.js create mode 100644 addons/website_sale/static/tests/interactions/popup.test.js create mode 100644 addons/website_sale/static/tests/interactions/snippets/dynamic_snippet_products.test.js delete mode 100644 addons/website_sale/views/snippets/s_popup.xml diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index eb44db12146e8..ec4ceaae04584 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -2853,6 +2853,10 @@ export class Wysiwyg extends Component { return Promise.resolve(); } + // force a destroy of all elements to clean dom + const iframe = document.querySelector("iframe.o_iframe"); + const websiteCore = iframe.contentWindow.odoo.__WOWL_DEBUG__.root.env.services["public.interactions"]; + websiteCore.stopInteractions(); // remove ZeroWidthSpace from odoo field value // ZeroWidthSpace may be present from OdooEditor edition process let escapedHtml = this._getEscapedElement($el).prop('outerHTML'); diff --git a/addons/website_blog/__manifest__.py b/addons/website_blog/__manifest__.py index af9e81b369a1f..4c503f20452b0 100644 --- a/addons/website_blog/__manifest__.py +++ b/addons/website_blog/__manifest__.py @@ -44,12 +44,25 @@ 'website_blog/static/src/js/wysiwyg_adapter.js', ], 'web.assets_tests': [ - 'website_blog/static/tests/**/*', + 'website_blog/static/tests/tours/**/*', + ], + 'web.assets_unit_tests': [ + 'website_blog/static/tests/interactions/**/*', + ], + 'web.assets_unit_tests_setup': [ + 'website_blog/static/src/interactions/**/*.js', + # TODO Re-activate when testing edit mode + ('remove', 'website_blog/static/src/interactions/**/*.edit.js'), + 'website_blog/static/src/snippets/**/*.js', + ('remove', 'website_blog/static/src/snippets/**/options.js'), ], 'web.assets_frontend': [ + 'website_blog/static/src/interactions/**/*', + ('remove', 'website_blog/static/src/interactions/**/*.edit.js'), 'website_blog/static/src/scss/website_blog.scss', - 'website_blog/static/src/js/contentshare.js', - 'website_blog/static/src/js/website_blog.js', + ], + 'website.assets_edit_frontend': [ + 'website_blog/static/src/**/*.edit.js', ], }, 'license': 'LGPL-3', diff --git a/addons/website_blog/static/src/interactions/contentshare.edit.js b/addons/website_blog/static/src/interactions/contentshare.edit.js new file mode 100644 index 0000000000000..410b212874275 --- /dev/null +++ b/addons/website_blog/static/src/interactions/contentshare.edit.js @@ -0,0 +1,16 @@ +import { registry } from "@web/core/registry"; +import { BlogContentShare } from "./contentshare"; + +const BlogContentShareEdit = I => class extends I { + dynamicContent = { + ...this.dynamicContent, + "_root": {}, + }; +}; + +registry + .category("public.interactions.edit") + .add("website_blog.blog_content_share", { + Interaction: BlogContentShare, + mixin: BlogContentShareEdit, + }); diff --git a/addons/website_blog/static/src/interactions/contentshare.js b/addons/website_blog/static/src/interactions/contentshare.js new file mode 100644 index 0000000000000..af2efdb1ce3b2 --- /dev/null +++ b/addons/website_blog/static/src/interactions/contentshare.js @@ -0,0 +1,160 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { sprintf } from "@web/core/utils/strings"; +import { scrollTo } from "@web_editor/js/common/scrolling"; +import { Interaction } from "@web/public/interaction"; + +export class BlogContentShare extends Interaction { + static selector = ".js_comment, .js_tweet"; + + dynamicContent = { + "_root": { "t-on-mouseup": this.showPopover }, + "_window": { "t-on-mousedown": this.hidePopover }, + }; + + setup() { + this.isCommentActive = this.el.matches(".js_comment"); + this.isTweetActive = this.el.matches(".js_tweet"); + + this.options = { + minLength: 5, + maxLength: 140, + }; + this.bsPopover = null; + this.shareCommentEl = null; + this.shareTweetEl = null; + this.removeCommentListener = null; + this.removeTweetListener = null; + this.popoverContentEl = null; + } + + showPopover() { + if (this.getSelectionRange("string").length < this.options.minLength) { + return; + } + const popoverEl = document.createElement("span"); + popoverEl.classList.add("share"); + this.popoverContentEl ||= this.makeContent(); + this.updatePopoverSelection(); + + const range = this.getSelectionRange(); + range.insertNode(popoverEl); + + this.bsPopover = Popover.getOrCreateInstance(popoverEl, { + trigger: "manual", + placement: "top", + html: true, + content: () => this.popoverContentEl, + }); + + this.bsPopover.show(); + this.registerCleanup(() => { + this.bsPopover.hide(); + this.bsPopover.dispose(); + popoverEl.remove(); + }); + } + + hidePopover() { + if (this.bsPopover) { + this.bsPopover.hide(); + } + } + + /** + * @param {"string" | null} type - whether to return a string or a Range + * @returns {"string" | Range} + */ + getSelectionRange(type) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return ""; + } + if (type === "string") { + return String(selection.getRangeAt(0)).replace(/\s{2,}/g, " "); + } else { + return selection.getRangeAt(0); + } + } + + makeContent() { + const popoverContentEl = document.createElement("div"); + popoverContentEl.className = "h4 m-0"; + + if (this.isCommentActive) { + this.shareCommentEl = this.makeButton( + "o_share_comment btn btn-link px-2", + "fa fa-lg fa-comment", + "Comment with the quoted selection" + ); + this.insert(this.shareCommentEl, popoverContentEl, "beforeend"); + } + if (this.isTweetActive) { + this.shareTweetEl = this.makeButton( + "btn", "ml4 mr4 fa fa-twitter fa-lg", "Tweet the selection" + ); + this.insert(this.shareTweetEl, popoverContentEl, "beforeend"); + } + return popoverContentEl; + } + + updatePopoverSelection() { + if (this.isCommentActive) { + const selectedText = this.getSelectionRange("string"); + this.removeCommentListener?.(); + this.removeCommentListener = this.addListener(this.shareCommentEl, "click", () => { + const textareaEl = document.querySelector("#chatterRoot")?.shadowRoot + .querySelector(".o-mail-Composer-coreMain textarea"); + if (textareaEl) { + textareaEl.value = `"${selectedText}"\n`; + textareaEl.focus(); + } + const commentsEl = document.getElementById("o_wblog_post_comments"); + if (commentsEl) { + scrollTo(commentsEl); + } + }); + } + if (this.isTweetActive) { + const tweet = '"%s" - %s'; + const baseLength = tweet.replace(/%s/g, "").length; + const selectedTextShort = this.getSelectionRange("string").substring( + 0, + this.options.maxLength - baseLength - 23 + ); + const text = window.btoa( + encodeURIComponent(sprintf(tweet, selectedTextShort, window.location.href)) + ); + + this.removeTweetListener?.(); + this.removeTweetListener = this.addListener(this.shareTweetEl, "click", () => { + const decodedText = atob(text); + window.open( + "http://twitter.com/intent/tweet?text=" + decodedText, + "_blank", + "location=yes,height=570,width=520,scrollbars=yes,status=yes" + ); + }); + } + } + + /** + * @param {string} btnClasses + * @param {string} iconClasses + * @param {string} iconTitle + */ + makeButton(btnClasses, iconClasses, iconTitle) { + const btnEl = document.createElement("button"); + btnEl.className = btnClasses; + const iconEl = document.createElement("span"); + iconEl.className = iconClasses; + iconEl.title = iconEl.ariaLabel = _t(iconTitle); + iconEl.role = "img"; + btnEl.appendChild(iconEl); + return btnEl; + } +} + +registry + .category("public.interactions") + .add("website_blog.blog_content_share", BlogContentShare); diff --git a/addons/website_blog/static/src/interactions/website_blog.js b/addons/website_blog/static/src/interactions/website_blog.js new file mode 100644 index 0000000000000..a25e2641b6b81 --- /dev/null +++ b/addons/website_blog/static/src/interactions/website_blog.js @@ -0,0 +1,87 @@ +import { browser } from "@web/core/browser/browser"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { scrollTo } from "@web_editor/js/common/scrolling"; +import { Interaction } from "@web/public/interaction"; + +export class WebsiteBlog extends Interaction { + static selector = ".website_blog"; + dynamicContent = { + "#o_wblog_next_container": { + "t-on-click": this.onNextBlogClick, + }, + "#o_wblog_post_content_jump": { + "t-on-click": this.onContentAnchorClick, + }, + ".o_twitter, .o_facebook, .o_linkedin, .o_google, .o_twitter_complete, .o_facebook_complete, .o_linkedin_complete, .o_google_complete": { + "t-on-click": this.onShareArticle, + }, + }; + + /** + * @param {Event} ev + */ + async onNextBlogClick(ev) { + ev.preventDefault(); + const nextInfo = ev.currentTarget.querySelector("#o_wblog_next_post_info").dataset; + const recordCoverContainerEl = ev.currentTarget.querySelector(".o_record_cover_container"); + const classes = nextInfo.size.split(" "); + recordCoverContainerEl.classList.add(...classes, nextInfo.textContent); + ev.currentTarget.querySelectorAll(".o_wblog_toggle").forEach(el => el.classList.toggle("d-none")); + // Appending a placeholder so that the cover can scroll to the top of the + // screen, regardless of its height. + const placeholder = document.createElement("div"); + placeholder.style.minHeight = "100vh"; + this.insert(placeholder, this.el.querySelector("#o_wblog_next_container"), "beforeend"); + await this.forumScrollAction(ev.currentTarget, 300, () => { + browser.location.href = nextInfo.url; + }); + } + + /** + * @param {Event} ev + */ + async onContentAnchorClick(ev) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + const currentTargetEl = document.querySelector(ev.currentTarget.hash); + + await this.forumScrollAction(currentTargetEl, 500, () => { + browser.location.hash = "blog_content"; + }); + } + + /** + * @param {Event} ev + */ + onShareArticle(ev) { + ev.preventDefault(); + let url = ""; + const blogPostTitle = document.querySelector("#o_wblog_post_name").textContent || ""; + const articleURL = browser.location.href; + if (ev.currentTarget.classList.contains("o_twitter")) { + const tweetText = _t("Amazing blog article: %(title)s! Check it live: %(url)s", { + title: blogPostTitle, + url: articleURL, + }); + url = "https://twitter.com/intent/tweet?tw_p=tweetbutton&text=" + encodeURIComponent(tweetText); + } else if (ev.currentTarget.classList.contains("o_facebook")) { + url = "https://www.facebook.com/sharer/sharer.php?u=" + encodeURIComponent(articleURL); + } else if (ev.currentTarget.classList.contains("o_linkedin")) { + url = "https://www.linkedin.com/sharing/share-offsite/?url=" + encodeURIComponent(articleURL); + } + window.open(url, "", "menubar=no, width=500, height=400"); + } + + /** + * @param {HTMLElement} el - the element we are scrolling to + * @param {Integer} duration - scroll animation duration + * @param {Function} callback - to be executed after the scroll is performed + */ + async forumScrollAction(el, duration, callback) { + await this.waitFor(scrollTo(el, { duration })); + callback(); + } +} + +registry.category("public.interactions").add("website_blog.website_blog", WebsiteBlog); diff --git a/addons/website_blog/static/src/js/contentshare.js b/addons/website_blog/static/src/js/contentshare.js deleted file mode 100644 index f67864a4ae0c4..0000000000000 --- a/addons/website_blog/static/src/js/contentshare.js +++ /dev/null @@ -1,141 +0,0 @@ -import { sprintf, escape } from "@web/core/utils/strings"; -import { scrollTo } from "@web_editor/js/common/scrolling"; - -export function share(el, options) { - const option = { - shareLink: "http://twitter.com/intent/tweet?text=", - minLength: 5, - maxLength: 140, - target: "blank", - className: "share", - placement: "top", - ...options - }; - let selectedText = ""; - - function init(shareable) { - shareable.addEventListener("mouseup", () => { - if (!shareable.closest("body.editor_enable")) { - popOver(); - } - }); - shareable.addEventListener("mousedown", destroy); - } - - function getContent() { - const popoverContentEl = document.createElement("div"); - popoverContentEl.className = "h4 m-0"; - - if ( - document.querySelector( - ".o_wblog_title.js_comment, .o_wblog_post_content_field.js_comment" - ) - ) { - selectedText = getSelection("string"); - const btnEl = document.createElement("a"); - btnEl.className = "o_share_comment btn btn-link px-2"; - btnEl.href = "#"; - const iEl = document.createElement("i"); - iEl.className = "fa fa-lg fa-comment"; - btnEl.appendChild(iEl); - popoverContentEl.appendChild(btnEl); - } - - if ( - document.querySelector(".o_wblog_title.js_tweet, .o_wblog_post_content_field.js_tweet") - ) { - const tweet = '"%s" - %s'; - const baseLength = tweet.replace(/%s/g, "").length; - const selectedTextShort = getSelection("string").substring( - 0, - option.maxLength - baseLength - 23 - ); - - const text = window.btoa( - encodeURIComponent(sprintf(tweet, selectedTextShort, window.location.href)) - ); - - const anchorEL = document.createElement("a"); - anchorEL.href = "#"; - anchorEL.classList.add("btn"); - anchorEL.addEventListener("click", () => { - const decodedText = atob(text); - window.open( - escape(option.shareLink) + decodedText, - `_${escape(option.target)}`, - "location=yes,height=570,width=520,scrollbars=yes,status=yes" - ); - }); - const iconEl = document.createElement("i"); - iconEl.className = "ml4 mr4 fa fa-twitter fa-lg"; - anchorEL.appendChild(iconEl); - popoverContentEl.appendChild(anchorEL); - } - - return popoverContentEl; - } - - function commentEdition() { - const textareaEl = document.querySelector(".o_portal_chatter_composer_body textarea"); - if (textareaEl) { - textareaEl.value = `"${selectedText}" `; - textareaEl.focus(); - } - const commentsEl = document.getElementById("o_wblog_post_comments"); - if (commentsEl) { - scrollTo(commentsEl).then(() => { - window.location.hash = "blog_post_comment_quote"; - }); - } - } - - function getSelection(type) { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return ""; - } - if (type === "string") { - return String(selection.getRangeAt(0)).replace(/\s{2,}/g, " "); - } else { - return selection.getRangeAt(0); - } - } - - function popOver() { - destroy(); - if (getSelection("string").length < option.minLength) { - return; - } - const data = getContent(); - const range = getSelection(); - - const newNode = document.createElement("span"); - range.insertNode(newNode); - newNode.className = option.className; - - const popover = Popover.getOrCreateInstance(newNode, { - trigger: "manual", - placement: option.placement, - html: true, - content: () => data, - }); - - popover.show(); - - const shareCommentEl = document.querySelector(".o_share_comment"); - shareCommentEl?.addEventListener("click", commentEdition); - } - - function destroy() { - const spanEl = document.querySelector(`span.${option.className}`); - if (spanEl) { - const popover = Popover.getInstance(spanEl); - if (popover) { - popover.hide(); - } - spanEl.remove(); - } - } - - init(el); -} diff --git a/addons/website_blog/static/src/js/website_blog.js b/addons/website_blog/static/src/js/website_blog.js deleted file mode 100644 index 3c11220540b00..0000000000000 --- a/addons/website_blog/static/src/js/website_blog.js +++ /dev/null @@ -1,102 +0,0 @@ -import { _t } from "@web/core/l10n/translation"; -import { scrollTo } from "@web_editor/js/common/scrolling"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { share } from "./contentshare"; - -publicWidget.registry.websiteBlog = publicWidget.Widget.extend({ - selector: '.website_blog', - events: { - 'click #o_wblog_next_container': '_onNextBlogClick', - 'click #o_wblog_post_content_jump': '_onContentAnchorClick', - 'click .o_twitter, .o_facebook, .o_linkedin, .o_google, .o_twitter_complete, .o_facebook_complete, .o_linkedin_complete, .o_google_complete': '_onShareArticle', - }, - - /** - * @override - */ - start: function () { - document.querySelectorAll(".js_tweet, .js_comment").forEach((el) => { - share(el); - }); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onNextBlogClick: function (ev) { - ev.preventDefault(); - const nexInfo = ev.currentTarget.querySelector("#o_wblog_next_post_info").dataset; - const recordCoverContainerEl = ev.currentTarget.querySelector(".o_record_cover_container"); - const classes = nexInfo.size.split(" "); - recordCoverContainerEl.classList.add(...classes, nexInfo.textContent); - ev.currentTarget.querySelectorAll(".o_wblog_toggle").forEach(el => el.classList.toggle("d-none")); - // Appending a placeholder so that the cover can scroll to the top of the - // screen, regardless of its height. - const placeholder = document.createElement('div'); - placeholder.style.minHeight = '100vh'; - this.el.querySelector("#o_wblog_next_container").append(placeholder); - - // Use setTimeout() to calculate the 'offset()'' only after that size classes - // have been applyed and that $el has been resized. - setTimeout(() => { - this._forumScrollAction(ev.currentTarget, 300, function () { - window.location.href = nexInfo.url; - }); - }); - }, - /** - * @private - * @param {Event} ev - */ - _onContentAnchorClick: function (ev) { - ev.preventDefault(); - ev.stopImmediatePropagation(); - const currentTargetEl = document.querySelector(ev.currentTarget.hash); - - this._forumScrollAction(currentTargetEl, 500, function () { - window.location.hash = 'blog_content'; - }); - }, - /** - * @private - * @param {Event} ev - */ - _onShareArticle: function (ev) { - ev.preventDefault(); - let url = ""; - const blogPostTitle = document.querySelector("#o_wblog_post_name").textContent || ""; - const articleURL = window.location.href; - if (ev.currentTarget.classList.contains("o_twitter")) { - const tweetText = _t("Amazing blog article: %(title)s! Check it live: %(url)s", { - title: blogPostTitle, - url: articleURL, - }); - url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=' + encodeURIComponent(tweetText); - } else if (ev.currentTarget.classList.contains("o_facebook")) { - url = 'https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(articleURL); - } else if (ev.currentTarget.classList.contains("o_linkedin")) { - url = 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(articleURL); - } - window.open(url, '', 'menubar=no, width=500, height=400'); - }, - - //-------------------------------------------------------------------------- - // Utils - //-------------------------------------------------------------------------- - - /** - * @private - * @param {HTMLElement} el - the element we are scrolling to - * @param {Integer} duration - scroll animation duration - * @param {Function} callback - to be executed after the scroll is performed - */ - _forumScrollAction: function (el, duration, callback) { - scrollTo(el, { duration: duration }).then(() => callback()); - }, -}); diff --git a/addons/website_blog/static/src/snippets/s_blog_posts/000.js b/addons/website_blog/static/src/snippets/s_blog_posts/000.js deleted file mode 100644 index 6832c979ec140..0000000000000 --- a/addons/website_blog/static/src/snippets/s_blog_posts/000.js +++ /dev/null @@ -1,36 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; -import DynamicSnippet from "@website/snippets/s_dynamic_snippet/000"; - -const DynamicSnippetBlogPosts = DynamicSnippet.extend({ - selector: '.s_dynamic_snippet_blog_posts', - disabledInEditableMode: false, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Method to be overridden in child components in order to provide a search - * domain if needed. - * @override - * @private - */ - _getSearchDomain: function () { - const searchDomain = this._super.apply(this, arguments); - const filterByBlogId = parseInt(this.$el.get(0).dataset.filterByBlogId); - if (filterByBlogId >= 0) { - searchDomain.push(['blog_id', '=', filterByBlogId]); - } - return searchDomain; - }, - /** - * @override - * @private - */ - _getMainPageUrl() { - return "/blog"; - }, -}); -publicWidget.registry.blog_posts = DynamicSnippetBlogPosts; - -export default DynamicSnippetBlogPosts; diff --git a/addons/website_blog/static/src/snippets/s_blog_posts/blog_posts.js b/addons/website_blog/static/src/snippets/s_blog_posts/blog_posts.js new file mode 100644 index 0000000000000..e4c14e716329f --- /dev/null +++ b/addons/website_blog/static/src/snippets/s_blog_posts/blog_posts.js @@ -0,0 +1,34 @@ +import { registry } from "@web/core/registry"; +import { DynamicSnippet } from "@website/snippets/s_dynamic_snippet/dynamic_snippet"; + +export class DynamicSnippetBlogPosts extends DynamicSnippet { + static selector = ".s_dynamic_snippet_blog_posts"; + + /** + * Method to be overridden in child components in order to provide a search + * domain if needed. + * @override + */ + getSearchDomain() { + const searchDomain = super.getSearchDomain(...arguments); + const filterByBlogId = parseInt(this.el.dataset.filterByBlogId); + if (filterByBlogId >= 0) { + searchDomain.push(["blog_id", "=", filterByBlogId]); + } + return searchDomain; + } + /** + * @override + */ + getMainPageUrl() { + return "/blog"; + } +} + +registry.category("public.interactions").add("website_blog.blog_posts", DynamicSnippetBlogPosts); + +registry + .category("public.interactions.edit") + .add("website_blog.blog_posts", { + Interaction: DynamicSnippetBlogPosts, + }); diff --git a/addons/website_blog/static/tests/interactions/snippets/blog_posts.test.js b/addons/website_blog/static/tests/interactions/snippets/blog_posts.test.js new file mode 100644 index 0000000000000..326f4017fd842 --- /dev/null +++ b/addons/website_blog/static/tests/interactions/snippets/blog_posts.test.js @@ -0,0 +1,80 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { + onRpc, +} from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +class TestItem extends Interaction { + static selector = ".s_test_item"; + dynamicContent = { + "_root": { + "t-att-data-started": (el) => `*${el.dataset.testParam}*`, + }, + }; +} +registry.category("public.interactions").add("website_blog.test_blog_post_item", TestItem); + +setupInteractionWhiteList(["website_blog.blog_posts", "website_blog.test_blog_post_item"]); +describe.current.tags("interaction_dev"); + +test("dynamic snippet blog posts loads items and displays them through template", async () => { + onRpc("/website/snippet/filters", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.filter_id).toBe(1); + expect(json.params.template_key).toBe("website_blog.dynamic_filter_template_blog_post_big_picture"); + expect(json.params.limit).toBe(16); + expect(json.params.search_domain).toEqual([["blog_id", "=", 1]]); + } + return [` +
+ Some test record +
+ `, ` +
+ Another test record +
+ `]; + }); + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ Your Dynamic Snippet will be displayed here... This message is displayed because you did not provide both a filter and a template to use. +
+
+
+
+
+
+
+
+
+ `); + expect(core.interactions.length).toBe(3); + const contentEl = el.querySelector(".dynamic_snippet_template"); + const itemEls = contentEl.querySelectorAll(".s_test_item"); + expect(itemEls[0].dataset.testParam).toBe("test"); + expect(itemEls[1].dataset.testParam).toBe("test2"); + // Make sure element interactions are started. + expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[1].dataset.started).toBe("*test2*"); + core.stopInteractions(); + // Make sure element interactions are stopped. + expect(core.interactions.length).toBe(0); +}); diff --git a/addons/website_blog/static/tests/interactions/snippets/website_blog.test.js b/addons/website_blog/static/tests/interactions/snippets/website_blog.test.js new file mode 100644 index 0000000000000..1a23d7cd0e24a --- /dev/null +++ b/addons/website_blog/static/tests/interactions/snippets/website_blog.test.js @@ -0,0 +1,26 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { browser } from "@web/core/browser/browser"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList(["website_blog.website_blog"]); +describe.current.tags("interaction_dev"); + +test("click on next blog updates URL", async () => { + const { core, el } = await startInteractions(` +
+
+
+ +
+
+
+ `); + expect(core.interactions.length).toBe(1); + expect(browser.location.pathname).toBe("/") + const nextBlogBtn = el.querySelector("#o_wblog_next_container"); + await click(nextBlogBtn); + await advanceTime(300); + expect(browser.location.pathname).toBe("/some/blog"); +}); diff --git a/addons/website_blog/views/snippets/s_blog_posts.xml b/addons/website_blog/views/snippets/s_blog_posts.xml index a681fece2ccb0..b70b49a669fbf 100644 --- a/addons/website_blog/views/snippets/s_blog_posts.xml +++ b/addons/website_blog/views/snippets/s_blog_posts.xml @@ -209,10 +209,10 @@ website_blog/static/src/snippets/s_blog_posts/000.scss
- - Blog posts 000 JS + + Blog posts JS web.assets_frontend - website_blog/static/src/snippets/s_blog_posts/000.js + website_blog/static/src/snippets/s_blog_posts/blog_posts.js diff --git a/addons/website_cf_turnstile/__manifest__.py b/addons/website_cf_turnstile/__manifest__.py index d7306fdc32242..7ef12736d68a7 100644 --- a/addons/website_cf_turnstile/__manifest__.py +++ b/addons/website_cf_turnstile/__manifest__.py @@ -14,8 +14,13 @@ ], 'assets': { 'web.assets_frontend': [ - 'website_cf_turnstile/static/src/js/turnstile.js', - 'website_cf_turnstile/static/src/js/error_handler.js', + 'website_cf_turnstile/static/src/interactions/**/*.js', + ], + 'web.assets_unit_tests': [ + 'website_cf_turnstile/static/tests/interactions/**/*', + ], + 'web.assets_unit_tests_setup': [ + 'website_cf_turnstile/static/src/interactions/**/*.js', ], }, 'license': 'LGPL-3', diff --git a/addons/website_cf_turnstile/static/src/js/error_handler.js b/addons/website_cf_turnstile/static/src/interactions/error_handler.js similarity index 100% rename from addons/website_cf_turnstile/static/src/js/error_handler.js rename to addons/website_cf_turnstile/static/src/interactions/error_handler.js diff --git a/addons/website_cf_turnstile/static/src/interactions/form.js b/addons/website_cf_turnstile/static/src/interactions/form.js new file mode 100644 index 0000000000000..f51eed90886fc --- /dev/null +++ b/addons/website_cf_turnstile/static/src/interactions/form.js @@ -0,0 +1,40 @@ +import { Form } from "@website/snippets/s_website_form/form"; +import { uniqueId } from "@web/core/utils/functions"; +import { patch } from "@web/core/utils/patch"; +import { session } from "@web/session"; +import { TurnStile } from "./turnstile"; + + +patch(Form.prototype, { + /** + * @override + */ + start() { + super.start(); + TurnStile.clean(this.el); + if ( + !this.el.querySelector(".s_turnstile") && + session.turnstile_site_key + ) { + this.uniq = uniqueId("turnstile_"); + this.el.classList.add(this.uniq); + const {turnstileEl, script1El, script2El} = new TurnStile( + "website_form", + `.${this.uniq} .s_website_form_send,.${this.uniq} .o_website_form_send`, + ); + const formSendEl = this.el.querySelector(".s_website_form_send, .o_website_form_send"); + formSendEl.parentNode.insertBefore(turnstileEl, formSendEl.nextSibling); + formSendEl.parentNode.insertBefore(script1El, formSendEl.nextSibling); + formSendEl.parentNode.insertBefore(script2El, formSendEl.nextSibling); + } + }, + + /** + * Discard all library changes to reset the state of the Html. + * @override + */ + destroy() { + TurnStile.clean(this.el); + super.destroy(); + }, +}); diff --git a/addons/website_cf_turnstile/static/src/interactions/turnstile.js b/addons/website_cf_turnstile/static/src/interactions/turnstile.js new file mode 100644 index 0000000000000..8beeec599ec82 --- /dev/null +++ b/addons/website_cf_turnstile/static/src/interactions/turnstile.js @@ -0,0 +1,60 @@ +import { session } from "@web/session"; + + +export class TurnStile { + constructor(action, selector) { + const cf = new URLSearchParams(window.location.search).get("cf"); + const mode = cf == "show" ? "always" : "interaction-only"; + const turnstileEl = document.createElement("div"); + turnstileEl.className = "s_turnstile cf-turnstile float-end"; + turnstileEl.dataset.action = action; + turnstileEl.dataset.appearance = mode; + turnstileEl.dataset.responseFieldName = "turnstile_captcha"; + turnstileEl.dataset.sitekey = session.turnstile_site_key; + turnstileEl.dataset.errorCallback = "throwTurnstileError"; + turnstileEl.dataset.beforeInteractiveCallback = "turnstileBeforeInteractive"; + turnstileEl.dataset.afterInteractiveCallback = "turnstileAfterInteractive"; + + const script1El = document.createElement("script"); + script1El.className = "s_turnstile"; + script1El.textContent = ` + // Rethrow the error, or we only will catch a "Script error" without any info + // because of the script api.js originating from a different domain. + function throwTurnstileError(code) { + const error = new Error("Turnstile Error"); + error.code = code; + throw error; + } + function turnstileBeforeInteractive() { + const btnEl = document.querySelector("${selector}"); + if (btnEl && !btnEl.classList.contains("disabled")) { + btnEl.classList.add("disabled", "cf_form_disabled"); + } + } + function turnstileAfterInteractive() { + const btnEl = document.querySelector("${selector}"); + if (btnEl && btnEl.classList.contains("cf_form_disabled")) { + btnEl.classList.remove("disabled", "cf_form_disabled"); + } + } + `; + + const script2El = document.createElement("script"); + script2El.className = "s_turnstile"; + script2El.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + + this.turnstileEl = turnstileEl; + this.script1El = script1El; + this.script2El = script2El; + } + + /** + * Remove potential existing loaded script/token + * + * @param {HTMLElement} el + */ + static clean(el) { + const turnstileEls = el.querySelectorAll(".s_turnstile"); + turnstileEls.forEach(element => element.remove()); + } +} diff --git a/addons/website_cf_turnstile/static/src/interactions/turnstile_captcha.js b/addons/website_cf_turnstile/static/src/interactions/turnstile_captcha.js new file mode 100644 index 0000000000000..12875ac514604 --- /dev/null +++ b/addons/website_cf_turnstile/static/src/interactions/turnstile_captcha.js @@ -0,0 +1,40 @@ +import { uniqueId } from "@web/core/utils/functions"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; +import { session } from "@web/session"; +import { TurnStile } from "./turnstile"; + +export class TurnstileCaptcha extends Interaction { + static selector = "[data-captcha]"; + + async willStart() { + TurnStile.clean(this.el); + } + + start() { + if ( + !this.el.querySelector(".s_turnstile") && + session.turnstile_site_key + ) { + this.uniq = uniqueId("turnstile_"); + const action = this.el.dataset.captcha || "generic"; + const {turnstileEl, script1El, script2El} = new TurnStile(action, `.${this.uniq}`); + const submitButton = this.el.querySelector("button[type='submit']"); + submitButton.classList.add(this.uniq); + submitButton.parentNode.insertBefore(turnstileEl, submitButton); + this.el.appendChild(script1El); + this.el.appendChild(script2El); + } + } + + /** + * Discard all library changes to reset the state of the Html. + * @override + */ + destroy() { + TurnStile.clean(this.el); + super.destroy(); + } +} + +registry.category("public.interactions").add("website_cf_turnstile.turnstile_captcha", TurnstileCaptcha); diff --git a/addons/website_cf_turnstile/static/src/js/turnstile.js b/addons/website_cf_turnstile/static/src/js/turnstile.js deleted file mode 100644 index 4622d9b5f1b29..0000000000000 --- a/addons/website_cf_turnstile/static/src/js/turnstile.js +++ /dev/null @@ -1,122 +0,0 @@ -import "@website/snippets/s_website_form/000"; // force deps -import { uniqueId } from "@web/core/utils/functions"; -import publicWidget from '@web/legacy/js/public/public_widget'; -import { session } from "@web/session"; - - -const turnStile = { - addTurnstile(action, selector) { - const cf = new URLSearchParams(window.location.search).get("cf"); - const mode = cf == "show" ? "always" : "interaction-only"; - const turnstileEl = document.createElement("div"); - turnstileEl.className = "s_turnstile cf-turnstile float-end"; - turnstileEl.dataset.action = action; - turnstileEl.dataset.appearance = mode; - turnstileEl.dataset.responseFieldName = "turnstile_captcha"; - turnstileEl.dataset.sitekey = session.turnstile_site_key; - turnstileEl.dataset.errorCallback = "throwTurnstileError"; - turnstileEl.dataset.beforeInteractiveCallback = "turnstileBeforeInteractive"; - turnstileEl.dataset.afterInteractiveCallback = "turnstileAfterInteractive"; - - const script1El = document.createElement("script"); - script1El.className = "s_turnstile"; - script1El.textContent = ` - // Rethrow the error, or we only will catch a "Script error" without any info - // because of the script api.js originating from a different domain. - function throwTurnstileError(code) { - const error = new Error("Turnstile Error"); - error.code = code; - throw error; - } - function turnstileBeforeInteractive() { - const btnEl = document.querySelector('${selector}'); - if (btnEl && !btnEl.classList.contains('disabled')) { - btnEl.classList.add('disabled', 'cf_form_disabled'); - } - } - function turnstileAfterInteractive() { - const btnEl = document.querySelector('${selector}'); - if (btnEl && btnEl.classList.contains('cf_form_disabled')) { - btnEl.classList.remove('disabled', 'cf_form_disabled'); - } - } - `; - - const script2El = document.createElement("script"); - script2El.className = "s_turnstile"; - script2El.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; - - return [turnstileEl, script1El, script2El]; - }, - - /** - * Remove potential existing loaded script/token - */ - cleanTurnstile: function () { - const turnstileEls = this.el.querySelectorAll(".s_turnstile"); - turnstileEls.forEach(element => element.remove()); - }, - - /** - * @override - * Discard all library changes to reset the state of the Html. - */ - destroy: function () { - this.cleanTurnstile(); - this._super(...arguments); - }, -}; - -publicWidget.registry.s_website_form.include({ - ...turnStile, - - /** - * @override - */ - start() { - const res = this._super(...arguments); - this.cleanTurnstile(); - if ( - !this.isEditable && - !this.el.querySelector(".s_turnstile") && - session.turnstile_site_key - ) { - this.uniq = uniqueId("turnstile_"); - this.el.classList.add(this.uniq); - const [turnstileEl, script1El, script2El] = this.addTurnstile( - "website_form", - `.${this.uniq} .s_website_form_send,.${this.uniq} .o_website_form_send`, - ); - const formSendEl = this.el.querySelector(".s_website_form_send, .o_website_form_send"); - formSendEl.parentNode.insertBefore(turnstileEl, formSendEl.nextSibling); - formSendEl.parentNode.insertBefore(script1El, formSendEl.nextSibling); - formSendEl.parentNode.insertBefore(script2El, formSendEl.nextSibling); - } - return res; - }, -}); - -publicWidget.registry.turnstileCaptcha = publicWidget.Widget.extend({ - ...turnStile, - - selector: "[data-captcha]", - - async willStart() { - this._super(...arguments); - this.cleanTurnstile(); - if ( - !this.isEditable && - !this.el.querySelector(".s_turnstile") && - session.turnstile_site_key - ) { - this.uniq = uniqueId("turnstile_"); - const action = this.el.dataset.captcha || "generic"; - const [turnstileEl, script1El, script2El] = this.addTurnstile(action, `.${this.uniq}`); - const submitButton = this.el.querySelector("button[type='submit']"); - submitButton.classList.add(this.uniq); - submitButton.parentNode.insertBefore(turnstileEl, submitButton); - this.el.appendChild(script1El); - this.el.appendChild(script2El); - } - }, -}); diff --git a/addons/website_cf_turnstile/static/tests/interactions/form.test.js b/addons/website_cf_turnstile/static/tests/interactions/form.test.js new file mode 100644 index 0000000000000..fa776cf34a080 --- /dev/null +++ b/addons/website_cf_turnstile/static/tests/interactions/form.test.js @@ -0,0 +1,25 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { session } from "@web/session"; + +setupInteractionWhiteList("website.form"); +describe.current.tags("interaction_dev"); + +test("turnstile captcha gets added to form snippets", async () => { + session.turnstile_site_key = "test"; + const { core, el } = await startInteractions(` +
+
+ Submit +
+
+ `); + expect(core.interactions.length).toBe(1); + let scriptEls = el.querySelectorAll("form script.s_turnstile"); + expect(scriptEls.length).toBe(2); + core.stopInteractions(); + // Make sure element interactions are stopped. + expect(core.interactions.length).toBe(0); + scriptEls = el.querySelectorAll("form script.s_turnstile"); + expect(scriptEls.length).toBe(0); +}); diff --git a/addons/website_cf_turnstile/static/tests/interactions/turnstile_captcha.test.js b/addons/website_cf_turnstile/static/tests/interactions/turnstile_captcha.test.js new file mode 100644 index 0000000000000..535656491bd3d --- /dev/null +++ b/addons/website_cf_turnstile/static/tests/interactions/turnstile_captcha.test.js @@ -0,0 +1,24 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { session } from "@web/session"; + +setupInteractionWhiteList("website_cf_turnstile.turnstile_captcha"); +describe.current.tags("interaction_dev"); + +test("turnstile captcha gets added to a data-captcha form", async () => { + session.turnstile_site_key = "test"; + const { core, el } = await startInteractions(` +
+ +
+ Select All +
+
+ + + +`; + +test("spamIds returns empty array if dataset is empty", async () => { + const { core } = await startInteractions(template()); + expect(core.interactions[0].interaction.spamIDs).toHaveLength(0); +}); + +test("keep last spam input search", async () => { + await startInteractions(template({ spamIds: true })); + + const def = new Deferred(); + def.then(() => expect.step("rpc")); + onRpc("forum.post", "search_read", async () => await def); + await click("#spamSearch"); + await fill("coucou"); + await advanceTime(201); // debounced + await edit("hello"); + await advanceTime(201); // debounced + expect.verifySteps([]); + def.resolve([{ content: "
hello
"}]); + await tick(); + expect.verifySteps(["rpc"]); + expect(".post_spam").toHaveText("hello"); +}); + +test("select all checkboxes", async () => { + await startInteractions(` +
+ +
+ `); + queryAll(".tab-pane input").forEach((el) => { + expect(el).not.toBeChecked(); + }); + await click(".o_wforum_select_all_spam"); + queryAll(".tab-pane input").forEach((el) => { + expect(el).toBeChecked(); + }); +}); diff --git a/addons/website_mass_mailing/__manifest__.py b/addons/website_mass_mailing/__manifest__.py index 6bde470dad496..dcfcc0cae3ebc 100644 --- a/addons/website_mass_mailing/__manifest__.py +++ b/addons/website_mass_mailing/__manifest__.py @@ -13,12 +13,12 @@ 'depends': ['website', 'mass_mailing', 'google_recaptcha'], 'data': [ 'data/ir_model_data.xml', - 'views/snippets/s_popup.xml', 'views/snippets_templates.xml', ], 'auto_install': ['website', 'mass_mailing'], 'assets': { 'web.assets_frontend': [ + 'website_mass_mailing/static/src/interactions/**/*', 'website_mass_mailing/static/src/scss/website_mass_mailing_popup.scss', 'website_mass_mailing/static/src/js/website_mass_mailing.js', 'website_mass_mailing/static/src/xml/*.xml', @@ -30,7 +30,13 @@ 'website_mass_mailing/static/src/snippets/s_popup/options.js', ], 'web.assets_tests': [ - 'website_mass_mailing/static/tests/**/*', + 'website_mass_mailing/static/tests/tours/**/*', + ], + 'web.assets_unit_tests': [ + 'website_mass_mailing/static/tests/interactions/**/*', + ], + 'web.assets_unit_tests_setup': [ + 'website_mass_mailing/static/src/interactions/**/*', ], }, 'license': 'LGPL-3', diff --git a/addons/website_mass_mailing/static/src/interactions/popup.js b/addons/website_mass_mailing/static/src/interactions/popup.js new file mode 100644 index 0000000000000..0585cacaf34d7 --- /dev/null +++ b/addons/website_mass_mailing/static/src/interactions/popup.js @@ -0,0 +1,30 @@ +import { patch } from "@web/core/utils/patch"; +import { Popup } from "@website/interactions/popup/popup"; + +patch(Popup.prototype, { + /** + * Prevents the (newsletter) popup to be shown if the user is subscribed. + * + * @override + */ + canShowPopup() { + if ( + this.el.classList.contains("o_newsletter_popup") + // js_subscribe_email is kept by compatibility (it was the old name + // of js_subscribe_value) + && this.el.querySelector("input.js_subscribe_value, input.js_subscribe_email")?.disabled + ) { + return false; + } + return super.canShowPopup(...arguments); + }, + /** + * @override + */ + canBtnPrimaryClosePopup(primaryBtnEl) { + if (primaryBtnEl.classList.contains("js_subscribe_btn")) { + return false; + } + return super.canBtnPrimaryClosePopup(...arguments); + }, +}); diff --git a/addons/website_mass_mailing/static/src/snippets/s_popup/000.js b/addons/website_mass_mailing/static/src/snippets/s_popup/000.js deleted file mode 100644 index be333a80612f7..0000000000000 --- a/addons/website_mass_mailing/static/src/snippets/s_popup/000.js +++ /dev/null @@ -1,34 +0,0 @@ -import PopupWidget from '@website/snippets/s_popup/000'; - -PopupWidget.include({ - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Prevents the (newsletter) popup to be shown if the user is subscribed. - * - * @override - */ - _canShowPopup() { - if ( - this.$el.is('.o_newsletter_popup') && - this.$el.find('input.js_subscribe_value, input.js_subscribe_email').prop('disabled') // js_subscribe_email is kept by compatibility (it was the old name of js_subscribe_value) - ) { - return false; - } - return this._super(...arguments); - }, - /** - * @override - */ - _canBtnPrimaryClosePopup(primaryBtnEl) { - if (primaryBtnEl.classList.contains('js_subscribe_btn')) { - return false; - } - return this._super(...arguments); - }, -}); - -export default PopupWidget; diff --git a/addons/website_mass_mailing/static/tests/interactions/popup.test.js b/addons/website_mass_mailing/static/tests/interactions/popup.test.js new file mode 100644 index 0000000000000..3d3e519830fa1 --- /dev/null +++ b/addons/website_mass_mailing/static/tests/interactions/popup.test.js @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { animationFrame, tick } from "@odoo/hoot-dom"; +import { defineStyle } from "@web/../tests/web_test_helpers"; +import { setupInteractionWhiteList, startInteractions } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.popup"); +describe.current.tags("interaction_dev"); + +function getTemplate(disabled = false) { + return ` +
+ +
+ `; +} + +describe("mail popup", () => { + beforeEach(() => defineStyle("* { transition: none !important; }")); + test("popup is shown if user is not subscribed (mail input not disabled)", async () => { + const { core } = await startInteractions(getTemplate()); + expect(core.interactions).toHaveLength(1); + await tick(); + await animationFrame(); + expect("#sPopup .modal").toBeVisible(); + }); + + test("popup is not shown if user is subscribed (mail input disabled)", async () => { + const { core } = await startInteractions(getTemplate(true)); + expect(core.interactions).toHaveLength(1); + await tick(); + await animationFrame(); + expect("#sPopup .modal").not.toBeVisible(); + }); +}); diff --git a/addons/website_mass_mailing/views/snippets/s_popup.xml b/addons/website_mass_mailing/views/snippets/s_popup.xml deleted file mode 100644 index c7c1408b04662..0000000000000 --- a/addons/website_mass_mailing/views/snippets/s_popup.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Popup 000 JS Website Mass Mailing Override - web.assets_frontend - website_mass_mailing/static/src/snippets/s_popup/000.js - - - diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index d65f64d96006e..f39f10d4fe09a 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -59,7 +59,6 @@ 'views/snippets/s_add_to_cart.xml', 'views/snippets/s_dynamic_snippet_products.xml', 'views/snippets/s_dynamic_snippet_products_preview_data.xml', - 'views/snippets/s_popup.xml', 'views/snippets/s_mega_menu/big_icons_subtitles.xml', 'views/snippets/s_mega_menu/cards.xml', 'views/snippets/s_mega_menu/image_menu.xml', @@ -79,6 +78,7 @@ 'uninstall_hook': 'uninstall_hook', 'assets': { 'web.assets_frontend': [ + 'website_sale/static/src/interactions/**/*', 'website_sale/static/src/js/tours/tour_utils.js', 'website_sale/static/src/scss/website_sale.scss', 'website_sale/static/src/scss/website_sale_frontend.scss', @@ -105,7 +105,7 @@ 'website_sale/static/src/js/website_sale_recently_viewed.js', 'website_sale/static/src/js/website_sale_tracking.js', 'website/static/lib/multirange/multirange_custom.js', - 'website/static/lib/multirange/multirange_instance.js', + 'website/static/src/interactions/multirange_input.js', 'website_sale/static/src/xml/website_sale_image_viewer.xml', 'website_sale/static/src/js/components/website_sale_image_viewer.js', 'website_sale/static/src/xml/website_sale_reorder_modal.xml', @@ -166,9 +166,23 @@ 'website_sale/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js', ], 'web.assets_tests': [ - 'website_sale/static/tests/**/*', + 'website_sale/static/tests/tours/**/*', 'website_sale/static/src/js/tours/product_configurator_tour_utils.js', ], + 'web.assets_unit_tests': [ + 'website_sale/static/tests/interactions/**/*', + ], + 'web.assets_unit_tests_setup': [ + 'website_sale/static/src/interactions/**/*', + 'website_sale/static/src/snippets/s_dynamic_snippet_products/dynamic_snippet_products.js', + # TODO Find out why these do not work: + #'website_sale/static/src/snippets/**/*.js', + # ('remove', 'website_sale/static/src/snippets/**/options.js'), + # TODO Re-activate when testing edit mode + #('remove', 'website_sale/static/src/snippets/**/*.edit.js'), + # TODO Remove when all 000 have been adapted + #('remove', 'website_sale/static/src/snippets/**/000.js'), + ], }, 'license': 'LGPL-3', } diff --git a/addons/website_sale/static/src/interactions/popup.js b/addons/website_sale/static/src/interactions/popup.js new file mode 100644 index 0000000000000..09de204136fd4 --- /dev/null +++ b/addons/website_sale/static/src/interactions/popup.js @@ -0,0 +1,14 @@ +import { patch } from "@web/core/utils/patch"; +import { Popup } from "@website/interactions/popup/popup"; + +patch(Popup.prototype, { + /** + * @override + */ + canBtnPrimaryClosePopup(primaryBtnEl) { + return ( + super.canBtnPrimaryClosePopup(...arguments) + && !primaryBtnEl.classList.contains("js_add_cart") + ); + }, +}); diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index db58893febb47..cc68078fe3c75 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -3,13 +3,11 @@ import { rpc } from "@web/core/network/rpc"; import { SIZES, utils as uiUtils } from "@web/core/ui/ui_service"; import { throttleForAnimation } from "@web/core/utils/timing"; import publicWidget from "@web/legacy/js/public/public_widget"; -import { extraMenuUpdateCallbacks } from "@website/js/content/menu"; import "@website/libs/zoomodoo/zoomodoo"; import { ProductImageViewer } from "@website_sale/js/components/website_sale_image_viewer"; import VariantMixin from "@website_sale/js/sale_variant_mixin"; import { cartHandlerMixin } from "@website_sale/js/website_sale_utils"; - export const WebsiteSale = publicWidget.Widget.extend(VariantMixin, cartHandlerMixin, { selector: '.oe_website_sale', events: Object.assign({}, VariantMixin.events || {}, { @@ -645,6 +643,10 @@ publicWidget.registry.websiteSaleCarouselProduct = publicWidget.Widget.extend({ 'wheel .o_carousel_product_indicators': '_onMouseWheel', }, + init() { + this.website_menus = this.bindService("website_menus"); + }, + /** * @override */ @@ -652,7 +654,7 @@ publicWidget.registry.websiteSaleCarouselProduct = publicWidget.Widget.extend({ await this._super(...arguments); this._updateCarouselPosition(); this.throttleOnResize = throttleForAnimation(this._onSlideCarouselProduct.bind(this)); - extraMenuUpdateCallbacks.push(this._updateCarouselPosition.bind(this)); + this.website_menus.registerCallback(this._updateCarouselPosition.bind(this)); if (this.$el.find('.carousel-indicators').length > 0) { this.$el.on('slide.bs.carousel.carousel_product_slider', this._onSlideCarouselProduct.bind(this)); $(window).on('resize.carousel_product_slider', this.throttleOnResize); @@ -746,13 +748,17 @@ publicWidget.registry.websiteSaleProductPageReviews = publicWidget.Widget.extend selector: '#o_product_page_reviews', disabledInEditableMode: false, + init() { + this.website_menus = this.bindService("website_menus"); + }, + /** * @override */ async start() { await this._super(...arguments); this._updateChatterComposerPosition(); - extraMenuUpdateCallbacks.push(this._updateChatterComposerPosition.bind(this)); + this.website_menus.registerCallback(this._updateChatterComposerPosition.bind(this)); }, /** * @override diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js deleted file mode 100644 index 1ba529a3a33a3..0000000000000 --- a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js +++ /dev/null @@ -1,203 +0,0 @@ -import { rpc } from "@web/core/network/rpc"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import DynamicSnippetCarousel from "@website/snippets/s_dynamic_snippet_carousel/000"; -import wSaleUtils from "@website_sale/js/website_sale_utils"; -import { WebsiteSale } from "../../js/website_sale"; - -const DynamicSnippetProducts = DynamicSnippetCarousel.extend({ - selector: '.s_dynamic_snippet_products', - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Gets the category search domain - * - * @private - */ - _getCategorySearchDomain() { - const searchDomain = []; - let productCategoryId = this.$el.get(0).dataset.productCategoryId; - if (productCategoryId && productCategoryId !== 'all') { - if (productCategoryId === 'current') { - productCategoryId = undefined; - const productCategoryField = $("#product_details").find(".product_category_id"); - if (productCategoryField && productCategoryField.length) { - productCategoryId = parseInt(productCategoryField[0].value); - } - if (!productCategoryId) { - this.trigger_up('main_object_request', { - callback: function (value) { - if (value.model === "product.public.category") { - productCategoryId = value.id; - } - }, - }); - } - if (!productCategoryId) { - // Try with categories from product, unfortunately the category hierarchy is not matched with this approach - const productTemplateId = $("#product_details").find(".product_template_id"); - if (productTemplateId && productTemplateId.length) { - searchDomain.push(['public_categ_ids.product_tmpl_ids', '=', parseInt(productTemplateId[0].value)]); - } - } - } - if (productCategoryId) { - searchDomain.push(['public_categ_ids', 'child_of', parseInt(productCategoryId)]); - } - } - return searchDomain; - }, - /** - * Gets the tag search domain - * - * @private - */ - _getTagSearchDomain() { - const searchDomain = []; - let productTagIds = this.$el.get(0).dataset.productTagIds; - productTagIds = productTagIds ? JSON.parse(productTagIds) : []; - if (productTagIds.length) { - searchDomain.push(['all_product_tag_ids', 'in', productTagIds.map(productTag => productTag.id)]); - } - return searchDomain; - }, - /** - * Method to be overridden in child components in order to provide a search - * domain if needed. - * @override - * @private - */ - _getSearchDomain: function () { - const searchDomain = this._super.apply(this, arguments); - searchDomain.push(...this._getCategorySearchDomain()); - searchDomain.push(...this._getTagSearchDomain()); - const productNames = this.$el.get(0).dataset.productNames; - if (productNames) { - const nameDomain = []; - for (const productName of productNames.split(',')) { - // Ignore empty names - if (!productName.length) { - continue; - } - // Search on name, internal reference and barcode. - if (nameDomain.length) { - nameDomain.unshift('|'); - } - nameDomain.push(...[ - '|', '|', ['name', 'ilike', productName], - ['default_code', '=', productName], - ['barcode', '=', productName], - ]); - } - searchDomain.push(...nameDomain); - } - if (!this.el.dataset.showVariants) { - searchDomain.push('hide_variants') - } - return searchDomain; - }, - /** - * @override - */ - _getRpcParameters: function () { - const productTemplateId = $("#product_details").find(".product_template_id"); - return Object.assign(this._super.apply(this, arguments), { - productTemplateId: productTemplateId && productTemplateId.length ? productTemplateId[0].value : undefined, - }); - }, - /** - * @override - * @private - */ - _getMainPageUrl() { - return "/shop"; - }, -}); - -const DynamicSnippetProductsCard = WebsiteSale.extend({ - selector: '.o_carousel_product_card', - read_events: { - 'click .js_add_cart': '_onClickAddToCart', - 'click .js_remove': '_onRemoveFromRecentlyViewed', - }, - - init(root, options) { - const parent = options.parent || root; - this._super(parent, options); - }, - - start() { - this.add2cartRerender = this.el.dataset.add2cartRerender === 'True'; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Event triggered by a click on the Add to cart button - * - * @param {OdooEvent} ev - */ - async _onClickAddToCart(ev) { - const button = ev.currentTarget - if (!button.dataset.productSelected || button.dataset.isCombo) { - const dummy_form = document.createElement('form'); - dummy_form.setAttribute('method', 'post'); - dummy_form.setAttribute('action', '/shop/cart/update'); - - const inputPT = document.createElement('input'); - inputPT.setAttribute('name', 'product_template_id'); - inputPT.setAttribute('type', 'hidden'); - inputPT.setAttribute('value', button.dataset.productTemplateId); - dummy_form.appendChild(inputPT); - - const inputPP = document.createElement('input'); - inputPP.setAttribute('name', 'product_id'); - inputPP.setAttribute('type', 'hidden'); - inputPP.setAttribute('value', button.dataset.productId); - dummy_form.appendChild(inputPP); - - return this._handleAdd($(dummy_form)); // existing logic expects jquery form - } - else { - const data = await rpc("/shop/cart/update_json", { - product_id: parseInt(ev.currentTarget.dataset.productId), - add_qty: 1, - display: false, - }); - wSaleUtils.updateCartNavBar(data); - wSaleUtils.showCartNotification(this.call.bind(this), data.notification_info); - } - if (this.add2cartRerender) { - this.trigger_up('widgets_start_request', { - $target: this.$el.closest('.s_dynamic'), - }); - } - }, - /** - * Event triggered by a click on the remove button on a "recently viewed" - * template. - * - * @param {OdooEvent} ev - */ - async _onRemoveFromRecentlyViewed(ev) { - const rpcParams = {} - if (ev.currentTarget.dataset.productSelected) { - rpcParams.product_id = ev.currentTarget.dataset.productId; - } else { - rpcParams.product_template_id = ev.currentTarget.dataset.productTemplateId; - } - await rpc("/shop/products/recently_viewed_delete", rpcParams); - this.trigger_up('widgets_start_request', { - $target: this.$el.closest('.s_dynamic'), - }); - }, -}); - -publicWidget.registry.dynamic_snippet_products_cta = DynamicSnippetProductsCard; -publicWidget.registry.dynamic_snippet_products = DynamicSnippetProducts; - -export default DynamicSnippetProducts; diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/dynamic_snippet_products.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/dynamic_snippet_products.js new file mode 100644 index 0000000000000..071fb3da07dc1 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/dynamic_snippet_products.js @@ -0,0 +1,109 @@ +import { registry } from "@web/core/registry"; +import { DynamicSnippetCarousel } from "@website/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel"; + +export class DynamicSnippetProducts extends DynamicSnippetCarousel { + static selector = ".s_dynamic_snippet_products"; + + /** + * Gets the category search domain + */ + getCategorySearchDomain() { + const searchDomain = []; + let productCategoryId = this.el.dataset.productCategoryId; + if (productCategoryId && productCategoryId !== "all") { + if (productCategoryId === "current") { + productCategoryId = undefined; + const productCategoryFieldEl = this.el.closest("body").querySelector("#product_details .product_category_id"); + if (productCategoryFieldEl) { + productCategoryId = parseInt(productCategoryFieldEl.value); + } + if (!productCategoryId) { + const mainObject = this.services.website_page.mainObject; + if (mainObject.model === "product.public.category") { + productCategoryId = mainObject.id; + } + } + if (!productCategoryId) { + // Try with categories from product, unfortunately the category hierarchy is not matched with this approach + const productTemplateIdEl = this.el.closest("body").querySelector("#product_details .product_category_id"); + if (productTemplateIdEl) { + searchDomain.push(["public_categ_ids.product_tmpl_ids", "=", parseInt(productTemplateIdEl.value)]); + } + } + } + if (productCategoryId) { + searchDomain.push(["public_categ_ids", "child_of", parseInt(productCategoryId)]); + } + } + return searchDomain; + } + /** + * Gets the tag search domain + */ + getTagSearchDomain() { + const searchDomain = []; + let productTagIds = this.el.dataset.productTagIds; + productTagIds = productTagIds ? JSON.parse(productTagIds) : []; + if (productTagIds.length) { + searchDomain.push(["all_product_tag_ids", "in", productTagIds.map(productTag => productTag.id)]); + } + return searchDomain; + } + /** + * Method to be overridden in child components in order to provide a search + * domain if needed. + * @override + */ + getSearchDomain() { + const searchDomain = super.getSearchDomain(...arguments); + searchDomain.push(...this.getCategorySearchDomain()); + searchDomain.push(...this.getTagSearchDomain()); + const productNames = this.el.dataset.productNames; + if (productNames) { + const nameDomain = []; + for (const productName of productNames.split(",")) { + // Ignore empty names + if (!productName.length) { + continue; + } + // Search on name, internal reference and barcode. + if (nameDomain.length) { + nameDomain.unshift("|"); + } + nameDomain.push(...[ + "|", "|", ["name", "ilike", productName], + ["default_code", "=", productName], + ["barcode", "=", productName], + ]); + } + searchDomain.push(...nameDomain); + } + if (!this.el.dataset.showVariants) { + searchDomain.push("hide_variants"); + } + return searchDomain; + } + /** + * @override + */ + getRpcParameters() { + const productTemplateIdEl = this.el.closest("body").querySelector("#product_details .product_category_id"); + return Object.assign(super.getRpcParameters(...arguments), { + productTemplateId: productTemplateIdEl ? productTemplateIdEl.value : undefined, + }); + } + /** + * @override + */ + getMainPageUrl() { + return "/shop"; + } +} + +registry.category("public.interactions").add("website_sale.dynamic_snippet_products", DynamicSnippetProducts); + +registry + .category("public.interactions.edit") + .add("website_sale.dynamic_snippet_products", { + Interaction: DynamicSnippetProducts, + }); diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/product_card.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/product_card.js new file mode 100644 index 0000000000000..75e12e68364ce --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/product_card.js @@ -0,0 +1,87 @@ +import publicWidget from "@web/legacy/js/public/public_widget"; +import { rpc } from "@web/core/network/rpc"; +import wSaleUtils from "@website_sale/js/website_sale_utils"; +import { WebsiteSale } from "../../js/website_sale"; + +const DynamicSnippetProductsCard = WebsiteSale.extend({ + selector: '.o_carousel_product_card', + read_events: { + 'click .js_add_cart': '_onClickAddToCart', + 'click .js_remove': '_onRemoveFromRecentlyViewed', + }, + + init(root, options) { + const parent = options.parent || root; + this._super(parent, options); + }, + + start() { + this.add2cartRerender = this.el.dataset.add2cartRerender === 'True'; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Event triggered by a click on the Add to cart button + * + * @param {OdooEvent} ev + */ + async _onClickAddToCart(ev) { + const button = ev.currentTarget + if (!button.dataset.productSelected || button.dataset.isCombo) { + const dummy_form = document.createElement('form'); + dummy_form.setAttribute('method', 'post'); + dummy_form.setAttribute('action', '/shop/cart/update'); + + const inputPT = document.createElement('input'); + inputPT.setAttribute('name', 'product_template_id'); + inputPT.setAttribute('type', 'hidden'); + inputPT.setAttribute('value', button.dataset.productTemplateId); + dummy_form.appendChild(inputPT); + + const inputPP = document.createElement('input'); + inputPP.setAttribute('name', 'product_id'); + inputPP.setAttribute('type', 'hidden'); + inputPP.setAttribute('value', button.dataset.productId); + dummy_form.appendChild(inputPP); + + return this._handleAdd($(dummy_form)); // existing logic expects jquery form + } + else { + const data = await rpc("/shop/cart/update_json", { + product_id: parseInt(ev.currentTarget.dataset.productId), + add_qty: 1, + display: false, + }); + wSaleUtils.updateCartNavBar(data); + wSaleUtils.showCartNotification(this.call.bind(this), data.notification_info); + } + if (this.add2cartRerender) { + this.trigger_up('widgets_start_request', { + $target: this.$el.closest('.s_dynamic'), + }); + } + }, + /** + * Event triggered by a click on the remove button on a "recently viewed" + * template. + * + * @param {OdooEvent} ev + */ + async _onRemoveFromRecentlyViewed(ev) { + const rpcParams = {} + if (ev.currentTarget.dataset.productSelected) { + rpcParams.product_id = ev.currentTarget.dataset.productId; + } else { + rpcParams.product_template_id = ev.currentTarget.dataset.productTemplateId; + } + await rpc("/shop/products/recently_viewed_delete", rpcParams); + this.trigger_up('widgets_start_request', { + $target: this.$el.closest('.s_dynamic'), + }); + }, +}); + +publicWidget.registry.dynamic_snippet_products_cta = DynamicSnippetProductsCard; diff --git a/addons/website_sale/static/src/snippets/s_popup/000.js b/addons/website_sale/static/src/snippets/s_popup/000.js deleted file mode 100644 index 6a6fc85cb234f..0000000000000 --- a/addons/website_sale/static/src/snippets/s_popup/000.js +++ /dev/null @@ -1,23 +0,0 @@ -import PopupWidget from '@website/snippets/s_popup/000'; - -PopupWidget.include({ - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Checks if the given primary button should allow or not to close the - * modal. - * - * @override - */ - _canBtnPrimaryClosePopup(primaryBtnEl) { - return ( - this._super(...arguments) - && !primaryBtnEl.classList.contains("js_add_cart") - ); - }, -}); - -export default PopupWidget; diff --git a/addons/website_sale/static/tests/interactions/popup.test.js b/addons/website_sale/static/tests/interactions/popup.test.js new file mode 100644 index 0000000000000..7e2c8531ee8fc --- /dev/null +++ b/addons/website_sale/static/tests/interactions/popup.test.js @@ -0,0 +1,44 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click, tick } from "@odoo/hoot-dom"; +import { defineStyle } from "@web/../tests/web_test_helpers"; +import { setupInteractionWhiteList, startInteractions } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website.popup"); +describe.current.tags("interaction_dev"); + +test("click on primary button which is add to cart button doesn't close popup", async () => { + defineStyle("* { transition: none !important; }"); + const { core } = await startInteractions(` +
+ +
+ `); + expect(core.interactions).toHaveLength(1); + const modal = "#sPopup .modal"; + await tick(); + await animationFrame(); + expect(modal).toBeVisible(); + await tick(); + await click(".btn-primary"); + expect(modal).toBeVisible(); +}); diff --git a/addons/website_sale/static/tests/interactions/snippets/dynamic_snippet_products.test.js b/addons/website_sale/static/tests/interactions/snippets/dynamic_snippet_products.test.js new file mode 100644 index 0000000000000..d7f13b8faa34d --- /dev/null +++ b/addons/website_sale/static/tests/interactions/snippets/dynamic_snippet_products.test.js @@ -0,0 +1,117 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { + onRpc, +} from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; + +class TestItem extends Interaction { + static selector = ".s_test_item"; + dynamicContent = { + "_root": { + "t-att-data-started": (el) => `*${el.dataset.testParam}*`, + }, + }; +} +registry.category("public.interactions").add("website_sale.test_dynamic_carousel_products_item", TestItem); + +setupInteractionWhiteList(["website_sale.dynamic_snippet_products", "website_sale.test_dynamic_carousel_products_item"]); +describe.current.tags("interaction_dev"); + +test.tags("desktop")("dynamic snippet products loads items and displays them through template", async () => { + document.querySelector("html").dataset.mainObject = "product.public.category(2,)"; + onRpc("/website/snippet/filters", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.filter_id).toBe(3); + expect(json.params.template_key).toBe("website_sale.dynamic_filter_template_product_product_borderless_1"); + expect(json.params.limit).toBe(16); + expect(json.params.search_domain).toEqual([[ + "public_categ_ids", + "child_of", + 2, + ]]); + } + return [` +
+ Some test record +
+ `, ` +
+ Another test record +
+ `, ` +
+ Yet another test record +
+ `, ` +
+ Last test record of first page +
+ `, ` +
+ Test record in second page +
+ `]; + }); + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ Your Dynamic Snippet will be displayed here... This message is displayed because you did not provide both a filter and a template to use. +
+
+
+
+
+
+
+
+
+ `); + expect(core.interactions.length).toBe(6); + const contentEl = el.querySelector(".dynamic_snippet_template"); + const carouselEl = contentEl.querySelector(".carousel"); + // Neutralize carousel automatic sliding. + carouselEl.dataset.bsRide = "false"; + const itemEls = carouselEl.querySelectorAll(".s_test_item"); + expect(itemEls[0].dataset.testParam).toBe("test"); + expect(itemEls[1].dataset.testParam).toBe("test2"); + expect(itemEls[2].dataset.testParam).toBe("test3"); + expect(itemEls[3].dataset.testParam).toBe("test4"); + expect(itemEls[4].dataset.testParam).toBe("test5"); + expect(itemEls[3].closest(".carousel-item")).toHaveClass("active"); + expect(itemEls[4].closest(".carousel-item")).not.toHaveClass("active"); + await animationFrame(); + const nextEl = el.querySelector(".carousel-control-next .oi"); + await click(nextEl); + await animationFrame(); + await advanceTime(1000); // Slide duration. + expect(itemEls[3].closest(".carousel-item")).not.toHaveClass("active"); + expect(itemEls[4].closest(".carousel-item")).toHaveClass("active"); + // Make sure element interactions are started. + expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[1].dataset.started).toBe("*test2*"); + expect(itemEls[2].dataset.started).toBe("*test3*"); + expect(itemEls[3].dataset.started).toBe("*test4*"); + expect(itemEls[4].dataset.started).toBe("*test5*"); + core.stopInteractions(); + // Make sure element interactions are stopped. + expect(core.interactions.length).toBe(0); +}); diff --git a/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml b/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml index 3217bddf8e358..1ceef17d23bf9 100644 --- a/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml +++ b/addons/website_sale/views/snippets/s_dynamic_snippet_products.xml @@ -40,10 +40,16 @@ - - Dynamic snippet products 000 JS + + Dynamic snippet products JS web.assets_frontend - website_sale/static/src/snippets/s_dynamic_snippet_products/000.js + website_sale/static/src/snippets/s_dynamic_snippet_products/dynamic_snippet_products.js + + + + Dynamic snippet products product card JS + web.assets_frontend + website_sale/static/src/snippets/s_dynamic_snippet_products/product_card.js diff --git a/addons/website_sale/views/snippets/s_popup.xml b/addons/website_sale/views/snippets/s_popup.xml deleted file mode 100644 index 31c75a0362621..0000000000000 --- a/addons/website_sale/views/snippets/s_popup.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Popup 000 JS Website Sale Override - web.assets_frontend - website_sale/static/src/snippets/s_popup/000.js - - - From 10411d6f53bf7edcaffe38b2e6ae0956e2bb012e Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 16 Dec 2024 16:36:18 +0100 Subject: [PATCH 004/150] fix parallax --- addons/website/static/src/interactions/parallax.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/addons/website/static/src/interactions/parallax.js b/addons/website/static/src/interactions/parallax.js index 0eca82329a72d..c0488156565ae 100644 --- a/addons/website/static/src/interactions/parallax.js +++ b/addons/website/static/src/interactions/parallax.js @@ -44,9 +44,7 @@ class Parallax extends Interaction { } updateBgCSS(options) { - // this.options.wysiwyg?.odooEditor.observerUnactive('updateBgCSS'); Object.assign(this.bgEl.style, options); - // this.options.wysiwyg?.odooEditor.observerActive('updateBgCSS'); } rebuild() { @@ -92,3 +90,9 @@ class Parallax extends Interaction { registry .category("public.interactions") .add("website.parallax", Parallax); + +registry + .category("public.interactions.edit") + .add("website.parallax", { + Interaction: Parallax, + }); From 625fc8592030d0ae07edcc88058554df7618676c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 17 Dec 2024 09:23:40 +0100 Subject: [PATCH 005/150] fix fullscreen height --- addons/website/static/src/interactions/full_screen_height.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website/static/src/interactions/full_screen_height.js b/addons/website/static/src/interactions/full_screen_height.js index 609733c646a6b..29384f5fa54a8 100644 --- a/addons/website/static/src/interactions/full_screen_height.js +++ b/addons/website/static/src/interactions/full_screen_height.js @@ -22,7 +22,7 @@ export class FullScreenHeight extends Interaction { // Only initialize if taller than the ideal height as some extra css // rules may alter the full-screen-height class behavior in some // cases (blog...). - this.isActive = !isVisible(this.el) || (currentHeight > idealHeight); + this.isActive = !isVisible(this.el) || (currentHeight > idealHeight + 1); } computeIdealHeight() { From f675720ffcabad21aae76dbccf10bcbb95b9e84b Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 17 Dec 2024 09:33:26 +0100 Subject: [PATCH 006/150] improve anchor_slide test --- .../tests/interactions/anchor_slide.test.js | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/addons/website/static/tests/interactions/anchor_slide.test.js b/addons/website/static/tests/interactions/anchor_slide.test.js index e3ad2f6f34996..3d0bd46baf849 100644 --- a/addons/website/static/tests/interactions/anchor_slide.test.js +++ b/addons/website/static/tests/interactions/anchor_slide.test.js @@ -21,17 +21,16 @@ test("anchor slide does nothing if there is no href", async () => { test("anchor slide scrolls to targetted location", async () => { const { core, el } = await startInteractions(` -
+
Click here
Tall stuff
Target
`); - expect(core.interactions.length).toBe(1); - const aEl = el.querySelector("a[href]"); const targetEl = el.querySelector("div#target"); + expect(core.interactions.length).toBe(1); expect(isElementInViewport(targetEl)).toBe(false); - click(aEl); + await click("a[href]"); expect(isElementInViewport(targetEl)).toBe(false); await animationFrame(); await advanceTime(500); // Duration defined in AnchorSlide. @@ -40,36 +39,34 @@ test("anchor slide scrolls to targetted location", async () => { test("without anchor slide instantly reach the targetted location", async () => { const { core, el } = await startInteractions(` -
+
Click here
Tall stuff
Target
`); + const targetEl = el.querySelector("div#target"); expect(core.interactions.length).toBe(1); core.stopInteractions(); expect(core.interactions.length).toBe(0); - const aEl = el.querySelector("a[href]"); - const targetEl = el.querySelector("div#target"); expect(isElementInViewport(targetEl)).toBe(false); - click(aEl); + await click("a[href]"); await animationFrame(); expect(isElementInViewport(targetEl)).toBe(true); }); test("anchor slide scrolls to targetted location - with non-ASCII7 characters", async () => { const { core, el } = await startInteractions(` -
+
Click here
Tall stuff
Target
`); - expect(core.interactions.length).toBe(1); - const aEl = el.querySelector("a[href]"); const targetEl = el.querySelector("div.target"); + expect(core.interactions.length).toBe(1); expect(isElementInViewport(targetEl)).toBe(false); - click(aEl); + await click("a[href]"); expect(isElementInViewport(targetEl)).toBe(false); await animationFrame(); await advanceTime(500); // Duration defined in AnchorSlide. From daa5c3ef0504f140554930863e2fe1066e90bb00 Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Tue, 17 Dec 2024 11:22:43 +0100 Subject: [PATCH 007/150] throw error with white-listed interactions not in array --- addons/web/static/tests/public/helpers.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/web/static/tests/public/helpers.js b/addons/web/static/tests/public/helpers.js index 40620fd540032..baf2fba639909 100644 --- a/addons/web/static/tests/public/helpers.js +++ b/addons/web/static/tests/public/helpers.js @@ -7,6 +7,9 @@ const elementRegistry = registry.category("public.interactions"); const content = elementRegistry.content; export function setupInteractionWhiteList(interactions) { + if (arguments.length > 1) { + throw new Error("Multiple white-listed interactions should be listed in an array."); + } if (typeof interactions === "string") { interactions = [interactions]; } From 6704eaf79550deca063f5ca4f0f7666a71837201 Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Tue, 17 Dec 2024 11:49:24 +0100 Subject: [PATCH 008/150] fix dynamicContent doc with proper syntax --- addons/web/static/src/public/interaction.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index d690564dee9b9..1492feff28c23 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -46,9 +46,9 @@ export class Interaction { * * 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}), - * "_root:t-component": () => [Component, { someProp: "value" }], + * ".some-selector": { "t-on-click": (ev) => this.onClick(ev) }, + * ".some-other-selector": { "t-att-class": () => ({ "some-class": true}) }, + * _root: { "t-component": () => [Component, { someProp: "value" }] }, * } * * A selector is either a standard css selector, or a special keyword From 24a9377384c2a6d2bb30aba9ea48f387ab4d4f16 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 17 Dec 2024 10:35:35 +0100 Subject: [PATCH 009/150] do not lose "this" when when using qualifiers --- addons/web/static/src/public/colibri.js | 6 +++--- .../static/tests/public/interaction.test.js | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/addons/web/static/src/public/colibri.js b/addons/web/static/src/public/colibri.js index fd99f7030a1bb..d54373c460c7e 100644 --- a/addons/web/static/src/public/colibri.js +++ b/addons/web/static/src/public/colibri.js @@ -53,11 +53,11 @@ export class Colibri { fn = { prevent: (f) => (ev) => { ev.preventDefault(); - return f(ev); + return f.call(this.interaction, ev); }, stop: (f) => (ev) => { ev.stopPropagation(); - return f(ev); + return f.call(this.interaction, ev); }, capture: (f) => { options ||= {}; @@ -65,7 +65,7 @@ export class Colibri { return f; }, noupdate: (f) => (ev) => { - f(ev); + f.call(this.interaction, ev); return SKIP_IMPLICIT_UPDATE; }, }[groups.suffix](fn); diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index d2245706909d0..8ff4b2893f473 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -731,6 +731,27 @@ describe("using qualifiers", () => { core.interactions[0].interaction.updateContent(); expect("span").toHaveClass("a"); }); + + test("add a listener does not lose 'this' with qualifiers", async () => { + let clicked = false; + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { + "t-on-click.noupdate.stop.prevent": this.doSomething, + }, + }; + doSomething(ev) { + clicked = true; + expect(this).not.toBe(undefined); + expect(this.doSomething).not.toBe(undefined); + } + } + await startInteraction(Test, TemplateTest); + expect(clicked).toBe(false); + await click("span"); + expect(clicked).toBe(true); + }); }); describe("lifecycle", () => { From 0b96800071f08f6bed2387bb5a3ac7edeb535c3a Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 17 Dec 2024 12:12:51 +0100 Subject: [PATCH 010/150] forward arguments of throttled functions --- addons/web/static/src/public/interaction.js | 8 ++++---- addons/web/static/tests/public/interaction.test.js | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index 1492feff28c23..b01d1164a59db 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -231,8 +231,8 @@ export class Interaction { * Throttles a function for animation and makes sure it is cancelled upon destroy. */ throttledForAnimation(fn) { - const throttledFn = throttleForAnimation(() => { - fn.call(this); + const throttledFn = throttleForAnimation((...args) => { + fn.apply(this, args); if (this.isReady) { this.updateContent(); } @@ -242,8 +242,8 @@ export class Interaction { }); return Object.assign( { - [throttledFn.name]: () => { - throttledFn(); + [throttledFn.name]: (...args) => { + throttledFn(...args); return SKIP_IMPLICIT_UPDATE; }, }[throttledFn.name], diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index 8ff4b2893f473..615ff74061888 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -2053,4 +2053,17 @@ describe("throttled_for_animation (2)", () => { await advanceTime(150); expect.verifySteps(["updatecontent", "start"]); }); + + test("throttled_for_animation forwards arguments", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + _root: { "t-on-click": this.throttledForAnimation((ev) => expect.step(ev.type)) }, + }; + } + await startInteraction(Test, TemplateTest); + expect.verifySteps([]); + await click(".test"); + expect.verifySteps(["click"]); + }); }); From 6d5417ad9768ca80d5c4adf014042efe07f854b2 Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Mon, 16 Dec 2024 18:17:50 +0100 Subject: [PATCH 011/150] WebsiteForumShare --- addons/website_forum/__manifest__.py | 2 +- .../src/interactions/website_forum_share.js | 35 ++++++ .../static/src/js/website_forum.share.js | 65 ---------- .../interactions/website_forum_share.test.js | 115 ++++++++++++++++++ 4 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 addons/website_forum/static/src/interactions/website_forum_share.js delete mode 100644 addons/website_forum/static/src/js/website_forum.share.js create mode 100644 addons/website_forum/static/tests/interactions/website_forum_share.test.js diff --git a/addons/website_forum/__manifest__.py b/addons/website_forum/__manifest__.py index b874362ddf874..81183e03d813f 100644 --- a/addons/website_forum/__manifest__.py +++ b/addons/website_forum/__manifest__.py @@ -74,7 +74,6 @@ 'website_forum/static/src/interactions/**/*', 'website_forum/static/src/js/tours/website_forum.js', 'website_forum/static/src/scss/website_forum.scss', - 'website_forum/static/src/js/website_forum.share.js', 'website_forum/static/src/xml/public_templates.xml', 'website_forum/static/src/xml/website_forum_tags_wrapper.xml', 'website_forum/static/src/components/flag_mark_as_offensive/**/*', @@ -84,6 +83,7 @@ 'website_forum/static/tests/interactions/**/*', ], 'web.assets_unit_tests_setup': [ + 'website_forum/static/src/interactions/website_forum_share.js', 'website_forum/static/src/interactions/website_forum_spam.js', 'website_forum/static/src/xml/public_templates.xml', ], diff --git a/addons/website_forum/static/src/interactions/website_forum_share.js b/addons/website_forum/static/src/interactions/website_forum_share.js new file mode 100644 index 0000000000000..3f422aa467441 --- /dev/null +++ b/addons/website_forum/static/src/interactions/website_forum_share.js @@ -0,0 +1,35 @@ +import { registry } from "@web/core/registry"; +import { renderToElement } from "@web/core/utils/render"; +import { Interaction } from "@web/public/interaction"; + +class WebsiteForumShare extends Interaction { + static selector = ".website_forum"; + + start() { + // Retrieve stored social data + if (sessionStorage.getItem("social_share")) { + const socialData = JSON.parse(sessionStorage.getItem("social_share")); + + if (socialData.targetType) { + const questionEl = document.querySelector(".o_wforum_question"); + const modalEl = renderToElement("website.social_modal", { + target_type: socialData.targetType, + state: questionEl.dataset.state, + }); + this.addListener(modalEl, "hidden.bs.modal", modalEl.remove); + this.insert(modalEl, document.body); + + if (modalEl.querySelector(".s_share")) { + this.services["public.interactions"].startInteractions(modalEl.querySelector(".s_share")); + } + const bsModal = window.Modal.getOrCreateInstance(document.querySelector("#oe_social_share_modal")); + bsModal.show(); + this.registerCleanup(() => bsModal.dispose()); + } + + sessionStorage.removeItem("social_share"); + } + } +} + +registry.category("public.interactions").add("website_forum.website_forum_share", WebsiteForumShare); diff --git a/addons/website_forum/static/src/js/website_forum.share.js b/addons/website_forum/static/src/js/website_forum.share.js deleted file mode 100644 index 991a8324a261b..0000000000000 --- a/addons/website_forum/static/src/js/website_forum.share.js +++ /dev/null @@ -1,65 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; -import "@website/js/content/snippets.animation"; -import { renderToElement } from "@web/core/utils/render"; - -const ForumShare = publicWidget.Widget.extend({ - selector: '', - events: {}, - - /** - * @override - * @param {Object} parent - * @param {Object} options - * @param {string} targetType - */ - init: function (parent, options, targetType) { - this._super.apply(this, arguments); - this.targetType = targetType; - }, - /** - * @override - */ - start: function () { - var def = this._super.apply(this, arguments); - var $question = this.$('article.question'); - if (this.targetType) { - const modalEl = renderToElement("website.social_modal", { - target_type: this.targetType, - state: $question.data('state'), - }); - // Remove modal from DOM once it's closed. - modalEl.addEventListener("hidden.bs.modal", () => { - modalEl.remove(); - }); - this.el.appendChild(modalEl); - this.trigger_up('widgets_start_request', { - editableMode: false, - $target: $(modalEl.querySelector(".s_share")), - }); - $('#oe_social_share_modal').modal('show'); - } - return def; - }, -}); - -publicWidget.registry.websiteForumShare = publicWidget.Widget.extend({ - selector: '.website_forum', - - /** - * @override - */ - start: function () { - // Retrieve stored social data - if (sessionStorage.getItem('social_share')) { - var socialData = JSON.parse(sessionStorage.getItem('social_share')); - // Dummy div to attach the ForumShare publicwidget - const divEl = document.createElement("div"); - divEl.classList.add("social-modal"); - document.body.appendChild(divEl); - (new ForumShare(this, false, socialData.targetType)).attachTo(divEl); - sessionStorage.removeItem('social_share'); - } - - return this._super.apply(this, arguments); - }, -}); diff --git a/addons/website_forum/static/tests/interactions/website_forum_share.test.js b/addons/website_forum/static/tests/interactions/website_forum_share.test.js new file mode 100644 index 0000000000000..4fb1fbea37cd1 --- /dev/null +++ b/addons/website_forum/static/tests/interactions/website_forum_share.test.js @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from "@odoo/hoot"; +import { animationFrame, tick } from "@odoo/hoot-dom"; +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { defineStyle } from "@web/../tests/web_test_helpers"; + +setupInteractionWhiteList(["website_forum.website_forum_share", "website.share"]); +describe.current.tags("interaction_dev"); + +function removeTransitions() { + defineStyle(`* { transition: none !important; }`); +} + +beforeEach(removeTransitions); +afterEach(() => { + document.body.querySelector("#oe_social_share_modal")?.remove(); +}); + +test("sessionStorage social_share is cleared after start", async () => { + sessionStorage.setItem("social_share", JSON.stringify({ + targetType: "answer", + })); + expect(sessionStorage.getItem("social_share")).toEqual('{"targetType":"answer"}'); + const { core } = await startInteractions(` +
+
+
+ `); + expect(sessionStorage.getItem("social_share")).toBe(null); + core.stopInteractions(); +}); + + +describe("target types", () => { + test("target type answer shows modal with website_forum.social_message_answer", async () => { + sessionStorage.setItem("social_share", JSON.stringify({ + targetType: "answer", + })); + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions).toHaveLength(2); + await tick(); + await animationFrame(); + expect(el.ownerDocument.body.querySelector(".modal")).toBeVisible(); + expect(el.ownerDocument.body.querySelector(".modal p")).toHaveText(/^By sharing you answer, you will get additional/); + }); + + test("target type answer shows modal with website_forum.social_message_question", async () => { + sessionStorage.setItem("social_share", JSON.stringify({ + targetType: "question", + })); + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions).toHaveLength(2); + await tick(); + await animationFrame(); + expect(el.ownerDocument.body.querySelector(".modal")).toBeVisible(); + expect(el.ownerDocument.body.querySelector(".modal p")).toHaveText(/^On average,/); + }); + + test("target type answer shows modal with website_forum.social_message_default", async () => { + sessionStorage.setItem("social_share", JSON.stringify({ + targetType: "default", + })); + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions).toHaveLength(2); + await tick(); + await animationFrame(); + expect(el.ownerDocument.body.querySelector(".modal")).toBeVisible(); + expect(el.ownerDocument.body.querySelector(".modal p")).toHaveText(/^Share this content to increase your chances/); + }); +}); + +describe("forum share state", () => { + test("pending state doesn't show .s_share", async () => { + sessionStorage.setItem("social_share", JSON.stringify({ + targetType: "answer", + })); + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions).toHaveLength(1); + await tick(); + await animationFrame(); + expect(el.ownerDocument.body.querySelector(".modal")).toBeVisible(); + expect(el.ownerDocument.body.querySelector(".modal .s_share")).toBe(null); + }); + + test("active state shows .s_share", async () => { + sessionStorage.setItem("social_share", JSON.stringify({ + targetType: "answer", + })); + const { core, el } = await startInteractions(` +
+
+
+ `); + expect(core.interactions).toHaveLength(2); + await tick(); + await animationFrame(); + expect(el.ownerDocument.body.querySelector(".modal")).toBeVisible(); + expect(el.ownerDocument.body.querySelector(".modal .s_share")).toBeVisible(); + }); +}); From 34865ba039db2d17cdd6d57206de862a0a414797 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 16 Dec 2024 16:21:46 +0100 Subject: [PATCH 012/150] fix onWillStart issue in website_sale by converting carousel_product --- .../tests/interactions/snippets/form.test.js | 2 +- .../src/interactions/carousel_product.js | 118 +++++++++++++++ .../static/src/js/website_sale.js | 108 -------------- .../interactions/carousel_product.test.js | 134 ++++++++++++++++++ 4 files changed, 253 insertions(+), 109 deletions(-) create mode 100644 addons/website_sale/static/src/interactions/carousel_product.js create mode 100644 addons/website_sale/static/tests/interactions/carousel_product.test.js diff --git a/addons/website/static/tests/interactions/snippets/form.test.js b/addons/website/static/tests/interactions/snippets/form.test.js index e982aa544aba5..c9c68bcbea90e 100644 --- a/addons/website/static/tests/interactions/snippets/form.test.js +++ b/addons/website/static/tests/interactions/snippets/form.test.js @@ -7,7 +7,7 @@ import { setupInteractionWhiteList, } from "@web/../tests/public/helpers"; -setupInteractionWhiteList("website.form", "website.post_link"); +setupInteractionWhiteList(["website.form", "website.post_link"]); describe.current.tags("interaction_dev"); function field(inputEl) { diff --git a/addons/website_sale/static/src/interactions/carousel_product.js b/addons/website_sale/static/src/interactions/carousel_product.js new file mode 100644 index 0000000000000..b8dda97736216 --- /dev/null +++ b/addons/website_sale/static/src/interactions/carousel_product.js @@ -0,0 +1,118 @@ +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; +import { SIZES, utils as uiUtils } from "@web/core/ui/ui_service"; + +export class CarouselProduct extends Interaction { + static selector = "#o-carousel-product"; + dynamicContent = { + _root: { + "t-on-slide.bs.carousel.noupdate": this.onSlideCarouselProduct, + "t-att-style": () => ({ + "top": this.top, + }), + }, + _window: { + "t-on-resize.noupdate": this.throttledForAnimation(this.onSlideCarouselProduct), + }, + ".carousel-indicators": { + "t-att-style": () => ({ + "justify-content": this.indicatorJustify, + }), + }, + ".o_carousel_product_indicators": { + "t-on-wheel.prevent": this.onMouseWheel, + }, + }; + + setup() { + this.top = undefined; + this.indicatorJustify = "start"; + } + start() { + this.updateCarouselPosition(); + this.registerCleanup(this.services.website_menus.registerCallback(this.updateCarouselPosition.bind(this))); + if (this.el.querySelector(".carousel-indicators")) { + this.updateJustifyContent(); + } + } + + updateCarouselPosition() { + let size = 5; + for (const el of document.querySelectorAll(".o_top_fixed_element")) { + const style = window.getComputedStyle(el); + size += el.getBoundingClientRect().height + parseFloat(el.marginTop) + parseFloat(el.marginBottom); + } + this.top = size; + } + + /** + * Center the selected indicator to scroll the indicators list when it + * overflows. + * + * @param {Event} ev + */ + onSlideCarouselProduct(ev) { + const isReversed = this.el.style["flex-direction"] === "column-reverse"; + const isLeftIndicators = this.el.classList.contains("o_carousel_product_left_indicators"); + const indicatorsDivEl = this.el.querySelector(isLeftIndicators ? ".o_carousel_product_indicators" : ".carousel-indicators"); + if (!indicatorsDivEl) { + return; + } + const isVertical = isLeftIndicators && !isReversed; + const currentIndicatorEl = ev?.relatedTarget || this.el.querySelector("li.active"); + let indicatorIndex = currentIndicatorEl ? [...currentIndicatorEl.parentElement.children].findIndex(el => el === currentIndicatorEl) : -1; + const indicatorEl = indicatorsDivEl.querySelector(`[data-bs-slide-to="${indicatorIndex}"]`); + const indicatorsDivRect = indicatorsDivEl.getBoundingClientRect(); + const indicatorsDivStyle = window.getComputedStyle(indicatorsDivEl); + const indicatorsDivSize = isVertical ? indicatorsDivRect.height + parseFloat(indicatorsDivStyle.marginTop) + parseFloat(indicatorsDivStyle.marginBottom) : indicatorsDivRect.width + parseFloat(indicatorsDivStyle.marginLeft) + parseFloat(indicatorsDivStyle.marginRight); + const indicatorRect = indicatorEl.getBoundingClientRect(); + const indicatorStyle = window.getComputedStyle(indicatorEl); + const indicatorSize = isVertical ? indicatorRect.height : indicatorRect.width; + const indicatorPosition = isVertical ? indicatorRect.top - indicatorsDivRect.top - parseFloat(indicatorStyle.marginTop) : indicatorRect.left - indicatorsDivRect.left - parseFloat(indicatorStyle.marginLeft); + const scrollSize = isVertical ? indicatorsDivEl.scrollHeight : indicatorsDivEl.scrollWidth; + let indicatorsPositionDiff = (indicatorPosition + (indicatorSize/2)) - (indicatorsDivSize/2); + indicatorsPositionDiff = Math.min(indicatorsPositionDiff, scrollSize - indicatorsDivSize); + this.updateJustifyContent(); + this.updateContent(); + const indicatorsPositionX = isVertical ? "0" : "-" + indicatorsPositionDiff; + const indicatorsPositionY = isVertical ? "-" + indicatorsPositionDiff : "0"; + const translate3D = indicatorsPositionDiff > 0 ? "translate3d(" + indicatorsPositionX + "px," + indicatorsPositionY + "px,0)" : ""; + indicatorsDivEl.style.setProperty("transform", translate3D); + } + updateJustifyContent() { + this.indicatorJustify = "start"; + if (uiUtils.getSize() <= SIZES.MD) { + const indicatorsDivEl = this.el.querySelector(".carousel-indicators"); + const indicatorsDivRect = indicatorsDivEl.getBoundingClientRect(); + const lastIndicatorEl = indicatorsDivEl.children[indicatorsDivEl.children.length - 1]; + const lastIndicatorRect = lastIndicatorEl.getBoundingClientRect(); + const lastIndicatorStyle = window.getComputedStyle(lastIndicatorEl); + const firstLiEl = indicatorsDivEl.querySelector("li"); + const firstLiRect = firstLiEl.getBoundingClientRect(); + if ((lastIndicatorRect.left - indicatorsDivRect.left - parseFloat(lastIndicatorStyle.marginLeft) + firstLiRect.width) < indicatorsDivRect.width) { + this.indicatorJustify = "center"; + } + } + } + /** + * @param {Event} ev + */ + onMouseWheel(ev) { + const bsCarousel = window.Carousel.getOrCreateInstance(this.el); + if (ev.deltaY > 0) { + bsCarousel.next(); + } else { + bsCarousel.prev(); + } + } +} + +registry + .category("public.interactions") + .add("website_sale.carousel_product", CarouselProduct); + +registry + .category("public.interactions.edit") + .add("website_sale.carousel_product", { + Interaction: CarouselProduct, + }); diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index cc68078fe3c75..9e855a63910f7 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -636,114 +636,6 @@ publicWidget.registry.WebsiteSaleAccordionProduct = publicWidget.Widget.extend({ }, }); -publicWidget.registry.websiteSaleCarouselProduct = publicWidget.Widget.extend({ - selector: '#o-carousel-product', - disabledInEditableMode: false, - events: { - 'wheel .o_carousel_product_indicators': '_onMouseWheel', - }, - - init() { - this.website_menus = this.bindService("website_menus"); - }, - - /** - * @override - */ - async start() { - await this._super(...arguments); - this._updateCarouselPosition(); - this.throttleOnResize = throttleForAnimation(this._onSlideCarouselProduct.bind(this)); - this.website_menus.registerCallback(this._updateCarouselPosition.bind(this)); - if (this.$el.find('.carousel-indicators').length > 0) { - this.$el.on('slide.bs.carousel.carousel_product_slider', this._onSlideCarouselProduct.bind(this)); - $(window).on('resize.carousel_product_slider', this.throttleOnResize); - this._updateJustifyContent(); - } - }, - /** - * @override - */ - destroy() { - this.$el.css('top', ''); - this.$el.off('.carousel_product_slider'); - if (this.throttleOnResize) { - this.throttleOnResize.cancel(); - } - this._super(...arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _updateCarouselPosition() { - let size = 5; - for (const el of document.querySelectorAll('.o_top_fixed_element')) { - size += $(el).outerHeight(); - } - this.$el.css('top', size); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Center the selected indicator to scroll the indicators list when it - * overflows. - * - * @private - * @param {Event} ev - */ - _onSlideCarouselProduct: function (ev) { - const isReversed = this.$el.css('flex-direction') === "column-reverse"; - const isLeftIndicators = this.$el.hasClass('o_carousel_product_left_indicators'); - const $indicatorsDiv = isLeftIndicators ? this.$el.find('.o_carousel_product_indicators') : this.$el.find('.carousel-indicators'); - let indicatorIndex = $(ev.relatedTarget).index(); - indicatorIndex = indicatorIndex > -1 ? indicatorIndex : this.$el.find('li.active').index(); - const $indicator = $indicatorsDiv.find('[data-bs-slide-to=' + indicatorIndex + ']'); - const indicatorsDivSize = isLeftIndicators && !isReversed ? $indicatorsDiv.outerHeight() : $indicatorsDiv.outerWidth(); - const indicatorSize = isLeftIndicators && !isReversed ? $indicator.outerHeight() : $indicator.outerWidth(); - const indicatorPosition = isLeftIndicators && !isReversed ? $indicator.position().top : $indicator.position().left; - const scrollSize = isLeftIndicators && !isReversed ? $indicatorsDiv[0].scrollHeight : $indicatorsDiv[0].scrollWidth; - let indicatorsPositionDiff = (indicatorPosition + (indicatorSize/2)) - (indicatorsDivSize/2); - indicatorsPositionDiff = Math.min(indicatorsPositionDiff, scrollSize - indicatorsDivSize); - this._updateJustifyContent(); - const indicatorsPositionX = isLeftIndicators && !isReversed ? '0' : '-' + indicatorsPositionDiff; - const indicatorsPositionY = isLeftIndicators && !isReversed ? '-' + indicatorsPositionDiff : '0'; - const translate3D = indicatorsPositionDiff > 0 ? "translate3d(" + indicatorsPositionX + "px," + indicatorsPositionY + "px,0)" : ''; - $indicatorsDiv.css("transform", translate3D); - }, - /** - * @private - */ - _updateJustifyContent: function () { - const $indicatorsDiv = this.$el.find('.carousel-indicators'); - $indicatorsDiv.css('justify-content', 'start'); - if (uiUtils.getSize() <= SIZES.MD) { - if (($indicatorsDiv.children().last().position().left + this.$el.find('li').outerWidth()) < $indicatorsDiv.outerWidth()) { - $indicatorsDiv.css('justify-content', 'center'); - } - } - }, - /** - * @private - * @param {Event} ev - */ - _onMouseWheel: function (ev) { - ev.preventDefault(); - if (ev.originalEvent.deltaY > 0) { - this.$el.carousel('next'); - } else { - this.$el.carousel('prev'); - } - }, -}); - publicWidget.registry.websiteSaleProductPageReviews = publicWidget.Widget.extend({ selector: '#o_product_page_reviews', disabledInEditableMode: false, diff --git a/addons/website_sale/static/tests/interactions/carousel_product.test.js b/addons/website_sale/static/tests/interactions/carousel_product.test.js new file mode 100644 index 0000000000000..840c2e5b4a072 --- /dev/null +++ b/addons/website_sale/static/tests/interactions/carousel_product.test.js @@ -0,0 +1,134 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { defineStyle } from "@web/../tests/web_test_helpers"; +import { setupInteractionWhiteList, startInteractions } from "@web/../tests/public/helpers"; + +setupInteractionWhiteList(["website_sale.carousel_product"]); +describe.current.tags("interaction_dev"); + +test("scroll miniatures", async () => { + defineStyle("li { min-width: 64px !important; }"); + const { core, el } = await startInteractions(` +
+ +
+ `); + expect(core.interactions).toHaveLength(1); + const olEl = el.querySelector(".carousel-indicators"); + expect(olEl.style.transform).toBe(""); + await click(`[data-bs-slide-to="15"]`); + await animationFrame(); + expect(olEl.style.transform).toMatch(/translate3d(.*px, 0px, 0px)/); +}); From f07aeafea9ddf7cbf9e284f264b436e46cfe1eea Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 17 Dec 2024 13:29:45 +0100 Subject: [PATCH 013/150] remove console log --- .../static/tests/interactions/snippets/faq_horizontal.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js b/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js index e4649f7eee14c..315226efe20c3 100644 --- a/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js +++ b/addons/website/static/tests/interactions/snippets/faq_horizontal.test.js @@ -172,7 +172,6 @@ test.tags("desktop")("faq_horizontal updates titles position with a o_header_fad expect(core.interactions.length).toBe(2); const wrapwrap = el.querySelector("#wrapwrap"); const title = el.querySelector(".s_faq_horizontal_entry_title"); - console.log(wrapwrap.getBoundingClientRect().top); await setupTest(core, wrapwrap); // Since the header does not move in Hoot, we have to take into // account the scroll in the test when checking where the bottom From 12311bd439f98e06861a22306a00c59fc3d8fb9e Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 17 Dec 2024 16:04:23 +0100 Subject: [PATCH 014/150] WebsiteEventMeetingRoom & WebsiteEventCreateMeetingRoom --- addons/website_event_meet/__manifest__.py | 10 +- .../website_event_create_meeting_room.js | 34 +++ .../website_event_meeting_room.js | 68 +++++ ...ebsite_event_create_meeting_room_button.js | 50 ---- .../src/js/website_event_meeting_room.js | 100 ------- .../website_event_create_meeting_room.test.js | 252 +++++++++++++++++ .../website_event_meeting_room.test.js | 258 ++++++++++++++++++ 7 files changed, 620 insertions(+), 152 deletions(-) create mode 100644 addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js create mode 100644 addons/website_event_meet/static/src/interactions/website_event_meeting_room.js delete mode 100644 addons/website_event_meet/static/src/js/website_event_create_meeting_room_button.js delete mode 100644 addons/website_event_meet/static/src/js/website_event_meeting_room.js create mode 100644 addons/website_event_meet/static/tests/interactions/website_event_create_meeting_room.test.js create mode 100644 addons/website_event_meet/static/tests/interactions/website_event_meeting_room.test.js diff --git a/addons/website_event_meet/__manifest__.py b/addons/website_event_meet/__manifest__.py index a791f56b01847..0d62f8f6270d1 100644 --- a/addons/website_event_meet/__manifest__.py +++ b/addons/website_event_meet/__manifest__.py @@ -27,13 +27,19 @@ 'assets': { 'web.assets_frontend': [ 'website_event_meet/static/src/scss/event_meet_templates.scss', - 'website_event_meet/static/src/js/website_event_meeting_room.js', - 'website_event_meet/static/src/js/website_event_create_meeting_room_button.js', + 'website_event_meet/static/src/interactions/**/*.js', 'website_event_meet/static/src/xml/website_event_meeting_room.xml', ], 'website.assets_wysiwyg': [ 'website_event_meet/static/src/js/snippets/options.js', ], + 'web.assets_unit_tests': [ + 'website_event_meet/static/tests/interactions/**/*', + ], + 'web.assets_unit_tests_setup': [ + 'website_event_meet/static/src/interactions/**/*.js', + 'website_event_meet/static/src/xml/website_event_meeting_room.xml', + ], }, 'license': 'LGPL-3', } diff --git a/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js b/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js new file mode 100644 index 0000000000000..1f4dee7eb926d --- /dev/null +++ b/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js @@ -0,0 +1,34 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { renderToElement } from "@web/core/utils/render"; +import { rpc } from "@web/core/network/rpc"; + +export class WebsiteEventCreateMeetingRoom extends Interaction { + static selector = ".o_wevent_create_room_button"; + dynamicContent = { + "_root": { "t-on-click": this.onClick } + } + + async onClick() { + if (!this.createModalEl) { + const langs = await this.waitFor(rpc("/event/active_langs")); + if (langs) { + this.createModalEl = renderToElement("event_meet_create_room_modal", { + csrf_token: odoo.csrf_token, + eventId: this.el.dataset.eventId, + defaultLangCode: this.el.dataset.defaultLangCode, + langs: langs, + }); + this.insert(this.createModalEl, this.el, "afterend"); + } + } + if (this.createModalEl) { + Modal.getOrCreateInstance(this.createModalEl).show(); + } + } +} + +registry + .category("public.interactions") + .add("website_event_meet.website_event_create_meeting_room", WebsiteEventCreateMeetingRoom); diff --git a/addons/website_event_meet/static/src/interactions/website_event_meeting_room.js b/addons/website_event_meet/static/src/interactions/website_event_meeting_room.js new file mode 100644 index 0000000000000..50694b3371e49 --- /dev/null +++ b/addons/website_event_meet/static/src/interactions/website_event_meeting_room.js @@ -0,0 +1,68 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { user } from "@web/core/user"; +import { _t } from "@web/core/l10n/translation"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +export class WebsiteEventMeetingRoom extends Interaction { + static selector = ".o_wevent_meeting_room_card"; + dynamicContent = { + '.o_wevent_meeting_room_delete': { "t-on-click.prevent.stop": this.onClickDelete }, + '.o_wevent_meeting_room_duplicate': { "t-on-click.prevent.stop": this.onClickDuplicate }, + '.o_wevent_meeting_room_is_pinned': { "t-on-click.prevent.stop": this.onClickIsPinned }, + } + + setup() { + this.meetingRoomId = parseInt(this.el.dataset["meetingRoomId"]); + } + + onClickDelete() { + this.services.dialog.add(ConfirmationDialog, { + body: _t("Are you sure you want to close this room?"), + confirm: async () => { + await this.waitFor(this.services.orm.write( + "event.meeting.room", + [this.meetingRoomId], + { is_published: false }, + { context: user.context } // this.services.user.context + )); + + // remove the element so we do not need to refresh the page + this.el.remove(); + }, + }) + } + + onClickDuplicate() { + this.services.dialog.add(ConfirmationDialog, { + body: _t("Are you sure you want to duplicate this room?"), + confirm: async () => { + await this.waitFor(this.services.orm.call("event.meeting.room", "copy", [this.meetingRoomId], { + context: user.context, + })); + + window.location.reload(); + }, + }); + } + + async onClickIsPinned(ev) { + const target = ev.currentTarget + const pinnedButtonClass = "o_wevent_meeting_room_pinned"; + const isPinned = ev.currentTarget.classList.contains(pinnedButtonClass); + + await this.waitFor(this.services.orm.write( + "event.meeting.room", + [this.meetingRoomId], + { is_pinned: !isPinned }, + { context: user.context } + )); + + target.classList.toggle(pinnedButtonClass, !isPinned); + } +} + +registry + .category("public.interactions") + .add("website_event_meet.website_event_meeting_room", WebsiteEventMeetingRoom); diff --git a/addons/website_event_meet/static/src/js/website_event_create_meeting_room_button.js b/addons/website_event_meet/static/src/js/website_event_create_meeting_room_button.js deleted file mode 100644 index 4fddefb4c5c77..0000000000000 --- a/addons/website_event_meet/static/src/js/website_event_create_meeting_room_button.js +++ /dev/null @@ -1,50 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; -import { rpc } from "@web/core/network/rpc"; -import { renderToElement } from "@web/core/utils/render"; - -publicWidget.registry.websiteEventCreateMeetingRoom = publicWidget.Widget.extend({ - selector: '.o_wevent_create_room_button', - events: { - 'click': '_onClickCreate', - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - _onClickCreate: async function () { - if (!this.createModalEl) { - const langs = await rpc("/event/active_langs"); - - this.createModalEl = renderToElement("event_meet_create_room_modal", { - csrf_token: odoo.csrf_token, - eventId: this.el.dataset.eventId, - defaultLangCode: this.el.dataset.defaultLangCode, - langs: langs, - }); - this.el.parentNode.append(this.createModalEl); - } - - Modal.getOrCreateInstance(this.createModalEl).show(); - }, - - //-------------------------------------------------------------------------- - // Override - //-------------------------------------------------------------------------- - - /** - * Remove the create modal from the DOM, to avoid issue when editing the template - * with the website editor. - * - * @override - */ - destroy: function () { - const modalEl = document.querySelector(".o_wevent_create_meeting_room_modal"); - if (modalEl) { - modalEl.remove(); - } - this._super.apply(this, arguments); - }, -}); - -export default publicWidget.registry.websiteEventMeetingRoom; diff --git a/addons/website_event_meet/static/src/js/website_event_meeting_room.js b/addons/website_event_meet/static/src/js/website_event_meeting_room.js deleted file mode 100644 index c5494c062a455..0000000000000 --- a/addons/website_event_meet/static/src/js/website_event_meeting_room.js +++ /dev/null @@ -1,100 +0,0 @@ -import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { _t } from "@web/core/l10n/translation"; - -publicWidget.registry.websiteEventMeetingRoom = publicWidget.Widget.extend({ - selector: '.o_wevent_meeting_room_card', - events: { - 'click .o_wevent_meeting_room_delete': '_onDeleteClick', - 'click .o_wevent_meeting_room_duplicate': '_onDuplicateClick', - 'click .o_wevent_meeting_room_is_pinned': '_onPinClick', - }, - - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, - - start: function () { - this._super.apply(this, arguments); - this.meetingRoomId = parseInt(this.el.dataset["meetingRoomId"]); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Delete the meeting room. - * - * @private - */ - _onDeleteClick: async function (event) { - event.preventDefault(); - event.stopPropagation(); - - this.call("dialog", "add", ConfirmationDialog, { - body: _t("Are you sure you want to close this room?"), - confirm: async () => { - await this.orm.write( - "event.meeting.room", - [this.meetingRoomId], - { is_published: false }, - { context: this.context } - ); - - // remove the element so we do not need to refresh the page - this.el.remove(); - }, - }); - }, - - /** - * Duplicate the room. - * - * @private - */ - _onDuplicateClick: function (event) { - event.preventDefault(); - event.stopPropagation(); - this.call("dialog", "add", ConfirmationDialog, { - body: _t("Are you sure you want to duplicate this room?"), - confirm: async () => { - await this.orm.call("event.meeting.room", "copy", [this.meetingRoomId], { - context: this.context, - }); - - window.location.reload(); - }, - }); - }, - - /** - * Pin/unpin the room. - * - * @private - */ - _onPinClick: async function (event) { - event.preventDefault(); - event.stopPropagation(); - - const pinnedButtonClass = "o_wevent_meeting_room_pinned"; - const isPinned = event.currentTarget.classList.contains(pinnedButtonClass); - - await this.orm.write( - "event.meeting.room", - [this.meetingRoomId], - { is_pinned: !isPinned }, - { context: this.context } - ); - - // TDE FIXME: addclass ? - if (isPinned) { - event.currentTarget.classList.remove(pinnedButtonClass); - } else { - event.currentTarget.classList.add(pinnedButtonClass); - } - } -}); - -export default publicWidget.registry.websiteEventMeetingRoom; diff --git a/addons/website_event_meet/static/tests/interactions/website_event_create_meeting_room.test.js b/addons/website_event_meet/static/tests/interactions/website_event_create_meeting_room.test.js new file mode 100644 index 0000000000000..436eb3efed196 --- /dev/null +++ b/addons/website_event_meet/static/tests/interactions/website_event_create_meeting_room.test.js @@ -0,0 +1,252 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; + +import { onRpc } from "@web/../tests/web_test_helpers"; + +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website_event_meet.website_event_create_meeting_room"); +describe.current.tags("interaction_dev"); + +const getTemplate = function (options = {}) { + return ` +
+
+
+ +

Choose a topic that interests you and start talking with the community.
Don't forget to setup your camera and microphone.

+
+ + +
+
+
+

Vos meubles préférés ?

+
+

Venez partager vos meubles préférés et l'utilisation que vous en faites.

+
+

+ + 3 + client(s) +

+
French / Français
+
+
+
+
+ + +
+
+ + +
+
Full
+
+
+

Reducing the ecological footprint with wood?

+
+

Share your tips to reduce your ecological footprint using wood.

+
+

+ + 8 + ecologist(s) +

+
English (US)
+
+
+
+
+ + +
+
+ + +
+
+
+

Best wood for furniture

+
+

Let's talk about wood types for furniture

+
+

+ + 9 + wood expert(s) +

+
English (US)
+
+
+
+
+ + +
+
+
+
+
+
+
+

Start a topic

+

Want to create your own discussion room?

+ + Create a Room + +
+
+
+
+
+`} + +test("website_event_create_meeting_room is started when there is an element .o_wevent_create_room_button", async () => { + const { core } = await startInteractions(getTemplate()); + expect(core.interactions.length).toBe(1); +}); + +test("[click] website_event_create_meeting_room open a modal to create a room", async () => { + onRpc("/event/active_langs", async () => { + return [ + [ + "en_US", + "English (US)" + ] + ]; + }); + const { el } = await startInteractions(getTemplate()); + expect(!!el.querySelector(".o_wevent_create_meeting_room_modal")).toBe(false) + await click(".o_wevent_create_room_button"); + expect(!!el.querySelector(".o_wevent_create_meeting_room_modal")).toBe(true) +}); diff --git a/addons/website_event_meet/static/tests/interactions/website_event_meeting_room.test.js b/addons/website_event_meet/static/tests/interactions/website_event_meeting_room.test.js new file mode 100644 index 0000000000000..c1448eaa7ae96 --- /dev/null +++ b/addons/website_event_meet/static/tests/interactions/website_event_meeting_room.test.js @@ -0,0 +1,258 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; +import { Deferred } from "@odoo/hoot-mock"; +import { onRpc } from "@web/../tests/web_test_helpers"; + +import { WebsiteEventMeetingRoom } from "../../src/interactions/website_event_meeting_room"; +import { patchWithCleanup } from "@web/../tests/web_test_helpers"; + +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +setupInteractionWhiteList("website_event_meet.website_event_meeting_room"); +describe.current.tags("interaction_dev"); + +const getTemplate = function (options = {}) { + return ` +
+
+
+ +

Choose a topic that interests you and start talking with the community.
Don't forget to setup your camera and microphone.

+
+ + +
+
+
+

Vos meubles préférés ?

+
+

Venez partager vos meubles préférés et l'utilisation que vous en faites.

+
+

+ + 3 + client(s) +

+
French / Français
+
+
+
+
+ + +
+
+ + +
+
Full
+
+
+

Reducing the ecological footprint with wood?

+
+

Share your tips to reduce your ecological footprint using wood.

+
+

+ + 8 + ecologist(s) +

+
English (US)
+
+
+
+
+ + +
+
+ + +
+
+
+

Best wood for furniture

+
+

Let's talk about wood types for furniture

+
+

+ + 9 + wood expert(s) +

+
English (US)
+
+
+
+
+ + +
+
+
+
+
+
+
+

Start a topic

+

Want to create your own discussion room?

+ + Create a Room + +
+
+
+
+
+`} + +test("website_event_meeting_room is started when there is an element .o_wevent_meeting_room_card", async () => { + const { core } = await startInteractions(getTemplate()); + expect(core.interactions.length).toBe(3); +}); + +test("[click] website_event_meeting_room enable to pin / unpin a room", async () => { + await startInteractions(getTemplate()); + // await click(".o_wevent_meeting_room_is_pinned"); + expect(true).toBe(true); +}); + +test("[click] website_event_meeting_room enable to delete a room", async () => { + await startInteractions(getTemplate()); + // await click(".o_wevent_meeting_room_delete"); + expect(true).toBe(true); +}); + +test("[click] website_event_meeting_room enable to duplicate a room", async () => { + await startInteractions(getTemplate()); + // await click(".o_wevent_meeting_room_duplicate"); + expect(true).toBe(true); +}); From bc1a3aba4733c4f890522efc3ed793e28fc4380a Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Tue, 17 Dec 2024 14:41:05 +0100 Subject: [PATCH 015/150] WebsiteJitsi ChatRoom --- .../website_jitsi/static/src/js/chat_room.js | 207 +++++++++--------- 1 file changed, 98 insertions(+), 109 deletions(-) diff --git a/addons/website_jitsi/static/src/js/chat_room.js b/addons/website_jitsi/static/src/js/chat_room.js index d89cb2ddf968b..7d4eac6f9f46a 100644 --- a/addons/website_jitsi/static/src/js/chat_room.js +++ b/addons/website_jitsi/static/src/js/chat_room.js @@ -1,18 +1,20 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; -import { renderToElement } from "@web/core/utils/render"; -import { utils as uiUtils } from "@web/core/ui/ui_service"; +import { browser } from "@web/core/browser/browser"; import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { utils as uiUtils } from "@web/core/ui/ui_service"; +import { renderToElement } from "@web/core/utils/render"; +import { Interaction } from "@web/public/interaction"; -publicWidget.registry.ChatRoom = publicWidget.Widget.extend({ - selector: '.o_wjitsi_room_widget', - events: { - 'click .o_wjitsi_room_link': '_onChatRoomClick', - }, +class ChatRoom extends Interaction { + static selector = ".o_wjitsi_room_widget"; + dynamicContent = { + ".o_wjitsi_room_link": { "t-on-click": this.onChatRoomClick }, + }; /** * Manage the chat room (Jitsi), update the participant count... * - * The widget takes some options + * The interaction takes some options * - 'room-name', the name of the Jitsi room * - 'chat-room-id', the ID of the `chat.room` record * - 'auto-open', the chat room will be automatically opened when the page is loaded @@ -22,120 +24,114 @@ publicWidget.registry.ChatRoom = publicWidget.Widget.extend({ * - 'default-username': the username to use in the chat room * - 'jitsi-server': the domain name of the Jitsi server to use */ - start: async function () { - await this._super.apply(this, arguments); - this.roomName = this.$el.data('room-name'); - this.chatRoomId = parseInt(this.$el.data('chat-room-id')); + setup() { + this.roomName = this.el.dataset.roomName; + this.chatRoomId = parseInt(this.el.dataset.chatRoomId); // automatically open the current room - this.autoOpen = parseInt(this.$el.data('auto-open') || 0); + this.autoOpen = parseInt(this.el.dataset.autoOpen || 0); // before joining, perform a RPC call to verify that the chat room is not full - this.checkFull = parseInt(this.$el.data('check-full') || 0); + this.checkFull = parseInt(this.el.dataset.checkFull || 0); // query selector of the element on which we attach the Jitsi iframe // if not defined, the widget will pop in a modal instead - this.attachTo = this.$el.data('attach-to') || false; + this.attachTo = this.el.dataset.attachTo || false; // default username for jitsi - this.defaultUsername = this.$el.data('default-username') || false; + this.defaultUsername = this.el.dataset.defaultUsername || false; - this.jitsiServer = this.$el.data('jitsi-server') || 'meet.jit.si'; + // FIXME: 'meet.jit.si' should not be used in production. + this.jitsiServer = this.el.dataset.jitsiServer || 'meet.jit.si'; - this.maxCapacity = parseInt(this.$el.data('max-capacity')) || Infinity; + this.maxCapacity = parseInt(this.el.dataset.maxCapacity) || Infinity; + } + async start() { if (this.autoOpen) { - await this._onChatRoomClick(); + await this.waitFor(this.onChatRoomClick()); } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- + } /** * Click on a chat room to join it. - * - * @private */ - _onChatRoomClick: async function () { + async onChatRoomClick() { if (this.checkFull) { // maybe we didn't refresh the page for a while and so we might join a room // which is full, so we perform a RPC call to verify that we can really join - let isChatRoomFull = await rpc('/jitsi/is_full', { room_name: this.roomName }); + let isChatRoomFull = await this.waitFor(rpc("/jitsi/is_full", { room_name: this.roomName })); if (isChatRoomFull) { - window.location.reload(); + browser.location.reload(); return; } } - if (await this._openMobileApplication(this.roomName)) { + if (this.openMobileApplication(this.roomName)) { // we opened the mobile application return; } - await this._loadJisti(); + await this.waitFor(this.loadJitsi()); if (this.attachTo) { // attach the Jitsi iframe on the given parent node - let $parentNode = $(this.attachTo); - $parentNode.find("iframe").trigger("empty"); - $parentNode.empty(); + const parentNode = document.querySelector(this.attachTo); + parentNode.replaceChildren(); - await this._joinJitsiRoom($parentNode); + await this.waitFor(this.joinJitsiRoom(parentNode)); } else { - // create a model and append the Jitsi iframe in it - let $jitsiModal = $(renderToElement('chat_room_modal', {})); - $("body").append($jitsiModal); - $jitsiModal.modal('show'); + // create a modal and append the Jitsi iframe in it + const jitsiModalEl = renderToElement("chat_room_modal", {}); + this.insert(jitsiModalEl, document.body); + const bsJitsiModal = window.Modal.getOrCreateInstance(jitsiModalEl) + bsJitsiModal.show(); + this.registerCleanup(() => bsJitsiModal.dispose()); - let jitsiRoom = await this._joinJitsiRoom($jitsiModal.find('.modal-body')); + const modalBodyEl = jitsiModalEl.querySelector(".modal-body"); + const jitsiRoomEl = await this.waitFor(this.joinJitsiRoom(modalBodyEl)); // close the modal when hanging up - jitsiRoom.addEventListener('videoConferenceLeft', async () => { - $('.o_wjitsi_room_modal').modal('hide'); + this.addListener(jitsiRoomEl, "videoConferenceLeft", () => { + bsJitsiModal.hide(); }); - // when the modal is closed, delete the Jitsi room object and clear the DOM - $jitsiModal.on('hidden.bs.modal', async () => { - jitsiRoom.dispose(); - $(".o_wjitsi_room_modal").remove(); + // when the modal is closed, delete the Jitsi room object and clear + // the DOM + this.addListener(jitsiModalEl, "hidden.bs.modal", () => { + bsJitsiModal.dispose(); + jitsiModalEl.remove(); }); } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + } /** - * Jitsi do not provide an REST API to get the number of participant in a room. - * The only way to get the number of participant is to be in the room and to use + * Jitsi does not provide a REST API to get the number of participants in a room. + * The only way to get the number of participants is to be in the room and to use * the Javascript API. So, to update the participant count on the server side, - * the participant have to send the count in RPC... + * the participants have to send the count through RPC... * * When leaving a room, the event "participantLeft" is called for the current user * once per participant in the room (like if all other participants were leaving the * room and then the current user himself). * - * "participantLeft" is called only one time for the other participant who are still + * "participantLeft" is called only one time for the other participants who are still * in the room. * - * We can not ask the user who is leaving the room to update the participant count + * We cannot ask the user who is leaving the room to update the participant count * because user might close their browser tab without hanging up (and so without * triggering the event "videoConferenceLeft"). So, we wait for a moment (because the - * event "participantLeft" is called many time for the participant who is leaving) - * and the first participant send the new participant count (so we avoid spamming the + * event "participantLeft" is called many times for the participant who is leaving) + * and the first participant sends the new participant count (so we avoid spamming the * server with HTTP requests). * - * We use "setTimout" to send maximum one HTTP request per interval, even if multiple + * We use "setTimeout" to send maximum one HTTP request per interval, even if multiple * participants join/leave at the same time in the defined interval. * * Update on the 29 June 2020 * - * @private - * @param {jQuery} $jitsiModal, jQuery modal element in which we add the Jitsi room - * @returns {JitsiRoom} the newly created Jitsi room + * @param {HTMLElement} parentNode, parent element in which we add the Jitsi room + * @returns {HTMLElement} the newly created Jitsi room */ - _joinJitsiRoom: async function ($parentNode) { - let jitsiRoom = await this._createJitsiRoom(this.roomName, $parentNode); + async joinJitsiRoom(parentNode) { + const jitsiRoom = await this.waitFor(this.createJitsiRoom(this.roomName, parentNode)); if (this.defaultUsername) { jitsiRoom.executeCommand("displayName", this.defaultUsername); @@ -151,114 +147,107 @@ publicWidget.registry.ChatRoom = publicWidget.Widget.extend({ // (so if 2 participants join/leave in this interval, we will perform only // one HTTP request for both). clearTimeout(timeoutCall); - timeoutCall = setTimeout(() => { + timeoutCall = this.waitForTimeout(() => { this.allParticipantIds = Object.keys(jitsiRoom._participants).sort(); if (this.participantId === this.allParticipantIds[0]) { - // only the first participant of the room send the new participant - // count so we avoid to send to many HTTP requests - this._updateParticipantCount(this.allParticipantIds.length, joined); + // only the first participant of the room sends the new participant + // count so we avoid to send too many HTTP requests + this.updateParticipantCount(this.allParticipantIds.length, joined); } }, timeoutTime); }; - jitsiRoom.addEventListener('participantJoined', () => updateParticipantCount(true)); - jitsiRoom.addEventListener('participantLeft', () => updateParticipantCount(false)); + this.addListener(jitsiRoom, "participantJoined", () => updateParticipantCount(true)); + this.addListener(jitsiRoom, "participantLeft", () => updateParticipantCount(false)); // update the participant count when joining the room - jitsiRoom.addEventListener('videoConferenceJoined', async (event) => { + this.addListener(jitsiRoom, "videoConferenceJoined", (event) => { this.participantId = event.id; updateParticipantCount(true); - $('.o_wjitsi_chat_room_loading').addClass('d-none'); + document.querySelector(".o_wjitsi_chat_room_loading")?.classList.add("d-none"); // recheck if the room is not full if (this.checkFull && this.allParticipantIds.length > this.maxCapacity) { clearTimeout(timeoutCall); - jitsiRoom.executeCommand('hangup'); - window.location.reload(); + jitsiRoom.executeCommand("hangup"); + browser.location.reload(); } }); // update the participant count when using the "Leave" button - jitsiRoom.addEventListener('videoConferenceLeft', async (event) => { + this.addListener(jitsiRoom, "videoConferenceLeft", () => { this.allParticipantIds = Object.keys(jitsiRoom._participants) if (!this.allParticipantIds.length) { // bypass the checks and timer of updateParticipantCount - this._updateParticipantCount(this.allParticipantIds.length, false); + this.updateParticipantCount(this.allParticipantIds.length, false); } }); return jitsiRoom; - }, + } /** * Perform an HTTP request to update the participant count on the server side. * - * @private * @param {integer} count, current number of participant in the room * @param {boolean} joined, true if someone joined the room */ - _updateParticipantCount: async function (count, joined) { - await rpc('/jitsi/update_status', { + async updateParticipantCount(count, joined) { + await this.waitFor(rpc("/jitsi/update_status", { room_name: this.roomName, participant_count: count, joined: joined, - }); - }, - - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + })); + } /** * Redirect on the Jitsi mobile application if we are on mobile. * - * @private * @param {string} roomName * @returns {boolean} true is we were redirected to the mobile application */ - _openMobileApplication: async function (roomName) { + openMobileApplication(roomName) { if (uiUtils.isSmall()) { // we are on mobile, open the room in the application - window.location = `intent://${this.jitsiServer}/${encodeURIComponent(roomName)}#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end`; + browser.location = `intent://${this.jitsiServer}/${encodeURIComponent(roomName)}#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end`; return true; } return false; - }, + } /** * Create a Jitsi room on the given DOM element. * - * @private * @param {string} roomName - * @param {jQuery} $parentNode - * @returns {JitsiRoom} the newly created Jitsi room + * @param {HTMLElement} parentNode + * @returns {HTMLElement} the newly created Jitsi room */ - _createJitsiRoom: async function (roomName, $parentNode) { - await this._loadJisti(); + async createJitsiRoom(roomName, parentNode) { + await this.waitFor(this.loadJitsi()); const options = { roomName: roomName, width: "100%", height: "100%", - parentNode: $parentNode[0], + parentNode: parentNode, configOverwrite: {disableDeepLinking: true}, }; return new window.JitsiMeetExternalAPI(this.jitsiServer, options); - }, + } /** * Load the Jitsi external library if necessary. - * - * @private */ - _loadJisti: async function () { + async loadJitsi() { if (!window.JitsiMeetExternalAPI) { - await $.ajax({ - url: `https://${this.jitsiServer}/external_api.js`, - dataType: "script", - }); + const scriptEl = document.createElement("script"); + scriptEl.setAttribute("src", `https://${this.jitsiServer}/external_api.js`); + this.insert(scriptEl, document.head); + let waitForScriptLoad; + const prom = new Promise(resolve => waitForScriptLoad = () => resolve()); + this.addListener(scriptEl, "load", waitForScriptLoad); + await this.waitFor(prom); } - }, -}); + } +} -export default publicWidget.registry.ChatRoom; +registry.category("public.interactions").add("website_jitsi.chat_room", ChatRoom); From 1a533a480d669ee579cca8594fe3bb5a43f60012 Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Tue, 17 Dec 2024 16:29:47 +0100 Subject: [PATCH 016/150] fix tests forum share --- .../tests/interactions/website_forum_share.test.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/addons/website_forum/static/tests/interactions/website_forum_share.test.js b/addons/website_forum/static/tests/interactions/website_forum_share.test.js index 4fb1fbea37cd1..44fc1fa1e03b2 100644 --- a/addons/website_forum/static/tests/interactions/website_forum_share.test.js +++ b/addons/website_forum/static/tests/interactions/website_forum_share.test.js @@ -6,11 +6,8 @@ import { defineStyle } from "@web/../tests/web_test_helpers"; setupInteractionWhiteList(["website_forum.website_forum_share", "website.share"]); describe.current.tags("interaction_dev"); -function removeTransitions() { - defineStyle(`* { transition: none !important; }`); -} -beforeEach(removeTransitions); +beforeEach(() => defineStyle(`* { transition: none !important; }`)); afterEach(() => { document.body.querySelector("#oe_social_share_modal")?.remove(); }); @@ -20,13 +17,12 @@ test("sessionStorage social_share is cleared after start", async () => { targetType: "answer", })); expect(sessionStorage.getItem("social_share")).toEqual('{"targetType":"answer"}'); - const { core } = await startInteractions(` + await startInteractions(`
`); expect(sessionStorage.getItem("social_share")).toBe(null); - core.stopInteractions(); }); @@ -47,7 +43,7 @@ describe("target types", () => { expect(el.ownerDocument.body.querySelector(".modal p")).toHaveText(/^By sharing you answer, you will get additional/); }); - test("target type answer shows modal with website_forum.social_message_question", async () => { + test("target type question shows modal with website_forum.social_message_question", async () => { sessionStorage.setItem("social_share", JSON.stringify({ targetType: "question", })); @@ -63,7 +59,7 @@ describe("target types", () => { expect(el.ownerDocument.body.querySelector(".modal p")).toHaveText(/^On average,/); }); - test("target type answer shows modal with website_forum.social_message_default", async () => { + test("target type default shows modal with website_forum.social_message_default", async () => { sessionStorage.setItem("social_share", JSON.stringify({ targetType: "default", })); From 19a434d0deb527df4b821743926eb019b9f3c939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Wed, 18 Dec 2024 09:16:38 +0100 Subject: [PATCH 017/150] wip --- addons/website_jitsi/static/src/js/chat_room.js | 2 +- addons/website_sale/static/src/interactions/carousel_product.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website_jitsi/static/src/js/chat_room.js b/addons/website_jitsi/static/src/js/chat_room.js index 7d4eac6f9f46a..35a174e4b4481 100644 --- a/addons/website_jitsi/static/src/js/chat_room.js +++ b/addons/website_jitsi/static/src/js/chat_room.js @@ -189,7 +189,7 @@ class ChatRoom extends Interaction { /** * Perform an HTTP request to update the participant count on the server side. * - * @param {integer} count, current number of participant in the room + * @param {number} count, current number of participant in the room * @param {boolean} joined, true if someone joined the room */ async updateParticipantCount(count, joined) { diff --git a/addons/website_sale/static/src/interactions/carousel_product.js b/addons/website_sale/static/src/interactions/carousel_product.js index b8dda97736216..a9e432131c016 100644 --- a/addons/website_sale/static/src/interactions/carousel_product.js +++ b/addons/website_sale/static/src/interactions/carousel_product.js @@ -40,7 +40,7 @@ export class CarouselProduct extends Interaction { let size = 5; for (const el of document.querySelectorAll(".o_top_fixed_element")) { const style = window.getComputedStyle(el); - size += el.getBoundingClientRect().height + parseFloat(el.marginTop) + parseFloat(el.marginBottom); + size += el.getBoundingClientRect().height + parseFloat(style.marginTop) + parseFloat(style.marginBottom); } this.top = size; } From 631130695fac75047f34045c829601f5b45a0a86 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Wed, 18 Dec 2024 09:44:49 +0100 Subject: [PATCH 018/150] WebsiteProfile & Co --- addons/website_profile/__manifest__.py | 6 +- .../src/interactions/website_profile.js | 27 +++ .../website_profile_editor.edit.js | 14 ++ .../interactions/website_profile_editor.js | 79 +++++++++ .../website_profile_next_rank_card.js | 15 ++ .../static/src/js/website_profile.js | 161 ------------------ 6 files changed, 140 insertions(+), 162 deletions(-) create mode 100644 addons/website_profile/static/src/interactions/website_profile.js create mode 100644 addons/website_profile/static/src/interactions/website_profile_editor.edit.js create mode 100644 addons/website_profile/static/src/interactions/website_profile_editor.js create mode 100644 addons/website_profile/static/src/interactions/website_profile_next_rank_card.js delete mode 100644 addons/website_profile/static/src/js/website_profile.js diff --git a/addons/website_profile/__manifest__.py b/addons/website_profile/__manifest__.py index 3762b0d0cf514..4040d3cc4bcaa 100644 --- a/addons/website_profile/__manifest__.py +++ b/addons/website_profile/__manifest__.py @@ -21,7 +21,11 @@ 'assets': { 'web.assets_frontend': [ 'website_profile/static/src/scss/website_profile.scss', - 'website_profile/static/src/js/website_profile.js', + 'website_profile/static/src/interactions/**/*', + ('remove', 'website_profile/static/src/interactions/**/*.edit.js'), + ], + 'website.assets_edit_frontend': [ + 'website_profile/static/src/interactions/**/*', ], 'web.assets_tests': [ 'website_profile/static/tests/tours/tour_website_profile_description.js', diff --git a/addons/website_profile/static/src/interactions/website_profile.js b/addons/website_profile/static/src/interactions/website_profile.js new file mode 100644 index 0000000000000..2d732a44c06fa --- /dev/null +++ b/addons/website_profile/static/src/interactions/website_profile.js @@ -0,0 +1,27 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { redirect } from "@web/core/utils/urls"; +import { rpc } from "@web/core/network/rpc"; + +export class WebsiteProfile extends Interaction { + static selector = ".o_wprofile_email_validation_container"; + dynamicContent = { + ".send_validation_email": { "t-on-click.prevent": this.onClickSend }, + ".validated_email_close": { "t-on-click": () => rpc("/profile/validate_email/close") }, + }; + + async onClickSend(ev) { + const element = ev.currentTarget; + const data = await this.waitFor(rpc('/profile/send_validation_email', { + redirect_url: element.dataset["redirect_url"], + })); + if (data) { + redirect(element.dataset["redirect_url"]); + } + } +} + +registry + .category("public.interactions") + .add("website_profile.website_profile", WebsiteProfile); diff --git a/addons/website_profile/static/src/interactions/website_profile_editor.edit.js b/addons/website_profile/static/src/interactions/website_profile_editor.edit.js new file mode 100644 index 0000000000000..d90c6401efb93 --- /dev/null +++ b/addons/website_profile/static/src/interactions/website_profile_editor.edit.js @@ -0,0 +1,14 @@ +import { WebsiteProfileEditor } from "./website_profile_editor"; +import { registry } from "@web/core/registry"; + +const WebsiteProfileEditorEdit = I => class extends I { + setup() { } + async willStart() { } +}; + +registry + .category("public.interactions.edit") + .add("website_profile.website_profile_editor", { + Interaction: WebsiteProfileEditor, + mixin: WebsiteProfileEditorEdit, + }); diff --git a/addons/website_profile/static/src/interactions/website_profile_editor.js b/addons/website_profile/static/src/interactions/website_profile_editor.js new file mode 100644 index 0000000000000..4be657aec4329 --- /dev/null +++ b/addons/website_profile/static/src/interactions/website_profile_editor.js @@ -0,0 +1,79 @@ + +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea"; + +export class WebsiteProfileEditor extends Interaction { + static selector = ".o_wprofile_editor_form"; + dynamicContent = { + ".o_forum_file_upload": { "t-on-change": this.onUploadFile }, + ".o_forum_profile_pic_edit": { "t-on-click.prevent": this.onClickEditProfilePic }, + ".o_forum_profile_pic_clear": { "t-on-click": this.onClickClearProfilePic }, + ".o_forum_profile_bio_edit": { + "t-on-click.prevent": () => this.isEditingBio = true, + "t-att-class": { "d-none": this.editingBio }, + }, + ".o_forum_profile_bio_cancel_edit": { + "t-on-click.prevent": this.isEditingBio = false, + "t-att-class": { "d-none": !this.editingBio }, + }, + ".o_forum_profile_bio_form": { "t-att-class": { "d-none": !this.editingBio } }, + ".o_forum_profile_bio": { "t-att-class": { "d-none": this.editingBio, } }, + }; + + setup() { + this.isEditingBio = false; + + this.textareaEl = this.el.querySelector("textarea.o_wysiwyg_loader"); + + this.options = { + recordInfo: { + context: this.services.website_page.context, + res_model: "res.users", + res_id: parseInt(this.el.querySelector("input[name=user_id]").value), + }, + value: this.textareaEl.getAttribute("content"), + resizable: true, + userGeneratedContent: true, + }; + + if (this.textareaEl.attributes.placeholder) { + this.options.placeholder = this.textareaEl.attributes.placeholder.value; + } + } + + async willStart() { + await loadWysiwygFromTextarea(this, this.textareaEl, this.options); + } + + onClickEditProfilePic(ev) { + ev.currentTarget.closest("form").querySelector(".o_forum_file_upload").click(); + } + + onClickClearProfilePic(ev) { + const formEl = ev.currentTarget.closest("form"); + formEl.querySelector(".o_wforum_avatar_img").src = "/web/static/img/placeholder.png"; + const inputElement = document.createElement("input"); + inputElement.setAttribute("name", "clear_image"); + inputElement.setAttribute("id", "forum_clear_image"); + inputElement.setAttribute("type", "hidden"); + this.insert(inputElement, formEl); + } + + onUploadFile(ev) { + if (!ev.currentTarget.files.length) { + return; + } + const formEl = ev.currentTarget.closest("form"); + const reader = new window.FileReader(); + reader.readAsDataURL(ev.currentTarget.files[0]); + this.addListener(reader, "load", (ev) => formEl.querySelector(".o_wforum_avatar_img").src = ev.target.result); + formEl.querySelector("#forum_clear_image")?.remove(); + } + +} + +registry + .category("public.interactions") + .add("website_profile.website_profile_editor", WebsiteProfileEditor); diff --git a/addons/website_profile/static/src/interactions/website_profile_next_rank_card.js b/addons/website_profile/static/src/interactions/website_profile_next_rank_card.js new file mode 100644 index 0000000000000..e42af3fd0407f --- /dev/null +++ b/addons/website_profile/static/src/interactions/website_profile_next_rank_card.js @@ -0,0 +1,15 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +class WebsiteProfileNextRankCard extends Interaction { + static selector = ".o_wprofile_progress_circle"; + + setup() { + const tooltip = window.Tooltip.getOrCreateInstance(this.el.querySelector('g[data-bs-toggle="tooltip"]')); + this.registerCleanup(() => tooltip.dispose()); + } +} + +registry + .category("public.interactions") + .add("website_profile.website_profile_next_rank_card", WebsiteProfileNextRankCard); diff --git a/addons/website_profile/static/src/js/website_profile.js b/addons/website_profile/static/src/js/website_profile.js deleted file mode 100644 index 5e04edf532388..0000000000000 --- a/addons/website_profile/static/src/js/website_profile.js +++ /dev/null @@ -1,161 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; -import { rpc } from "@web/core/network/rpc"; -import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea"; -import { redirect } from "@web/core/utils/urls"; - -publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ - selector: '.o_wprofile_email_validation_container', - read_events: { - 'click .send_validation_email': '_onSendValidationEmailClick', - 'click .validated_email_close': '_onCloseValidatedEmailClick', - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - /** - * @private - * @param {Event} ev - */ - _onSendValidationEmailClick: function (ev) { - ev.preventDefault(); - const element = ev.currentTarget; - rpc('/profile/send_validation_email', { - redirect_url: element.dataset["redirect_url"], - }).then(function (data) { - if (data) { - redirect(element.dataset["redirect_url"]); - } - }); - }, - - /** - * @private - */ - _onCloseValidatedEmailClick: function () { - rpc('/profile/validate_email/close'); - }, -}); - -publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ - selector: '.o_wprofile_editor_form', - read_events: { - 'click .o_forum_profile_pic_edit': '_onEditProfilePicClick', - 'change .o_forum_file_upload': '_onFileUploadChange', - 'click .o_forum_profile_pic_clear': '_onProfilePicClearClick', - 'click .o_forum_profile_bio_edit': '_onProfileBioEditClick', - 'click .o_forum_profile_bio_cancel_edit': '_onProfileBioCancelEditClick', - }, - - /** - * @override - */ - start: async function () { - const def = this._super.apply(this, arguments); - if (this.editableMode) { - return def; - } - - const textareaEl = this.el.querySelector("textarea.o_wysiwyg_loader"); - - const options = { - recordInfo: { - context: this._getContext(), - res_model: "res.users", - res_id: parseInt(this.el.querySelector("input[name=user_id]").value), - }, - value: textareaEl.getAttribute("content"), - resizable: true, - userGeneratedContent: true, - }; - - if (textareaEl.attributes.placeholder) { - options.placeholder = textareaEl.attributes.placeholder.value; - } - - this._wysiwyg = await loadWysiwygFromTextarea(this, textareaEl, options); - - return Promise.all([def]); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onEditProfilePicClick: function (ev) { - ev.preventDefault(); - ev.currentTarget.closest("form").querySelector(".o_forum_file_upload").click(); - }, - /** - * @private - * @param {Event} ev - */ - _onFileUploadChange: function (ev) { - if (!ev.currentTarget.files.length) { - return; - } - const formEl = ev.currentTarget.closest("form"); - var reader = new window.FileReader(); - reader.readAsDataURL(ev.currentTarget.files[0]); - reader.onload = function (ev) { - formEl.querySelector(".o_wforum_avatar_img").src = ev.target.result; - }; - formEl.querySelector("#forum_clear_image")?.remove(); - }, - /** - * @private - * @param {Event} ev - */ - _onProfilePicClearClick: function (ev) { - const formEl = ev.currentTarget.closest("form"); - formEl.querySelector(".o_wforum_avatar_img").src = "/web/static/img/placeholder.png"; - const inputElement = document.createElement("input"); - inputElement.setAttribute("name", "clear_image"); - inputElement.setAttribute("id", "forum_clear_image"); - inputElement.setAttribute("type", "hidden"); - formEl.append(inputElement); - }, - - /** - * @private - * @param {Event} ev - */ - _onProfileBioEditClick: function (ev) { - ev.preventDefault(); - ev.currentTarget.classList.add("d-none"); - document.querySelector(".o_forum_profile_bio_cancel_edit").classList.remove("d-none"); - document.querySelector(".o_forum_profile_bio").classList.add("d-none"); - document.querySelector(".o_forum_profile_bio_form").classList.remove("d-none"); - }, - - /** - * @private - * @param {Event} ev - */ - _onProfileBioCancelEditClick: function (ev) { - ev.preventDefault(); - ev.currentTarget.classList.add("d-none"); - document.querySelector(".o_forum_profile_bio_edit").classList.remove("d-none"); - document.querySelector(".o_forum_profile_bio_form").classList.add("d-none"); - document.querySelector(".o_forum_profile_bio").classList.remove("d-none"); - }, -}); - -publicWidget.registry.websiteProfileNextRankCard = publicWidget.Widget.extend({ - selector: '.o_wprofile_progress_circle', - - /** - * @override - */ - start: function () { - new Tooltip(this.el.querySelector('g[data-bs-toggle="tooltip"]')); - return this._super.apply(this, arguments); - }, - -}); - -export default publicWidget.registry.websiteProfile; From 07eb8c0a7f92cb1d8bd683300a5f8d84f25da7ad Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Wed, 18 Dec 2024 09:51:43 +0100 Subject: [PATCH 019/150] enhance jitsiRoom typing --- .../website_jitsi/static/src/js/chat_room.js | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/addons/website_jitsi/static/src/js/chat_room.js b/addons/website_jitsi/static/src/js/chat_room.js index 35a174e4b4481..5f12bf00ca8d8 100644 --- a/addons/website_jitsi/static/src/js/chat_room.js +++ b/addons/website_jitsi/static/src/js/chat_room.js @@ -5,6 +5,22 @@ import { utils as uiUtils } from "@web/core/ui/ui_service"; import { renderToElement } from "@web/core/utils/render"; import { Interaction } from "@web/public/interaction"; +/** + * @typedef jitsiRoom + * @type {Object} + * @property {object} _events + * @property {number} _eventsCount + * @property {string} _url + * @property {object} _frame + * @property {string} _height + * @property {string} _width + * @property {object} _transport + * @property {boolean} _isLargeVideoVisible + * @property {boolean} _isPrejoinVideoVisible + * @property {number} _numberOfParticipants + * @property {object} _participants + */ + class ChatRoom extends Interaction { static selector = ".o_wjitsi_room_widget"; dynamicContent = { @@ -86,10 +102,10 @@ class ChatRoom extends Interaction { this.registerCleanup(() => bsJitsiModal.dispose()); const modalBodyEl = jitsiModalEl.querySelector(".modal-body"); - const jitsiRoomEl = await this.waitFor(this.joinJitsiRoom(modalBodyEl)); + const jitsiRoom = await this.waitFor(this.joinJitsiRoom(modalBodyEl)); // close the modal when hanging up - this.addListener(jitsiRoomEl, "videoConferenceLeft", () => { + this.addListener(jitsiRoom, "videoConferenceLeft", () => { bsJitsiModal.hide(); }); @@ -128,11 +144,10 @@ class ChatRoom extends Interaction { * Update on the 29 June 2020 * * @param {HTMLElement} parentNode, parent element in which we add the Jitsi room - * @returns {HTMLElement} the newly created Jitsi room + * @returns {jitsiRoom} the newly created Jitsi room */ async joinJitsiRoom(parentNode) { const jitsiRoom = await this.waitFor(this.createJitsiRoom(this.roomName, parentNode)); - if (this.defaultUsername) { jitsiRoom.executeCommand("displayName", this.defaultUsername); } @@ -220,7 +235,7 @@ class ChatRoom extends Interaction { * * @param {string} roomName * @param {HTMLElement} parentNode - * @returns {HTMLElement} the newly created Jitsi room + * @returns {jitsiRoom} the newly created Jitsi room */ async createJitsiRoom(roomName, parentNode) { await this.waitFor(this.loadJitsi()); From 983b218df22514e4ebddeaf25ce938194bea9283 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Wed, 18 Dec 2024 09:11:16 +0100 Subject: [PATCH 020/150] MailGroup & MailGroupMessage --- addons/mail_group/__manifest__.py | 2 +- .../static/src/interactions/mail_group.js | 87 +++++++++++++++++++ .../src/interactions/mail_group_message.js | 57 ++++++++++++ addons/mail_group/static/src/js/mail_group.js | 85 ------------------ .../static/src/js/mail_group_message.js | 83 ------------------ 5 files changed, 145 insertions(+), 169 deletions(-) create mode 100644 addons/mail_group/static/src/interactions/mail_group.js create mode 100644 addons/mail_group/static/src/interactions/mail_group_message.js delete mode 100644 addons/mail_group/static/src/js/mail_group.js delete mode 100644 addons/mail_group/static/src/js/mail_group_message.js diff --git a/addons/mail_group/__manifest__.py b/addons/mail_group/__manifest__.py index 12b71040bbc24..e6009272af8e9 100644 --- a/addons/mail_group/__manifest__.py +++ b/addons/mail_group/__manifest__.py @@ -33,7 +33,7 @@ 'assets': { 'web.assets_frontend': [ 'mail_group/static/src/css/mail_group.scss', - 'mail_group/static/src/js/*', + 'mail_group/static/src/interactions/*', ], 'web.assets_backend': [ 'mail_group/static/src/css/mail_group_backend.scss', diff --git a/addons/mail_group/static/src/interactions/mail_group.js b/addons/mail_group/static/src/interactions/mail_group.js new file mode 100644 index 0000000000000..a547bf606405a --- /dev/null +++ b/addons/mail_group/static/src/interactions/mail_group.js @@ -0,0 +1,87 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; + +export class MailGroup extends Interaction { + static selector = ".o_mail_group"; + dynamicContent = { + _root: { + "t-att-class": { + "o_has_error": this.inError, + } + }, + ".form-control, .form-select": { + "t-att-class": { + "is-invalid": this.inError, + } + }, + ".o_mg_subscribe_btn": { + "t-on-click.prevent": this.onClickSubscribe, + "t-att-class": { + "btn-primary": !this.isMember, + "btn-outline-primary": this.isMember, + }, + "t-out": () => this.isMember ? _t('Unsubscribe') : _t('Subscribe'), + }, + } + + setup() { + this.inError = false; + this.mailgroupId = this.el.dataset.id; + this.isMember = this.el.dataset.isMember || false; + const searchParams = (new URL(document.location.href)).searchParams; + this.token = searchParams.get('token'); + this.forceUnsubscribe = searchParams.has('unsubscribe'); + } + + async onClickSubscribe() { + const email = this.el.querySelector(".o_mg_subscribe_email").value; + + if (!email.match(/.+@.+/)) { + this.inError = true; + return false; + } + + this.inError = false; + + const action = (this.isMember || this.forceUnsubscribe) ? 'unsubscribe' : 'subscribe'; + + const response = await this.waitFor(rpc('/group/' + action, { + 'group_id': this.mailgroupId, + 'email': email, + 'token': this.token, + })); + + this.el.querySelector(".o_mg_alert").remove(); + + if (response === 'added') { + this.isMember = true; + } else if (response === 'removed') { + this.isMember = false; + } else if (response === 'email_sent') { + this.el.innerHTML = ``; + } else if (response === 'is_already_member') { + this.isMember = true; + const divEl = document.createElement("div"); + divEl.classList.add("o_mg_alert alert alert-warning"); + divEl.setAttribute("role", "alert"); + divEl.innerText = _t('This email is already subscribed.'); + this.insert(divEl, this.el.querySelector(".o_mg_subscribe_form"), "beforebegin") + } else if (response === 'is_not_member') { + if (!this.forceUnsubscribe) { + this.isMember = false; + } + const divEl = document.createElement("div"); + divEl.classList.add("o_mg_alert alert alert-warning"); + divEl.setAttribute("role", "alert"); + divEl.innerText = _t('This email is not subscribed.'); + this.insert(divEl, this.el.querySelector(".o_mg_subscribe_form"), "beforebegin") + } + } +} + +registry + .category("public.interactions") + .add("mail_groupe.mail_group", MailGroup); diff --git a/addons/mail_group/static/src/interactions/mail_group_message.js b/addons/mail_group/static/src/interactions/mail_group_message.js new file mode 100644 index 0000000000000..db022e28aafdd --- /dev/null +++ b/addons/mail_group/static/src/interactions/mail_group_message.js @@ -0,0 +1,57 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { rpc } from "@web/core/network/rpc"; + +class MailGroupMessage extends Interaction { + static selector = ".o_mg_message"; + dynamicContent = { + ".o_mg_link_hide": { + "t-on-click.prevent.stop": () => this.isShown = false, + "t-att-class": { "d-none": !this.isShown } + }, + ".o_mg_link_show": { + "t-on-click.prevent.stop": () => this.isShown = true, + "t-att-class": { "d-none": !this.isShown }, + }, + ".o_mg_link_content": { "t-att-class": { "d-none": this.isShown } }, + "button.o_mg_read_more": { "t-on-click": this.onClickReadMore }, + }; + + setup() { + this.isShown = true; + + // By default hide the mention of the previous email for which we reply + // And add a button "Read more" to show the mention of the parent email + const quoted = this.el.querySelector(".card-body *[data-o-mail-quote]"); + const readMore = document.createElement("button"); + readMore.classList.add("btn btn-light btn-sm ms-1"); + readMore.innerText = ". . ."; + quoted.insertBefore(readMore); + readMore.addEventListener("click", () => quoted.classList.toggle("visible")); + } + + async onClickReadMore() { + const data = await this.waitFor(rpc(ev.target.getAttribute('href'), { + last_displayed_id: ev.target.dataste.listDisplayedId, + })); + if (data) { + const threadContainer = ev.target.closest(".o_mg_replies")?.querySelector("ul.list-unstyled"); + if (threadContainer) { + const messages = threadContainer.querySelectorAll(":scope > li.media"); + let lastMessage = messages[messages.length - 1]; + const newMessages = data.querySelector("ul.list-unstyled").querySelectorAll(":scope > li.media"); + for (const newMessage in newMessages) { + this.insert(newMessage, lastMessage, "afterend"); + lastMessage = newMessage; + } + this.insert(data.querySelector('.o_mg_read_more').parentElement, threadContainer); + } + target.parentElement.remove(); + } + } +} + +registry + .category("public.interactions") + .add("mail_group.mail_group_message", MailGroupMessage); diff --git a/addons/mail_group/static/src/js/mail_group.js b/addons/mail_group/static/src/js/mail_group.js deleted file mode 100644 index 75947efdbcc81..0000000000000 --- a/addons/mail_group/static/src/js/mail_group.js +++ /dev/null @@ -1,85 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; -import { _t } from "@web/core/l10n/translation"; -import { rpc } from "@web/core/network/rpc"; - -publicWidget.registry.MailGroup = publicWidget.Widget.extend({ - selector: '.o_mail_group', - events: { - 'click .o_mg_subscribe_btn': '_onSubscribeBtnClick', - }, - - /** - * @override - */ - start: function () { - this.mailgroupId = this.$el.data('id'); - this.isMember = this.$el.data('isMember') || false; - const searchParams = (new URL(document.location.href)).searchParams; - this.token = searchParams.get('token'); - this.forceUnsubscribe = searchParams.has('unsubscribe'); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onSubscribeBtnClick: async function (ev) { - ev.preventDefault(); - const $email = this.$el.find(".o_mg_subscribe_email"); - const email = $email.val(); - - if (!email.match(/.+@.+/)) { - this.$el.addClass('o_has_error').find('.form-control, .form-select').addClass('is-invalid'); - return false; - } - - this.$el.removeClass('o_has_error').find('.form-control, .form-select').removeClass('is-invalid'); - - const action = (this.isMember || this.forceUnsubscribe) ? 'unsubscribe' : 'subscribe'; - - const response = await rpc('/group/' + action, { - 'group_id': this.mailgroupId, - 'email': email, - 'token': this.token, - }); - - this.$el.find('.o_mg_alert').remove(); - - if (response === 'added') { - this.isMember = true; - this.$el.find('.o_mg_subscribe_btn').text(_t('Unsubscribe')).removeClass('btn-primary').addClass('btn-outline-primary'); - } else if (response === 'removed') { - this.isMember = false; - this.$el.find('.o_mg_subscribe_btn').text(_t('Subscribe')).removeClass('btn-outline-primary').addClass('btn-primary'); - } else if (response === 'email_sent') { - // The confirmation email has been sent - this.$el.html( - $(' - - Group 000 JS - web.assets_frontend - website_mail_group/static/src/snippets/s_group/000.js - From e5d7f64c70955a5eb11444fe72f128eeae3b1f28 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Wed, 18 Dec 2024 16:29:29 +0100 Subject: [PATCH 040/150] add tool to patch dynamic content --- addons/web/static/src/public/utils.js | 62 +++++++ .../static/tests/public/interaction.test.js | 65 +++++++ addons/web/static/tests/public/utils.test.js | 162 +++++++++++++----- addons/website_mail_group/__manifest__.py | 2 +- .../src/snippets/s_group/mail_group.edit.js | 8 +- .../static/src/snippets/s_group/mail_group.js | 14 +- 6 files changed, 262 insertions(+), 51 deletions(-) diff --git a/addons/web/static/src/public/utils.js b/addons/web/static/src/public/utils.js index bbf020833080d..e0cb09d9e122d 100644 --- a/addons/web/static/src/public/utils.js +++ b/addons/web/static/src/public/utils.js @@ -109,3 +109,65 @@ export function makeButtonHandler(fct) { 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") { + const oldFn = forSelector[t]; + if (oldFn) { + 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 { + forSelector[t] = (el, oldResult) => { + let result = oldResult; + result = oldFn(el, result); + result = replacement(el, result); + return result; + }; + } + } else { + forSelector[t] = replacement; + } + } 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, + * }), + * }, + * }) + * + * @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); + } + } +} diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index df9438d3c6fc8..75ef8bdf87f73 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -5,6 +5,8 @@ import { advanceTime, Deferred } from "@odoo/hoot-mock"; import { patchWithCleanup } from "@web/../tests/web_test_helpers"; import { Colibri } from "@web/public/colibri"; import { Interaction } from "@web/public/interaction"; +import { patchDynamicContent } from "@web/public/utils"; +import { patch } from "@web/core/utils/patch"; import { startInteraction } from "./helpers"; import { Component, onWillDestroy, xml } from "@odoo/owl"; @@ -2069,3 +2071,66 @@ describe("throttled_for_animation (2)", () => { expect.verifySteps(["click"]); }); }); + +describe("patching", () => { + test("'this' is kept through patches", async () => { + class Base extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { + "t-on-click": () => this.value++, + "t-att-value": () => this.value, + "t-att-class": () => ({ + base: true, + }), + }, + }; + setup() { + this.value = 10; + } + } + patch(Base.prototype, { + setup() { + super.setup(); + patchDynamicContent(this.dynamicContent, { + span: { + "t-on-click": undefined, + "t-att-value": (el, old) => old * 2 + this.value, + "t-att-class": () => ({ + big: this.value >= 50, + }), + }, + }); + }, + }); + patch(Base.prototype, { + setup() { + super.setup(); + patchDynamicContent(this.dynamicContent, { + span: { + "t-on-click": () => (this.value *= 5), + "t-att-value": (el, old) => old * 10 - this.value, + "t-att-class": () => ({ + bigger: this.value >= 100, + }), + }, + }); + }, + }); + const { core } = await startInteraction(Base, TemplateTest); + const interaction = core.interactions[0].interaction; + expect(interaction.value).toBe(10); + expect("span").toHaveAttribute("value", "290"); + expect("span").toHaveClass("base"); + expect("span").not.toHaveClass(["big", "bigger"]); + await click("span"); + expect(interaction.value).toBe(50); + expect("span").toHaveAttribute("value", "1450"); + expect("span").toHaveClass(["base", "big"]); + expect("span").not.toHaveClass("bigger"); + await click("span"); + expect(interaction.value).toBe(250); + expect("span").toHaveAttribute("value", "7250"); + expect("span").toHaveClass(["base", "big", "bigger"]); + }); +}); diff --git a/addons/web/static/tests/public/utils.test.js b/addons/web/static/tests/public/utils.test.js index 37d148e212bd5..fbb90f603d453 100644 --- a/addons/web/static/tests/public/utils.test.js +++ b/addons/web/static/tests/public/utils.test.js @@ -1,51 +1,133 @@ import { describe, expect, test } from "@odoo/hoot"; -import { PairSet } from "@web/public/utils"; +import { PairSet, patchDynamicContent } from "@web/public/utils"; describe.current.tags("headless"); -test("[PairSet] can add and delete pairs", () => { - const pairSet = new PairSet(); +describe("PairSet", () => { + test("can add and delete pairs", () => { + const pairSet = new PairSet(); - const a = {}; - const b = {}; - expect(pairSet.has(a, b)).toBe(false); - pairSet.add(a, b); - expect(pairSet.has(a, b)).toBe(true); - pairSet.delete(a, b); - expect(pairSet.has(a, b)).toBe(false); -}); + const a = {}; + const b = {}; + expect(pairSet.has(a, b)).toBe(false); + pairSet.add(a, b); + expect(pairSet.has(a, b)).toBe(true); + pairSet.delete(a, b); + expect(pairSet.has(a, b)).toBe(false); + }); + + test("can add and delete pairs with the same first element", () => { + const pairSet = new PairSet(); + + const a = {}; + const b = {}; + const c = {}; + expect(pairSet.has(a, b)).toBe(false); + expect(pairSet.has(a, c)).toBe(false); + pairSet.add(a, b); + expect(pairSet.has(a, b)).toBe(true); + expect(pairSet.has(a, c)).toBe(false); + pairSet.add(a, c); + expect(pairSet.has(a, b)).toBe(true); + expect(pairSet.has(a, c)).toBe(true); + pairSet.delete(a, c); + expect(pairSet.has(a, b)).toBe(true); + expect(pairSet.has(a, c)).toBe(false); + pairSet.delete(a, b); + expect(pairSet.has(a, b)).toBe(false); + expect(pairSet.has(a, c)).toBe(false); + }); -test("[PairSet] can add and delete pairs with the same first element", () => { - const pairSet = new PairSet(); - - const a = {}; - const b = {}; - const c = {}; - expect(pairSet.has(a, b)).toBe(false); - expect(pairSet.has(a, c)).toBe(false); - pairSet.add(a, b); - expect(pairSet.has(a, b)).toBe(true); - expect(pairSet.has(a, c)).toBe(false); - pairSet.add(a, c); - expect(pairSet.has(a, b)).toBe(true); - expect(pairSet.has(a, c)).toBe(true); - pairSet.delete(a, c); - expect(pairSet.has(a, b)).toBe(true); - expect(pairSet.has(a, c)).toBe(false); - pairSet.delete(a, b); - expect(pairSet.has(a, b)).toBe(false); - expect(pairSet.has(a, c)).toBe(false); + test("do not duplicated pairs", () => { + const pairSet = new PairSet(); + + const a = {}; + const b = {}; + expect(pairSet.map.size).toBe(0); + pairSet.add(a, b); + expect(pairSet.map.size).toBe(1); + pairSet.add(a, b); + expect(pairSet.map.size).toBe(1); + }); }); -test("[PairSet] do not duplicated pairs", () => { - const pairSet = new PairSet(); +describe("patch dynamic content", () => { + test("patch applies new values", () => { + const parent = { + somewhere: { + "t-att-doNotTouch": 123, + }, + }; + const patch = { + somewhere: { + "t-att-class": () => ({ + abc: true, + }), + "t-att-xyz": "123", + }, + elsewhere: { + "t-att-class": () => ({ + xyz: true, + }), + "t-att-abc": "123", + }, + }; + patchDynamicContent(parent, patch); + expect(parent).toEqual({ + somewhere: { + ...parent.somewhere, + ...patch.somewhere, + }, + elsewhere: patch.elsewhere, + }); + }); + + test("patch removes undefined values", () => { + const parent = { + somewhere: { + "t-att-doNotTouch": 123, + "t-att-removeMe": "abc", + }, + }; + const patch = { + somewhere: { + "t-att-removeMe": undefined, + }, + }; + patchDynamicContent(parent, patch); + expect(parent).toEqual({ + somewhere: { + "t-att-doNotTouch": 123, + }, + }); + }); - const a = {}; - const b = {}; - expect(pairSet.map.size).toBe(0); - pairSet.add(a, b); - expect(pairSet.map.size).toBe(1); - pairSet.add(a, b); - expect(pairSet.map.size).toBe(1); + test("patch combines function outputs", () => { + const parent = { + somewhere: { + "t-att-style": () => ({ + doNotTouch: true, + changeMe: 10, + doubleMe: 100, + }), + }, + }; + const patch = { + somewhere: { + "t-att-style": (el, old) => ({ + changeMe: 50, + doubleMe: old.doubleMe * 2, + addMe: 1000, + }), + }, + }; + patchDynamicContent(parent, patch); + expect(parent.somewhere["t-att-style"]()).toEqual({ + doNotTouch: true, + changeMe: 50, + doubleMe: 200, + addMe: 1000, + }); + }); }); diff --git a/addons/website_mail_group/__manifest__.py b/addons/website_mail_group/__manifest__.py index 367a41bf3e8ca..d307f3fdeb00c 100644 --- a/addons/website_mail_group/__manifest__.py +++ b/addons/website_mail_group/__manifest__.py @@ -22,7 +22,7 @@ ('remove', 'website_mail_group/static/src/**/*.edit.js'), ('remove', 'website_mail_group/static/src/**/options.js'), ], - 'web.assets_edit_frontend': [ + 'website.assets_edit_frontend': [ 'website_mail_group/static/src/**/*.edit.js', ], }, diff --git a/addons/website_mail_group/static/src/snippets/s_group/mail_group.edit.js b/addons/website_mail_group/static/src/snippets/s_group/mail_group.edit.js index bdeb19d2f2e4f..6d41e6840a161 100644 --- a/addons/website_mail_group/static/src/snippets/s_group/mail_group.edit.js +++ b/addons/website_mail_group/static/src/snippets/s_group/mail_group.edit.js @@ -8,15 +8,15 @@ import { Interaction } from "@web/public/interaction"; // but without the rest. Arguably could just enable the whole widget in edit // mode but not stable-friendly. export class MailGroupEdit extends Interaction { - static selector = MailGroup.prototype.selector; + static selector = MailGroup.selector; dynamicContent = { _root: { - "t-att-class": { + "t-att-class": () => ({ "d-none": false, - }, + }), }, }; -}); +} registry .category("public.interactions.edit") diff --git a/addons/website_mail_group/static/src/snippets/s_group/mail_group.js b/addons/website_mail_group/static/src/snippets/s_group/mail_group.js index 03715dd354fe2..6c9b69be2dc2b 100644 --- a/addons/website_mail_group/static/src/snippets/s_group/mail_group.js +++ b/addons/website_mail_group/static/src/snippets/s_group/mail_group.js @@ -2,16 +2,18 @@ import { _t } from "@web/core/l10n/translation"; import { rpc } from "@web/core/network/rpc"; import { MailGroup } from "@mail_group/interactions/mail_group"; import { patch } from "@web/core/utils/patch"; +import { patchDynamicContent } from "@web/public/utils"; patch(MailGroup.prototype, { setup() { super.setup(); - const oldRootClass = this.dynamicContent._root["t-att-class"]; - this.dynamicContent._root["t-att-class"] = () => { - const classes = oldRootClass.apply(this, this.el); - classes["d-none"] = false; - return classes; - }; + patchDynamicContent(this.dynamicContent, { + _root: { + "t-att-class": () => ({ + "d-none": false, + }), + }, + }); }, async willStart() { From e12ee07acf26a665ed078c110b9b6cb0be738f7e Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Thu, 19 Dec 2024 11:18:12 +0100 Subject: [PATCH 041/150] also handle t-component and t-on- --- addons/web/static/src/public/utils.js | 38 ++++++++++--------- .../static/tests/public/interaction.test.js | 1 - addons/web/static/tests/public/utils.test.js | 21 ++++++++++ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/addons/web/static/src/public/utils.js b/addons/web/static/src/public/utils.js index e0cb09d9e122d..39bb103cb03ca 100644 --- a/addons/web/static/src/public/utils.js +++ b/addons/web/static/src/public/utils.js @@ -124,26 +124,24 @@ export function patchDynamicContentEntry(dynamicContent, selector, t, replacemen const forSelector = dynamicContent[selector]; if (replacement === undefined) { delete forSelector[t]; - } else if (typeof replacement === "function") { + } else if (typeof replacement === "function" && forSelector[t] && t !== "t-component") { const oldFn = forSelector[t]; - if (oldFn) { - 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 { - forSelector[t] = (el, oldResult) => { - let result = oldResult; - result = oldFn(el, result); - result = replacement(el, result); - return result; - }; - } + 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) => replacement(el, oldFn); } else { - forSelector[t] = replacement; + forSelector[t] = (el, oldResult) => { + let result = oldResult; + result = oldFn(el, result); + result = replacement(el, result); + return result; + }; } } else { forSelector[t] = replacement; @@ -158,6 +156,10 @@ export function patchDynamicContentEntry(dynamicContent, selector, t, replacemen * "t-att-class": (el, old) => ({ * "test": this.condition && old.test, * }), + * "t-on-click": (el, oldFn) => { + * oldFn?.(el); + * this.doMoreStuff(); + * }, * }, * }) * diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index 75ef8bdf87f73..f2a6cb7a0da50 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -2094,7 +2094,6 @@ describe("patching", () => { super.setup(); patchDynamicContent(this.dynamicContent, { span: { - "t-on-click": undefined, "t-att-value": (el, old) => old * 2 + this.value, "t-att-class": () => ({ big: this.value >= 50, diff --git a/addons/web/static/tests/public/utils.test.js b/addons/web/static/tests/public/utils.test.js index fbb90f603d453..4747c9f5535d1 100644 --- a/addons/web/static/tests/public/utils.test.js +++ b/addons/web/static/tests/public/utils.test.js @@ -130,4 +130,25 @@ describe("patch dynamic content", () => { addMe: 1000, }); }); + + test("patch t-on-... provides access to super", () => { + const parent = { + somewhere: { + "t-on-click": () => { + expect.step("base"); + }, + }, + }; + const patch = { + somewhere: { + "t-on-click": (el, oldFn) => { + oldFn(); + expect.step("patch"); + }, + }, + }; + patchDynamicContent(parent, patch); + parent.somewhere["t-on-click"](); + expect.verifySteps(["base", "patch"]); + }); }); From 160c24f442a0bf7d2084b3a3d944ecc7eff30269 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 19 Dec 2024 13:04:27 +0100 Subject: [PATCH 042/150] HrRecruitmentForm --- addons/website_hr_recruitment/__manifest__.py | 2 +- .../hr_recruitment_form.js} | 129 +++++++++--------- 2 files changed, 63 insertions(+), 68 deletions(-) rename addons/website_hr_recruitment/static/src/{js/website_hr_applicant_form.js => interactions/hr_recruitment_form.js} (52%) diff --git a/addons/website_hr_recruitment/__manifest__.py b/addons/website_hr_recruitment/__manifest__.py index 631b71edd2f5c..5e0857ccb4dda 100644 --- a/addons/website_hr_recruitment/__manifest__.py +++ b/addons/website_hr_recruitment/__manifest__.py @@ -28,7 +28,7 @@ 'assets': { 'web.assets_frontend': [ 'website_hr_recruitment/static/src/scss/**/*', - 'website_hr_recruitment/static/src/js/website_hr_applicant_form.js', + 'website_hr_recruitment/static/src/interactions/*', ], 'web.assets_backend': [ 'website_hr_recruitment/static/src/js/widgets/copy_link_menuitem.js', diff --git a/addons/website_hr_recruitment/static/src/js/website_hr_applicant_form.js b/addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js similarity index 52% rename from addons/website_hr_recruitment/static/src/js/website_hr_applicant_form.js rename to addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js index d940acd14296d..26a5b29d9c630 100644 --- a/addons/website_hr_recruitment/static/src/js/website_hr_applicant_form.js +++ b/addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js @@ -1,18 +1,59 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { _t } from "@web/core/l10n/translation"; import { rpc } from "@web/core/network/rpc"; -publicWidget.registry.hrRecruitment = publicWidget.Widget.extend({ - selector : '#hr_recruitment_form', - events: { - 'click #apply-btn': '_onClickApplyButton', - "focusout #recruitment1" : "_onFocusOutName", - 'focusout #recruitment2' : '_onFocusOutMail', - "focusout #recruitment3" : "_onFocusOutPhone", - 'focusout #recruitment4' : '_onFocusOutLinkedin', - }, +export class HrRecruitmentForm extends Interaction { + static selector = "#hr_recruitment_form"; + dynamicContent = { + "#apply-btn": { "t-on-click": this.onClickApplyButton }, + "#recruitment1": { "t-on-focusout": (ev) => this.checkRedundant(ev.currentTarget, "name", "#warning-message") }, + "#recruitment2": { "t-on-focusout": (ev) => this.checkRedundant(ev.currentTarget, "email", "#warning-message") }, + "#recruitment3": { "t-on-focusout": (ev) => this.checkRedundant(ev.currentTarget, "phone", "#warning-message") }, + "#recruitment4": { "t-on-focusout": this.onFocusOutLinkedin }, + }; + + /** + * @param {HTMLElement} targetEl + * @param {string} messageContainerId + */ + showWarningMessage(targetEl, messageContainerId) { + targetEl.classList.add("border-warning"); + document.querySelector(messageContainerId).textContent = message; + document.querySelector(messageContainerId).classList.remove("d-none"); + } + + /** + * @param {HTMLElement} targetEl + * @param {string} messageContainerId + */ + hideWarningMessage(targetEl, messageContainerId) { + targetEl.classList.remove("border-warning"); + document.querySelector(messageContainerId).classList.add("d-none"); + } + + async checkRedundant(targetEl, field, messageContainerId, keepPreviousWarningMessage = false) { + const value = targetEl.value; + if (!value) { + this.hideWarningMessage(targetEl, messageContainerId); + return; + } + const job_id = document.querySelector("#recruitment7").value; + const data = await this.waitFor(rpc("/website_hr_recruitment/check_recent_application", { + field: field, + value: value, + job_id: job_id, + })); + + if (data.message) { + this.showWarningMessage(targetEl, messageContainerId, data.message); + } else if (!keepPreviousWarningMessage) { + this.hideWarningMessage(targetEl, messageContainerId); + } + } - _onClickApplyButton (ev) { + onClickApplyButton() { const linkedinProfileEl = document.querySelector("#recruitment4"); const resumeEl = document.querySelector("#recruitment6"); @@ -25,71 +66,25 @@ publicWidget.registry.hrRecruitment = publicWidget.Widget.extend({ linkedinProfileEl?.removeAttribute("required"); resumeEl?.removeAttribute("required"); } - }, + } - hideWarningMessage(targetEl, messageContainerId) { - targetEl.classList.remove("border-warning"); - document.querySelector(messageContainerId)?.classList.add("d-none"); - }, - - showWarningMessage(targetEl, messageContainerId, message) { - targetEl.classList.add("border-warning"); - document.querySelector(messageContainerId).textContent = message; - document.querySelector(messageContainerId)?.classList.remove("d-none"); - }, - - async _onFocusOutName(ev) { - const field = "name" - const messageContainerId = "#warning-message"; - await this.checkRedundant(ev.currentTarget, field, messageContainerId); - }, - - async _onFocusOutMail (ev) { - const field = "email" - const messageContainerId = "#warning-message"; - await this.checkRedundant(ev.currentTarget, field, messageContainerId); - }, - - async _onFocusOutPhone (ev) { - const field = "phone" - const messageContainerId = "#warning-message"; - await this.checkRedundant(ev.currentTarget, field, messageContainerId); - }, - - async _onFocusOutLinkedin (ev) { + onFocusOutLinkedin(ev) { const targetEl = ev.currentTarget; const linkedin = targetEl.value; const field = "linkedin"; const messageContainerId = "#linkedin-message"; const linkedin_regex = /^(https?:\/\/)?([\w\.]*)linkedin\.com\/in\/(.*?)(\/.*)?$/; - let hasWarningMessage = false; if (!linkedin_regex.test(linkedin) && linkedin !== "") { const message = _t("The profile that you gave us doesn't seems like a linkedin profile") - this.showWarningMessage(targetEl, "#linkedin-message", message); - hasWarningMessage = true; + this.showWarningMessage(targetEl, messageContainerId, message); + this.checkRedundant(targetEl, field, messageContainerId, true); } else { - this.hideWarningMessage(targetEl, "#linkedin-message"); - } - await this.checkRedundant(targetEl, field, messageContainerId, hasWarningMessage); - }, - - async checkRedundant(targetEl, field, messageContainerId, keepPreviousWarningMessage = false) { - const value = targetEl.value; - if (!value) { this.hideWarningMessage(targetEl, messageContainerId); - return; + this.checkRedundant(targetEl, field, messageContainerId, false); } - const job_id = document.querySelector("#recruitment7").value; - const data = await rpc("/website_hr_recruitment/check_recent_application", { - field: field, - value: value, - job_id: job_id, - }); + } +} - if (data.message) { - this.showWarningMessage(targetEl, messageContainerId, data.message); - } else if (!keepPreviousWarningMessage) { - this.hideWarningMessage(targetEl, messageContainerId); - } - }, -}); +registry + .category("public.interactions") + .add("website_hr_recruitment.hr_recruitment_form", HrRecruitmentForm); From 7acca6ed2ba567cf26551a3a37da95109c84e36c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 19 Dec 2024 13:49:24 +0100 Subject: [PATCH 043/150] BoothSponsorDetails --- .../__manifest__.py | 2 +- .../src/interactions/booth_sponsor_details.js | 30 ++++++++++++++++ .../static/src/js/booth_sponsor_details.js | 34 ------------------- 3 files changed, 31 insertions(+), 35 deletions(-) create mode 100644 addons/website_event_booth_exhibitor/static/src/interactions/booth_sponsor_details.js delete mode 100644 addons/website_event_booth_exhibitor/static/src/js/booth_sponsor_details.js diff --git a/addons/website_event_booth_exhibitor/__manifest__.py b/addons/website_event_booth_exhibitor/__manifest__.py index 701fea6e7afdd..289c61d6f8bce 100644 --- a/addons/website_event_booth_exhibitor/__manifest__.py +++ b/addons/website_event_booth_exhibitor/__manifest__.py @@ -22,7 +22,7 @@ 'auto_install': True, 'assets': { 'web.assets_frontend': [ - '/website_event_booth_exhibitor/static/src/js/booth_sponsor_details.js', + '/website_event_booth_exhibitor/static/src/interactions/booth_sponsor_details.js', ], 'web.assets_tests': [ 'website_event_booth_exhibitor/static/tests/tours/website_event_booth_exhibitor_steps.js', diff --git a/addons/website_event_booth_exhibitor/static/src/interactions/booth_sponsor_details.js b/addons/website_event_booth_exhibitor/static/src/interactions/booth_sponsor_details.js new file mode 100644 index 0000000000000..5a584e7128fb2 --- /dev/null +++ b/addons/website_event_booth_exhibitor/static/src/interactions/booth_sponsor_details.js @@ -0,0 +1,30 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +export class BoothSponsorDetails extends Interaction { + static selector = "#o_wbooth_contact_details_form"; + dynamicContent = { + "input[id='contact_details']": { "t-on-click": this.onClickContactDetails }, + } + + onClickContactDetails(ev) { + this.useContactDetails = ev.currentTarget.checked; + + const contactDetailsEl = this.el.querySelector("#o_wbooth_contact_details"); + contactDetailsEl.classList.toggle("d-none", !this.useContactDetails); + + const sponsorInfoEls = this.el.querySelectorAll("label[for='sponsor_name'] > .mandatory_mark, label[for='sponsor_email'] > .mandatory_mark"); + for (const sponsorInfoEl of sponsorInfoEls) { + sponsorInfoEl.classList.toggle("d-none", this.useContactDetails); + } + + const contactInfoEls = this.el.querySelectorAll("input[name='contact_name'], input[name='contact_email']"); + for (const contactInfoEl of contactInfoEls) { + contactInfoEl.required = this.useContactDetails; + } + } +} + +registry + .category("public.interactions") + .add("website_event_booth_exhibitor.booth_sponsor_details", BoothSponsorDetails); diff --git a/addons/website_event_booth_exhibitor/static/src/js/booth_sponsor_details.js b/addons/website_event_booth_exhibitor/static/src/js/booth_sponsor_details.js deleted file mode 100644 index d9bb14b3bb30f..0000000000000 --- a/addons/website_event_booth_exhibitor/static/src/js/booth_sponsor_details.js +++ /dev/null @@ -1,34 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; - -publicWidget.registry.boothSponsorDetails = publicWidget.Widget.extend({ - selector: '#o_wbooth_contact_details_form', - events: { - 'click input[id="contact_details"]': '_onClickContactDetails', - }, - - //-------------------------------------------------------------------------- - // Handler - //-------------------------------------------------------------------------- - - _onClickContactDetails(ev) { - this.useContactDetails = ev.currentTarget.checked; - this.el - .querySelector("#o_wbooth_contact_details") - .classList.toggle("d-none", !this.useContactDetails); - this.el - .querySelectorAll( - "label[for='sponsor_name'] > .mandatory_mark, label[for='sponsor_email'] > .mandatory_mark" - ) - .forEach((el) => { - el.classList.toggle("d-none", this.useContactDetails); - }); - this.el - .querySelectorAll("input[name='contact_name'], input[name='contact_email']") - .forEach((inputEl) => (inputEl.required = this.useContactDetails)); - }, - -}); - -export default { - boothSponsorDetails: publicWidget.registry.boothSponsorDetails, -}; From ad1982747ce24d9aafed59369253c35066952be8 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Thu, 19 Dec 2024 14:12:43 +0100 Subject: [PATCH 044/150] website_profile: fix test_save_change_description --- .../static/src/interactions/website_profile_editor.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/addons/website_profile/static/src/interactions/website_profile_editor.js b/addons/website_profile/static/src/interactions/website_profile_editor.js index 4be657aec4329..a35362ce2d679 100644 --- a/addons/website_profile/static/src/interactions/website_profile_editor.js +++ b/addons/website_profile/static/src/interactions/website_profile_editor.js @@ -12,14 +12,14 @@ export class WebsiteProfileEditor extends Interaction { ".o_forum_profile_pic_clear": { "t-on-click": this.onClickClearProfilePic }, ".o_forum_profile_bio_edit": { "t-on-click.prevent": () => this.isEditingBio = true, - "t-att-class": { "d-none": this.editingBio }, + "t-att-class": () => ({ "d-none": this.editingBio }), }, ".o_forum_profile_bio_cancel_edit": { - "t-on-click.prevent": this.isEditingBio = false, - "t-att-class": { "d-none": !this.editingBio }, + "t-on-click.prevent": () => this.isEditingBio = false, + "t-att-class": () => ({ "d-none": !this.editingBio }), }, - ".o_forum_profile_bio_form": { "t-att-class": { "d-none": !this.editingBio } }, - ".o_forum_profile_bio": { "t-att-class": { "d-none": this.editingBio, } }, + ".o_forum_profile_bio_form": { "t-att-class": () => ({ "d-none": !this.editingBio }) }, + ".o_forum_profile_bio": { "t-att-class": () => ({ "d-none": this.editingBio, }) }, }; setup() { From 896288a950102237575c8327a5890b1cf66ed8c8 Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Thu, 19 Dec 2024 15:04:53 +0100 Subject: [PATCH 045/150] fix t-att-class MailGroup --- .../static/src/interactions/mail_group_message.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/mail_group/static/src/interactions/mail_group_message.js b/addons/mail_group/static/src/interactions/mail_group_message.js index 62fe8c0c13342..3bb9d7ef97281 100644 --- a/addons/mail_group/static/src/interactions/mail_group_message.js +++ b/addons/mail_group/static/src/interactions/mail_group_message.js @@ -8,13 +8,13 @@ class MailGroupMessage extends Interaction { dynamicContent = { ".o_mg_link_hide": { "t-on-click.prevent.stop": () => this.isShown = false, - "t-att-class": { "d-none": !this.isShown } + "t-att-class": () => ({ "d-none": !this.isShown }), }, ".o_mg_link_show": { "t-on-click.prevent.stop": () => this.isShown = true, - "t-att-class": { "d-none": !this.isShown }, + "t-att-class": () => ({ "d-none": !this.isShown }), }, - ".o_mg_link_content": { "t-att-class": { "d-none": this.isShown } }, + ".o_mg_link_content": { "t-att-class": () => ({ "d-none": this.isShown }) }, "button.o_mg_read_more": { "t-on-click": this.onClickReadMore }, }; From 91911eb8cd4941584b6fd4ea5342ea77db1191ae Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 19 Dec 2024 15:48:51 +0100 Subject: [PATCH 046/150] Re-add searchbar classes --- addons/website/static/src/snippets/s_searchbar/search_bar.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar.js b/addons/website/static/src/snippets/s_searchbar/search_bar.js index e9f3c21219001..d1c2876f295d5 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar.js @@ -127,6 +127,8 @@ export class SearchBar extends Interaction { this.insert(this.menuEl, this.el); this.services["public.interactions"].startInteractions(this.menuEl); } + this.el.classList.toggle("dropdown", !!res); + this.el.classList.toggle("show", !!res); prevMenuEl?.remove(); } From 03612d389820a2108b9d76bdd839bc012ba6069c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 20 Dec 2024 08:22:45 +0100 Subject: [PATCH 047/150] fix hr_recruitment_form --- .../static/src/interactions/hr_recruitment_form.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js b/addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js index 26a5b29d9c630..c879ad17e786e 100644 --- a/addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js +++ b/addons/website_hr_recruitment/static/src/interactions/hr_recruitment_form.js @@ -17,8 +17,9 @@ export class HrRecruitmentForm extends Interaction { /** * @param {HTMLElement} targetEl * @param {string} messageContainerId + * @param {string} message */ - showWarningMessage(targetEl, messageContainerId) { + showWarningMessage(targetEl, messageContainerId, message) { targetEl.classList.add("border-warning"); document.querySelector(messageContainerId).textContent = message; document.querySelector(messageContainerId).classList.remove("d-none"); From 951d97c10ca3429f2345b846577ac6c505121acf Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 20 Dec 2024 09:29:23 +0100 Subject: [PATCH 048/150] fix searchbar result position --- .../src/snippets/s_searchbar/search_bar_results.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar_results.js b/addons/website/static/src/snippets/s_searchbar/search_bar_results.js index 77f81e8baad75..75b539feef9ec 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar_results.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar_results.js @@ -14,9 +14,7 @@ export class SearchBarResults extends Interaction { "t-att-style": () => { const bcr = this.el.closest(".o_searchbar_form").getBoundingClientRect(); return { - "position": "fixed !important", - "top": `${bcr.bottom}px !important`, - "left": `${bcr.left}px !important`, + "position": "absolute !important", "max-width": `${bcr.width}px !important`, "max-height": `${document.body.clientHeight - bcr.bottom - 16}px !important`, "min-width": this.autocompleteMinWidth, @@ -30,10 +28,10 @@ export class SearchBarResults extends Interaction { "t-att-data-bs-popper": () => this.isDropup ? "" : undefined, }, _window: { - "t-on-resize": () => {}, // Re-apply _root:t-att-style. + "t-on-resize": () => { }, // Re-apply _root:t-att-style. }, _scrollingParent: { - "t-on-scroll": () => {}, // Re-apply _root:t-att-style. + "t-on-scroll": () => { }, // Re-apply _root:t-att-style. }, ".dropdown-item": { "t-on-mousedown": this.onMousedown, @@ -94,14 +92,14 @@ export class SearchBarResults extends Interaction { // to get around that behavior to avoid onFocusOut() from triggering // render(), as this would prevent the click from working. if (isBrowserSafari) { - this.searchBarEl.dispatchEvent(new CustomEvent('safarihack', {detail: {linkHasFocus: true}})); + this.searchBarEl.dispatchEvent(new CustomEvent('safarihack', { detail: { linkHasFocus: true } })); } } onMouseup(ev) { // See comment in onMousedown. if (isBrowserSafari) { - this.searchBarEl.dispatchEvent(new CustomEvent('safarihack', {detail: {linkHasFocus: false}})); + this.searchBarEl.dispatchEvent(new CustomEvent('safarihack', { detail: { linkHasFocus: false } })); } } From 16255d9d456b689d9b479dd5999bf7490dce7d3f Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 20 Dec 2024 09:31:46 +0100 Subject: [PATCH 049/150] use t-att-class --- .../website/static/src/snippets/s_searchbar/search_bar.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar.js b/addons/website/static/src/snippets/s_searchbar/search_bar.js index d1c2876f295d5..14b7604f48dc6 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar.js @@ -14,6 +14,10 @@ export class SearchBar extends Interaction { "t-on-safarihack": (ev) => { this.linkHasFocus = ev.detail.linkHasFocus; }, + "t-att-class": () => ({ + "dropdown": this.hasDropdown, + "show": this.hasDropdown, + }), }, ".search-query": { "t-on-input": this.debounced(this.onInput, 400), @@ -127,8 +131,7 @@ export class SearchBar extends Interaction { this.insert(this.menuEl, this.el); this.services["public.interactions"].startInteractions(this.menuEl); } - this.el.classList.toggle("dropdown", !!res); - this.el.classList.toggle("show", !!res); + this.hasDropdown = !!res; prevMenuEl?.remove(); } From f9bbadf0684eee0cb40aefe1937c21f004789824 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Thu, 19 Dec 2024 15:03:18 +0100 Subject: [PATCH 050/150] do not listen to interaction's event impacts --- .../static/src/core/website_edit_service.js | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/addons/website/static/src/core/website_edit_service.js b/addons/website/static/src/core/website_edit_service.js index 60f3f1857d57c..9caafdba92321 100644 --- a/addons/website/static/src/core/website_edit_service.js +++ b/addons/website/static/src/core/website_edit_service.js @@ -1,5 +1,7 @@ import { registry } from "@web/core/registry"; -import { PublicRoot } from '@web/legacy/js/public/public_root'; +import { PublicRoot } from "@web/legacy/js/public/public_root"; +import { Colibri } from "@web/public/colibri"; +import { patch } from "@web/core/utils/patch"; export function buildEditableInteractions(builders) { const result = []; @@ -70,8 +72,7 @@ registry.category("services").add("website_edit", { } else { publicInteractions.startInteractions(target); } - - } + }, }; }, }); @@ -89,3 +90,24 @@ PublicRoot.include({ websiteEdit.update(targetEl, options?.editableMode || false); }, }); + +// Patch Colibri. + +patch(Colibri.prototype, { + addListener(target, event, fn, options) { + fn = fn.bind(this.interaction); + // TODO No jQuery ? + const wysiwyg = $("#wrapwrap").data("wysiwyg"); + let stealthFn = fn; + if (wysiwyg?.odooEditor && !fn.isHandler) { + const name = `${this.interaction.constructor.name}/${event}`; + stealthFn = async (ev) => { + wysiwyg.odooEditor.observerUnactive(name); + const result = await fn(ev); + wysiwyg.odooEditor.observerActive(name); + return result; + }; + } + return super.addListener(target, event, stealthFn, options); + }, +}); From a0a38275ecac41eaecfb7ecd6eef199a68ce39ee Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 11:10:48 +0100 Subject: [PATCH 051/150] test_website: dynamic content syntax fix --- addons/test_website/static/src/interactions/test_error.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/addons/test_website/static/src/interactions/test_error.js b/addons/test_website/static/src/interactions/test_error.js index 44a08faedc7d0..d6fa1af01631b 100644 --- a/addons/test_website/static/src/interactions/test_error.js +++ b/addons/test_website/static/src/interactions/test_error.js @@ -6,7 +6,9 @@ import { rpc } from "@web/core/network/rpc"; class TestError extends Interaction { static selector = ".rpc_error"; dynamicContent = { - "a:t-on-click.prevent": (ev) => rpc(ev.currentTarget.getAttribute("href")), + "a": { + "t-on-click.prevent": (ev) => rpc(ev.currentTarget.getAttribute("href")), + }, } } From 5cf2271cb365936580743b6d358be4129a7134ab Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 11:19:04 +0100 Subject: [PATCH 052/150] no jQuery in hoot --- addons/website/static/src/core/website_edit_service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website/static/src/core/website_edit_service.js b/addons/website/static/src/core/website_edit_service.js index 9caafdba92321..7e253be82da6d 100644 --- a/addons/website/static/src/core/website_edit_service.js +++ b/addons/website/static/src/core/website_edit_service.js @@ -97,7 +97,7 @@ patch(Colibri.prototype, { addListener(target, event, fn, options) { fn = fn.bind(this.interaction); // TODO No jQuery ? - const wysiwyg = $("#wrapwrap").data("wysiwyg"); + const wysiwyg = window.$?.("#wrapwrap").data("wysiwyg"); let stealthFn = fn; if (wysiwyg?.odooEditor && !fn.isHandler) { const name = `${this.interaction.constructor.name}/${event}`; From eed80216707171f660848874d8fcccca6dde2149 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 11:28:41 +0100 Subject: [PATCH 053/150] no snippets menu template in xml --- addons/website/__manifest__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index d3985201e4c0f..f2d862c7cc7df 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -315,6 +315,8 @@ ('remove', 'website/static/src/snippets/**/options.js'), 'website/static/src/snippets/**/*.xml', 'website/static/src/xml/**/*.xml', + ('remove', 'website/static/src/xml/website.editor.xml'), + ('remove', 'website/static/src/xml/web_editor.xml'), 'website/static/src/snippets/s_table_of_content/000.scss', 'google_recaptcha/static/src/js/recaptcha.js', ], From 6c8d3150b5575dc4ca95b4b49823d3ee05adc1c7 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 10:58:47 +0100 Subject: [PATCH 054/150] use correct editableMode option when implicitly starting public widgets --- addons/web/static/src/legacy/js/public/public_root.js | 3 ++- addons/website/static/src/core/website_edit_service.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index 4a2b44f41d8c2..c5f8f6f129c15 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -59,7 +59,8 @@ export const PublicRoot = publicWidget.Widget.extend({ startInteractions(el) { super.startInteractions(el); if (!this.startFromEventHandler) { - publicRoot._startWidgets($(el || this.el), { fromInteractionPatch: true }) + // this.editMode is assigned by website_edit_service + publicRoot._startWidgets($(el || this.el), { fromInteractionPatch: true, editableMode: this.editMode }) } }, stopInteractions(el) { diff --git a/addons/website/static/src/core/website_edit_service.js b/addons/website/static/src/core/website_edit_service.js index 7e253be82da6d..7db89593d3a37 100644 --- a/addons/website/static/src/core/website_edit_service.js +++ b/addons/website/static/src/core/website_edit_service.js @@ -68,6 +68,7 @@ registry.category("services").add("website_edit", { editableInteractions = buildEditableInteractions(builders); } editMode = true; + publicInteractions.editMode = true; publicInteractions.activate(editableInteractions); } else { publicInteractions.startInteractions(target); From 79edbfd02f86f3a7d4bdf8107b209a0694a41c83 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 13:42:01 +0100 Subject: [PATCH 055/150] do not addListener in setup --- .../static/src/interactions/popup/no_backdrop_popup.edit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js b/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js index 7fd35c0aa7e48..92f6eccd2866a 100644 --- a/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js +++ b/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js @@ -2,8 +2,8 @@ import { registry } from "@web/core/registry"; import { NoBackdropPopup } from "./no_backdrop_popup"; export const NoBackdropPopupEdit = (I) => class extends I { - setup() { - super.setup(); + start() { + super.start(); if (this.el.classList.contains("show")) { // Use case: When the "Backdrop" option is disabled in edit mode. // The page scrollbar must be adjusted and events must be added. From 3b4b5142b24d021a0e89cb2ea7ff915c69da8954 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 20 Dec 2024 13:02:04 +0100 Subject: [PATCH 056/150] WebsitePaymentDonation & DonationSnippet --- addons/website_payment/__manifest__.py | 9 +- .../interactions/website_payment_donation.js | 36 ++++ .../static/src/js/website_payment_donation.js | 36 ---- .../static/src/snippets/s_donation/000.js | 192 ------------------ .../s_donation/donation_snippet.edit.js | 13 ++ .../snippets/s_donation/donation_snippet.js | 153 ++++++++++++++ .../views/snippets/s_donation.xml | 2 +- 7 files changed, 211 insertions(+), 230 deletions(-) create mode 100644 addons/website_payment/static/src/interactions/website_payment_donation.js delete mode 100644 addons/website_payment/static/src/js/website_payment_donation.js delete mode 100644 addons/website_payment/static/src/snippets/s_donation/000.js create mode 100644 addons/website_payment/static/src/snippets/s_donation/donation_snippet.edit.js create mode 100644 addons/website_payment/static/src/snippets/s_donation/donation_snippet.js diff --git a/addons/website_payment/__manifest__.py b/addons/website_payment/__manifest__.py index 770b1bdd10d4a..1c5d56e4b3323 100644 --- a/addons/website_payment/__manifest__.py +++ b/addons/website_payment/__manifest__.py @@ -28,7 +28,14 @@ 'website_payment/static/src/snippets/s_donation/options.xml', ], 'web.assets_frontend': [ - 'website_payment/static/src/js/**/*', + 'website_payment/static/src/js/*', + 'website_payment/static/src/interactions/*', + 'website_payment/static/src/snippets/**/*.js', + ('remove', 'website_payment/static/src/snippets/**/*.edit.js'), + ('remove', 'website_payment/static/src/snippets/**/options.js'), + ], + 'website.assets_edit_frontend': [ + 'website_payment/static/src/**/*.edit.js', ], 'web.assets_tests': [ 'website_payment/static/tests/tours/donation.js', diff --git a/addons/website_payment/static/src/interactions/website_payment_donation.js b/addons/website_payment/static/src/interactions/website_payment_donation.js new file mode 100644 index 0000000000000..eaa4b402ed7b0 --- /dev/null +++ b/addons/website_payment/static/src/interactions/website_payment_donation.js @@ -0,0 +1,36 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +export class WebsitePaymentDonation extends Interaction { + static selector = ".o_donation_payment_form"; + dynamicContent = { + ".o_amount_input": { "t-on-focus": this.onFocusAmountInput }, + "#donation_comment_checkbox": { "t-on-change": this.onChangeDonationComment }, + }; + + /** + * @param {Event} ev + */ + onFocusAmountInput(ev) { + const otherAmount = this.el.querySelector("#other_amount"); + if (otherAmount) { + otherAmount.checked = true; + } + } + + /** + * @param {Event} ev + */ + onChangeDonationComment(ev) { + const checked = ev.currentTarget.checked; + const donationCommentEl = this.el.querySelector('#donation_comment'); + donationCommentEl.classList.toggle('d-none', !checked); + if (!checked) { + donationCommentEl.value = ""; + } + } +} + +registry + .category("public.interactions") + .add("website_payment.website_payment_donation", WebsitePaymentDonation); diff --git a/addons/website_payment/static/src/js/website_payment_donation.js b/addons/website_payment/static/src/js/website_payment_donation.js deleted file mode 100644 index 2696537de301e..0000000000000 --- a/addons/website_payment/static/src/js/website_payment_donation.js +++ /dev/null @@ -1,36 +0,0 @@ -import publicWidget from '@web/legacy/js/public/public_widget'; - -publicWidget.registry.WebsitePaymentDonation = publicWidget.Widget.extend({ - selector: '.o_donation_payment_form', - events: { - 'focus .o_amount_input': '_onFocusAmountInput', - 'change #donation_comment_checkbox': '_onChangeDonationComment' - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onFocusAmountInput(ev) { - const otherAmountEl = this.el.querySelector("#other_amount"); - if (otherAmountEl) { - otherAmountEl.checked = true; - } - }, - /** - * @private - * @param {Event} ev - */ - _onChangeDonationComment(ev) { - const donationCommentEl = this.el.querySelector('#donation_comment'); - const checked = ev.currentTarget.checked; - donationCommentEl.classList.toggle('d-none', !checked); - if (!checked) { - donationCommentEl.value = ""; - } - }, -}); diff --git a/addons/website_payment/static/src/snippets/s_donation/000.js b/addons/website_payment/static/src/snippets/s_donation/000.js deleted file mode 100644 index c6cca7fff2ed8..0000000000000 --- a/addons/website_payment/static/src/snippets/s_donation/000.js +++ /dev/null @@ -1,192 +0,0 @@ -import { formatCurrency } from "@web/core/currency"; -import { _t } from "@web/core/l10n/translation"; -import publicWidget from '@web/legacy/js/public/public_widget'; -import { rpc } from "@web/core/network/rpc"; - -const CUSTOM_BUTTON_EXTRA_WIDTH = 10; -let cachedCurrency; - -publicWidget.registry.DonationSnippet = publicWidget.Widget.extend({ - selector: '.s_donation', - disabledInEditableMode: false, - events: { - 'click .s_donation_btn': '_onClickPrefilledButton', - 'click .s_donation_donate_btn': '_onClickDonateNowButton', - 'input #s_donation_range_slider': '_onInputRangeSlider', - }, - - /** - * @override - */ - async start() { - await this._super(...arguments); - this.$rangeSlider = this.$('#s_donation_range_slider'); - this.defaultAmount = this.el.dataset.defaultAmount; - if (this.$rangeSlider.length) { - this.$rangeSlider.val(this.defaultAmount); - this._setBubble(this.$rangeSlider); - } - await this._displayCurrencies(); - const customButtonEl = this.el.querySelector("#s_donation_amount_input"); - if (customButtonEl) { - const canvasEl = document.createElement("canvas"); - const context = canvasEl.getContext("2d"); - context.font = window.getComputedStyle(customButtonEl).font; - const width = context.measureText(customButtonEl.placeholder).width; - customButtonEl.style.maxWidth = `${Math.ceil(width) + CUSTOM_BUTTON_EXTRA_WIDTH}px`; - } - }, - /** - * @override - */ - destroy() { - const customButtonEl = this.el.querySelector("#s_donation_amount_input"); - if (customButtonEl) { - customButtonEl.style.maxWidth = ""; - } - this.$el.find('.s_donation_currency').remove(); - this._deselectPrefilledButtons(); - this.$('.alert-danger').remove(); - this._super(...arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _deselectPrefilledButtons() { - this.$('.s_donation_btn').removeClass('active'); - }, - /** - * @private - * @param {jQuery} $range - */ - _setBubble($range) { - const $bubble = this.$('.s_range_bubble'); - const val = $range.val(); - const min = $range[0].min || 0; - const max = $range[0].max || 100; - const newVal = Number(((val - min) * 100) / (max - min)); - const tipOffsetLow = 8 - (newVal * 0.16); // the range thumb size is 16px*16px. The '8' and the '0.16' are related to that 16px (50% and 1% of 16px) - $bubble.contents().filter(function () { - return this.nodeType === 3; - }).replaceWith(val); - - // Sorta magic numbers based on size of the native UI thumb (source: https://css-tricks.com/value-bubbles-for-range-inputs/) - $bubble[0].style.left = `calc(${newVal}% + (${tipOffsetLow}px))`; - }, - /** - * @private - */ - _displayCurrencies() { - return this._getCachedCurrency().then((result) => { - // No need to recreate the elements if the currency is already set. - if (this.currency === result) { - return; - } - this.currency = result; - this.$('.s_donation_currency').remove(); - const $prefilledButtons = this.$('.s_donation_btn, .s_range_bubble'); - $prefilledButtons.toArray().forEach((button) => { - const before = result.position === "before"; - const $currencySymbol = document.createElement('span'); - $currencySymbol.innerText = result.symbol; - $currencySymbol.classList.add('s_donation_currency', before ? "pe-1" : "ps-1"); - if (before) { - $(button).prepend($currencySymbol); - } else { - $(button).append($currencySymbol); - } - }); - }); - }, - /** - * @private - */ - _getCachedCurrency() { - return cachedCurrency - ? Promise.resolve(cachedCurrency) - : rpc("/website/get_current_currency").then((result) => { - cachedCurrency = result; - return result; - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onClickPrefilledButton(ev) { - const $button = $(ev.currentTarget); - this._deselectPrefilledButtons(); - $button.addClass('active'); - if (this.$rangeSlider.length) { - this.$rangeSlider.val($button[0].dataset.donationValue); - this._setBubble(this.$rangeSlider); - } - }, - /** - * @private - */ - _onClickDonateNowButton(ev) { - if (this.editableMode) { - return; - }; - this.$('.alert-danger').remove(); - const $buttons = this.$('.s_donation_btn'); - const $selectedButton = $buttons.filter('.active'); - let amount = $selectedButton.length ? $selectedButton[0].dataset.donationValue : 0; - if (this.el.dataset.displayOptions && !amount) { - if (this.$rangeSlider.length) { - amount = this.$rangeSlider.val(); - } else if ($buttons.length) { - amount = parseFloat(this.$('#s_donation_amount_input').val()); - let errorMessage = ''; - const minAmount = parseFloat(this.el.dataset.minimumAmount); - if (!amount) { - errorMessage = _t("Please select or enter an amount"); - } else if (amount < minAmount) { - errorMessage = _t( - "The minimum donation amount is %(amount)s", - { - amount: formatCurrency(minAmount, this.currency.id), - } - ); - } - if (errorMessage) { - $(ev.currentTarget).before($('

', { - class: 'alert alert-danger', - text: errorMessage, - })); - return; - } - } - } - if (!amount) { - amount = this.defaultAmount; - } - const $form = this.$('.s_donation_form'); - $('').attr({type: 'hidden', name: 'amount', value: amount}).appendTo($form); - $('').attr({type: 'hidden', name: 'currency_id', value: this.currency.id}).appendTo($form); - $('').attr({type: 'hidden', name: 'csrf_token', value: odoo.csrf_token}).appendTo($form); - $('').attr({type: 'hidden', name: 'donation_options', value: JSON.stringify(this.el.dataset)}).appendTo($form); - $form.submit(); - }, - /** - * @private - */ - _onInputRangeSlider(ev) { - this._deselectPrefilledButtons(); - this._setBubble($(ev.currentTarget)); - }, -}); - -export default { - DonationSnippet: publicWidget.registry.DonationSnippet, -}; diff --git a/addons/website_payment/static/src/snippets/s_donation/donation_snippet.edit.js b/addons/website_payment/static/src/snippets/s_donation/donation_snippet.edit.js new file mode 100644 index 0000000000000..bdc5ad8ce7e63 --- /dev/null +++ b/addons/website_payment/static/src/snippets/s_donation/donation_snippet.edit.js @@ -0,0 +1,13 @@ +import { DonationSnippet } from "./donation_snippet"; +import { registry } from "@web/core/registry"; + +const DonationSnippetEdit = I => class extends I { + onClickDonate() { } +}; + +registry + .category("public.interactions.edit") + .add("website_payment.donation_snippet", { + Interaction: DonationSnippet, + mixin: DonationSnippetEdit, + }); diff --git a/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js b/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js new file mode 100644 index 0000000000000..86b425e008af2 --- /dev/null +++ b/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js @@ -0,0 +1,153 @@ + +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { formatCurrency } from "@web/core/currency"; +import { rpc } from "@web/core/network/rpc"; +import { _t } from "@web/core/l10n/translation"; + +const CUSTOM_BUTTON_EXTRA_WIDTH = 10; +let cachedCurrency; + +export class DonationSnippet extends Interaction { + static selector = ".s_donation"; + dynamicContent = { + ".s_donation_btn": { + "t-on-click": this.onClickPrefilled, + "t-att-class": (el) => ({ "active": el === this.activeButtonEl }), + }, + ".s_donation_donate_btn": { "t-on-click": this.onClickDonate }, + "#s_donation_range_slider": { "t-on-input": this.onInputRangeSlider }, + }; + + setup() { + this.currency = null; + this.activeButtonEl = null; + this.rangeSliderEl = this.el.querySelector('#s_donation_range_slider'); + this.defaultAmount = this.el.dataset.defaultAmount; + if (!!this.rangeSliderEl) { + this.rangeSliderEl.value = this.defaultAmount; + this.setBubble(); + } + } + + async willStart() { + cachedCurrency ||= await this.waitFor(rpc("/website/get_current_currency")); + this.currency = cachedCurrency; + } + + start() { + const prefilledButtonEls = this.el.querySelectorAll('.s_donation_btn, .s_range_bubble'); + for (const prefilledButtonEl of prefilledButtonEls) { + const insertBefore = this.currency.position === "before"; + const currencyEl = document.createElement('span'); + currencyEl.innerText = this.currency.symbol; + currencyEl.classList.add('s_donation_currency', insertBefore ? "pe-1" : "ps-1"); + this.insert(currencyEl, prefilledButtonEl, insertBefore ? "afterbegin" : "beforeend"); + } + + const customButtonEl = this.el.querySelector("#s_donation_amount_input"); + if (customButtonEl) { + this.registerCleanup(() => { customButtonEl.style.maxWidth = "" }); + const canvasEl = document.createElement("canvas"); + const context = canvasEl.getContext("2d"); + context.font = window.getComputedStyle(customButtonEl).font; + const width = context.measureText(customButtonEl.placeholder).width; + customButtonEl.style.maxWidth = `${Math.ceil(width) + CUSTOM_BUTTON_EXTRA_WIDTH}px`; + } + } + + setBubble() { + const bubbleEl = this.el.querySelector('.s_range_bubble'); + const val = this.rangeSliderEl.value; + const min = this.rangeSliderEl.min || 0; + const max = this.rangeSliderEl.max || 100; + const newVal = Number(((val - min) * 100) / (max - min)); + const tipOffsetLow = 8 - (newVal * 0.16); // the range thumb size is 16px*16px. The '8' and the '0.16' are related to that 16px (50% and 1% of 16px) + + for (const child of bubbleEl.childNodes) { + if (child.nodeType === 3) { + child.nodeValue = val; + } + } + // Sorta magic numbers based on size of the native UI thumb (source: https://css-tricks.com/value-bubbles-for-range-inputs/) + bubbleEl.style.insetInlineStart = `calc(${newVal}% + (${tipOffsetLow}px))`; + } + + /** + * @param {Event} ev + */ + onClickPrefilled(ev) { + this.activeButtonEl = ev.currentTarget; + if (this.rangeSliderEl) { + this.rangeSliderEl.value = this.activeButtonEl.dataset.donationValue; + this.setBubble(); + } + } + + /** + * @param {Event} ev + */ + onClickDonate(ev) { + this.el.querySelector('.alert-danger')?.remove(); + const donationButtonEls = this.el.querySelectorAll('.s_donation_btn'); + let amount = this.activeButtonEl ? parseFloat(this.activeButtonEl.dataset.donationValue) : 0; + if (this.el.dataset.displayOptions && !amount) { + if (this.rangeSliderEl) { + amount = parseFloat(this.rangeSliderEl.value); + } else if (donationButtonEls.length) { + amount = parseFloat(this.el.querySelector('#s_donation_amount_input').value); + let errorMessage = ''; + const minAmount = parseFloat(this.el.dataset.minimumAmount); + if (!amount) { + errorMessage = _t("Please select or enter an amount"); + } else if (amount < minAmount) { + errorMessage = _t( + "The minimum donation amount is %(amount)s", + { + amount: formatCurrency(minAmount, this.currency.id), + } + ); + } + if (errorMessage) { + const pEl = document.createElement("p"); + pEl.classList.add("alert alert-danger"); + pEl.innerText = errorMessage; + this.insert(pEl, ev.currentTarget, "beforebegin"); + return; + } + } + } + if (!amount) { + amount = this.defaultAmount; + } + const formEl = this.el.querySelector('.s_donation_form'); + + const inputsParams = [ + ["amount", amount], + ["currency_id", this.currency.id], + ["csrf_token", odoo.csrf_token], + ["donation_options", JSON.stringify(this.el.dataset)], + ]; + + for (const inputParams of inputsParams) { + const inputEl = document.createElement("input"); + inputEl.setAttribute("type", "hidden"); + inputEl.setAttribute("name", inputParams[0]); + inputEl.setAttribute("value", inputParams[1]); + this.insert(inputEl, formEl); + } + + formEl.submit(); + } + + onInputRangeSlider() { + this.activeButtonEl = null; + this.setBubble(); + } + +} + +registry + .category("public.interactions") + .add("website_payment.donation_snippet", DonationSnippet); diff --git a/addons/website_payment/views/snippets/s_donation.xml b/addons/website_payment/views/snippets/s_donation.xml index 3f0e7c8c6fc7c..1bccc1532d261 100644 --- a/addons/website_payment/views/snippets/s_donation.xml +++ b/addons/website_payment/views/snippets/s_donation.xml @@ -119,7 +119,7 @@ Donation 000 JS web.assets_frontend - website_payment/static/src/snippets/s_donation/000.js + website_payment/static/src/snippets/s_donation/donation_snippet.js From 836958f8d69e2a03323e34203894e253bd0f0e05 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 14:52:27 +0100 Subject: [PATCH 057/150] renderToFragment in dynamic snippet --- .../src/snippets/s_dynamic_snippet/dynamic_snippet.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js b/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js index 341c831c806b5..eae71bd091d2b 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js @@ -2,7 +2,7 @@ import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; import { rpc } from "@web/core/network/rpc"; import { uniqueId } from "@web/core/utils/functions"; -import { renderToElement } from "@web/core/utils/render"; +import { renderToFragment } from "@web/core/utils/render"; import { listenSizeChange, utils as uiUtils } from "@web/core/ui/ui_service"; import { markup } from "@odoo/owl"; @@ -27,7 +27,7 @@ export class DynamicSnippet extends Interaction { * @type {*|jQuery.fn.init|jQuery|HTMLElement} */ this.data = []; - this.renderedContentEl = document.createTextNode(""); + this.renderedContentNode = document.createDocumentFragment(); this.uniqueId = uniqueId("s_dynamic_snippet_"); this.templateKey = "website.s_dynamic_snippet.grid"; } @@ -102,7 +102,7 @@ export class DynamicSnippet extends Interaction { * before rendering. */ prepareContent() { - this.renderedContentEl = renderToElement( + this.renderedContentNode = renderToFragment( this.templateKey, this.getQWebRenderOptions() ); @@ -135,7 +135,7 @@ export class DynamicSnippet extends Interaction { this.prepareContent(); } else { this.el.classList.add("o_dynamic_snippet_empty"); - this.renderedContentEl = document.createTextNode(""); + this.renderedContentNode = document.createDocumentFragment(); } this.renderContent(); // TODO What was this about ? Rendered content is already started. @@ -152,7 +152,7 @@ export class DynamicSnippet extends Interaction { allContentLink.href = mainPageUrl; allContentLink.classList.remove("d-none"); } - templateAreaEl.replaceChildren(this.renderedContentEl); + templateAreaEl.replaceChildren(this.renderedContentNode); // TODO this is probably not the only public widget which creates DOM // which should be attached to another public widget. Maybe a generic // method could be added to properly do this operation of DOM addition. From ab42f1ae6f622abd5fb75beeed56393fdcfdc59b Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Fri, 20 Dec 2024 16:23:48 +0100 Subject: [PATCH 058/150] WebsiteForumSpam: renderToFragment template with multiple nodes --- .../static/src/interactions/website_forum_spam.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/addons/website_forum/static/src/interactions/website_forum_spam.js b/addons/website_forum/static/src/interactions/website_forum_spam.js index 7fc895134c46d..b986a72c8a961 100644 --- a/addons/website_forum/static/src/interactions/website_forum_spam.js +++ b/addons/website_forum/static/src/interactions/website_forum_spam.js @@ -1,7 +1,7 @@ import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; import { KeepLast } from "@web/core/utils/concurrency"; -import { renderToElement } from "@web/core/utils/render"; +import { renderToFragment } from "@web/core/utils/render"; import { Interaction } from "@web/public/interaction"; import { cloneContentEls } from "@website/js/utils"; @@ -51,7 +51,8 @@ class WebsiteForumSpam extends Interaction { const childEl = cloneContentEls(post.content).firstElementChild; post.content = childEl.textContent.substring(0, 250); }); - this.insert(renderToElement("website_forum.spam_search_name", { posts }), postSpamEl); + // No need for cleanup, it's already done above. + postSpamEl.append(renderToFragment("website_forum.spam_search_name", { posts })); } async onMarkSpamClick() { From 0f7b24218166b4819f3e633c78c5dee0b167812d Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 19 Dec 2024 14:16:58 +0100 Subject: [PATCH 059/150] UnsplashBeacon --- .../static/src/frontend/unsplash_beacon.js | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/addons/web_unsplash/static/src/frontend/unsplash_beacon.js b/addons/web_unsplash/static/src/frontend/unsplash_beacon.js index d6fc16d72a526..60e48c0037107 100644 --- a/addons/web_unsplash/static/src/frontend/unsplash_beacon.js +++ b/addons/web_unsplash/static/src/frontend/unsplash_beacon.js @@ -1,29 +1,34 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { rpc } from "@web/core/network/rpc"; -publicWidget.registry.UnsplashBeacon = publicWidget.Widget.extend({ - // /!\ To adapt the day the beacon makes sense for backend customizations - selector: '#wrapwrap', +export class UnsplashBeacon extends Interaction { + static selector = "#wrapwrap"; + + async willStart() { + const unsplashImageEls = this.el.querySelectorAll('img[src*="/unsplash/"]'); + const unsplashImageIds = []; + for (const unsplashImageEl of unsplashImageEls) { + // extract the image id from URL (`http://www.domain.com:1234/unsplash/xYdf5feoI/lion.jpg` -> `xYdf5feoI`) + unsplashImageIds.push(unsplashImageEl.src.split('/unsplash/')[1].split('/')[0]); + } - /** - * @override - */ - start: function () { - var unsplashImages = Array.from(this.$('img[src*="/unsplash/"]')).map((img) => { - // get image id from URL (`http://www.domain.com:1234/unsplash/xYdf5feoI/lion.jpg` -> `xYdf5feoI`) - return img.src.split('/unsplash/')[1].split('/')[0]; - }); - if (unsplashImages.length) { - rpc('/web_unsplash/get_app_id').then(function (appID) { - if (!appID) { - return; - } - $.get('https://views.unsplash.com/v', { - 'photo_id': unsplashImages.join(','), + if (unsplashImageIds.length) { + const appID = await this.waitFor(rpc('/web_unsplash/get_app_id')); + + if (appID) { + const fetchURL = new URL("https://views.unsplash.com/v"); + fetchURL.search = new URLSearchParams({ + 'photo_id': unsplashImageIds.join(','), 'app_id': appID, }); - }); + fetch(fetchURL); + } } - return this._super.apply(this, arguments); - }, -}); + } +} + +registry + .category("public.interactions") + .add("web_unsplash.unsplash_beacon", UnsplashBeacon); From 7103a26291978fce12995132dd4f931c2ec6d7f5 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 20 Dec 2024 16:08:43 +0100 Subject: [PATCH 060/150] update tour now that the dropdowns are not closed on scroll --- .../website/static/tests/tours/edit_menus.js | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/addons/website/static/tests/tours/edit_menus.js b/addons/website/static/tests/tours/edit_menus.js index 93224e174dcb4..dc838c17d0755 100644 --- a/addons/website/static/tests/tours/edit_menus.js +++ b/addons/website/static/tests/tours/edit_menus.js @@ -279,17 +279,12 @@ registerWebsitePreviewTour('edit_menus', { }, }, { - content: "The Home menu should be closed", - trigger: ':iframe .top_menu .nav-item:contains("Home"):has(ul:not(.show))', - }, - { - content: "Open the Home menu after scroll", - trigger: ':iframe .top_menu .nav-item a.dropdown-toggle:contains("Home")', - run: "click", + content: "The Home menu should not be closed", + trigger: ':iframe .top_menu .nav-item:contains("Home"):has(ul.show)', }, { - content: "Check that the Home menu is opened", - trigger: ':iframe .top_menu .nav-item:contains("Home") ul.show li' + + content: "Check that the Home menu contains the contact us button", + trigger: ':iframe .top_menu .nav-item:contains("Home"):has(ul.show)' + ' a.dropdown-item:contains("Contact us")[href="/contactus"]', }, { @@ -321,18 +316,8 @@ registerWebsitePreviewTour('edit_menus', { } }, { - content: "Check that the mega menu is closed", - trigger: ':iframe .top_menu .nav-item:contains("Megaaaaa!"):has(div[data-name="Mega Menu"]:not(.show))', - }, - { - content: "Open the mega menu after scroll", - trigger: ':iframe .top_menu .nav-item a.o_mega_menu_toggle:contains("Megaaaaa!")', - run: "click", - }, - { - content: "Check that the mega menu is opened", - trigger: ':iframe .top_menu .nav-item:has(a.o_mega_menu_toggle:contains("Megaaaaa!")) ' + - '.s_mega_menu_odoo_menu', + content: "Check that the mega menu is not closed", + trigger: ':iframe .top_menu .nav-item:contains("Megaaaaa!"):has(div[data-name="Mega Menu"].show)', }, ...clickOnEditAndWaitEditMode(), { From 7f5db59058c85ad723dce5d090408d07c43f4924 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 16:26:36 +0100 Subject: [PATCH 061/150] bind share modal removal to modal --- .../static/src/interactions/website_forum_share.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website_forum/static/src/interactions/website_forum_share.js b/addons/website_forum/static/src/interactions/website_forum_share.js index 3f422aa467441..6f7433a56f6c6 100644 --- a/addons/website_forum/static/src/interactions/website_forum_share.js +++ b/addons/website_forum/static/src/interactions/website_forum_share.js @@ -16,7 +16,7 @@ class WebsiteForumShare extends Interaction { target_type: socialData.targetType, state: questionEl.dataset.state, }); - this.addListener(modalEl, "hidden.bs.modal", modalEl.remove); + this.addListener(modalEl, "hidden.bs.modal", () => modalEl.remove()); this.insert(modalEl, document.body); if (modalEl.querySelector(".s_share")) { From 194131af03777e57c99293d100a698f0122d29b9 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 20 Dec 2024 14:59:47 +0100 Subject: [PATCH 062/150] no disabled 000.js --- addons/website/tests/test_disable_unused_snippets_assets.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/addons/website/tests/test_disable_unused_snippets_assets.py b/addons/website/tests/test_disable_unused_snippets_assets.py index eaca5348e1a3c..e853a43cfa214 100644 --- a/addons/website/tests/test_disable_unused_snippets_assets.py +++ b/addons/website/tests/test_disable_unused_snippets_assets.py @@ -27,13 +27,11 @@ def setUp(self): def test_homepage_outdated_and_mega_menu_up_to_date(self): self.Website._disable_unused_snippets_assets() - # Old snippet with scss and js + # Old snippet with scss s_website_form_000_scss = self._get_snippet_asset('s_website_form', '000', 'scss') s_website_form_001_scss = self._get_snippet_asset('s_website_form', '001', 'scss') - s_website_form_000_js = self._get_snippet_asset('s_website_form', '000', 'js') self.assertEqual(s_website_form_000_scss.active, True) self.assertEqual(s_website_form_001_scss.active, True) - self.assertEqual(s_website_form_000_js.active, True) # Old snippet with scss and scss variables s_masonry_block_000_scss = self._get_snippet_asset('s_masonry_block', '000', 'scss') @@ -84,10 +82,8 @@ def patched_clear_cache(*cache_names): s_website_form_000_scss = self._get_snippet_asset('s_website_form', '000', 'scss') s_website_form_001_scss = self._get_snippet_asset('s_website_form', '001', 'scss') - s_website_form_000_js = self._get_snippet_asset('s_website_form', '000', 'js') self.assertEqual(s_website_form_000_scss.active, False) self.assertEqual(s_website_form_001_scss.active, True) - self.assertEqual(s_website_form_000_js.active, True) s_masonry_block_000_scss = self._get_snippet_asset('s_masonry_block', '000', 'scss') s_masonry_block_000_variables_scss = self._get_snippet_asset('s_masonry_block', '000_variables', 'scss') From 127721638f2b7c224bff22231680e396bed64beb Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Sun, 22 Dec 2024 16:57:02 +0100 Subject: [PATCH 063/150] WebsiteEventExhibitorConnect --- .../website_event_exhibitor/__manifest__.py | 2 +- .../interactions/event_exhibitor_connect.js | 38 +++++++++ .../static/src/js/event_exhibitor_connect.js | 78 ------------------- 3 files changed, 39 insertions(+), 79 deletions(-) create mode 100644 addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js delete mode 100644 addons/website_event_exhibitor/static/src/js/event_exhibitor_connect.js diff --git a/addons/website_event_exhibitor/__manifest__.py b/addons/website_event_exhibitor/__manifest__.py index 6d1fffb9da084..e591508b3563c 100644 --- a/addons/website_event_exhibitor/__manifest__.py +++ b/addons/website_event_exhibitor/__manifest__.py @@ -35,7 +35,7 @@ 'web.assets_frontend': [ 'website_event_exhibitor/static/src/scss/event_templates_sponsor.scss', 'website_event_exhibitor/static/src/scss/event_exhibitor_templates.scss', - 'website_event_exhibitor/static/src/js/event_exhibitor_connect.js', + 'website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js', 'website_event_exhibitor/static/src/components/exhibitor_connect_closed_dialog/**/*', ], 'web.report_assets_common': [ diff --git a/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js b/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js new file mode 100644 index 0000000000000..0447d8ed94b2f --- /dev/null +++ b/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js @@ -0,0 +1,38 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { redirect } from "@web/core/utils/urls"; +import { ExhibitorConnectClosedDialog } from "../components/exhibitor_connect_closed_dialog/exhibitor_connect_closed_dialog"; + +export class WebsiteEventExhibitorConnect extends Interaction { + static selector = ".o_wesponsor_connect_button"; + dynamicContent = { + _root: { + "t-on-click.stop.prevent": () => this.debounced(this.onClick, 500), + }, + }; + + setup() { + const eventIsOngoing = this.el.dataset.eventIsOngoing || false; + const sponsorIsOngoing = this.el.dataset.sponsorIsOngoing || false; + const userEventManager = this.el.dataset.userEventManager || false; + this.shouldOpenDialog = !userEventManager && !(eventIsOngoing && sponsorIsOngoing); + } + + onClick() { + if (this.shouldOpenDialog) { + return this.openClosedDialog(); + } else { + redirect(this.el.dataset.sponsorUrl); + } + } + + openClosedDialog() { + const sponsorId = parseInt(this.el.dataset.sponsorId); + this.services.dialog.add(ExhibitorConnectClosedDialog, { sponsorId }); + } +} + +registry + .category("public.interactions") + .add("website_event_exhibitor.website_event_exhibitor_connect", WebsiteEventExhibitorConnect); diff --git a/addons/website_event_exhibitor/static/src/js/event_exhibitor_connect.js b/addons/website_event_exhibitor/static/src/js/event_exhibitor_connect.js deleted file mode 100644 index a93d7b2e02956..0000000000000 --- a/addons/website_event_exhibitor/static/src/js/event_exhibitor_connect.js +++ /dev/null @@ -1,78 +0,0 @@ -import { debounce } from "@web/core/utils/timing"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { redirect } from "@web/core/utils/urls"; -import { ExhibitorConnectClosedDialog } from "../components/exhibitor_connect_closed_dialog/exhibitor_connect_closed_dialog"; - -publicWidget.registry.eventExhibitorConnect = publicWidget.Widget.extend({ - selector: '.o_wesponsor_connect_button', - /** - * @override - * @public - */ - init: function () { - this._super(...arguments); - this._onConnectClick = debounce(this._onConnectClick, 500, true).bind(this); - }, - - /** - * @override - * @public - */ - start: function () { - var self = this; - return this._super(...arguments).then(function () { - self.eventIsOngoing = self.el.dataset.eventIsOngoing || false; - self.sponsorIsOngoing = self.el.dataset.sponsorIsOngoing || false; - self.isParticipating = self.el.dataset.isParticipating || false; - self.userEventManager = self.el.dataset.userEventManager || false; - self.el.addEventListener("click", self._onConnectClick); - }); - }, - - /** - * @override - * @public - */ - destory () { - this._super(...arguments); - this.el.removeEventListener("click", this._onConnectClick); - }, - - //-------------------------------------------------------------------------- - // Handlers - //------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - * On click, if sponsor is not within opening hours, display a modal instead - * of redirecting on the sponsor view; - */ - _onConnectClick: function (ev) { - ev.stopPropagation(); - ev.preventDefault(); - - if (this.userEventManager) { - redirect(this.el.dataset.sponsorUrl); - } else if (!this.eventIsOngoing || ! this.sponsorIsOngoing) { - return this._openClosedDialog(); - } else { - redirect(this.el.dataset.sponsorUrl); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - _openClosedDialog: function () { - const sponsorId = parseInt(this.el.dataset.sponsorId); - this.call("dialog", "add", ExhibitorConnectClosedDialog, { sponsorId }); - }, - -}); - - -export default { - eventExhibitorConnect: publicWidget.registry.eventExhibitorConnect, -}; From 2d1284debeac3bc85cdbd0665e84a2dc17c29bb5 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 23 Dec 2024 12:18:03 +0100 Subject: [PATCH 064/150] fix test_01_beacon (relied on patching jQuery) --- addons/website/tests/test_unsplash_beacon.py | 29 +++++++------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/addons/website/tests/test_unsplash_beacon.py b/addons/website/tests/test_unsplash_beacon.py index c69329015adb1..e9efcad7d6ac7 100644 --- a/addons/website/tests/test_unsplash_beacon.py +++ b/addons/website/tests/test_unsplash_beacon.py @@ -21,25 +21,16 @@ def test_01_beacon(self): production system. -->

From 6f7487fdb0ff13d3982194a098d2f4b093ae1b48 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 13:42:42 +0100 Subject: [PATCH 065/150] fixes --- addons/website/static/src/interactions/text_highlights.js | 6 ++++++ .../static/src/interactions/event_exhibitor_connect.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/addons/website/static/src/interactions/text_highlights.js b/addons/website/static/src/interactions/text_highlights.js index 13a9fdb180f94..064feb5eda0a5 100644 --- a/addons/website/static/src/interactions/text_highlights.js +++ b/addons/website/static/src/interactions/text_highlights.js @@ -132,3 +132,9 @@ class TextHighlight extends Interaction { registry .category("public.interactions") .add("website.text_highlight", TextHighlight); + +registry + .category("public.interactions.edit") + .add("website.text_highlight", { + Interaction: TextHighlight, + }); diff --git a/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js b/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js index 0447d8ed94b2f..4984bbd8a61d9 100644 --- a/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js +++ b/addons/website_event_exhibitor/static/src/interactions/event_exhibitor_connect.js @@ -8,7 +8,7 @@ export class WebsiteEventExhibitorConnect extends Interaction { static selector = ".o_wesponsor_connect_button"; dynamicContent = { _root: { - "t-on-click.stop.prevent": () => this.debounced(this.onClick, 500), + "t-on-click.stop.prevent": this.debounced(this.onClick, 500), }, }; From 3d2ac48bbbe98a58ee980e596e415d715aa18666 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 19 Dec 2024 13:45:14 +0100 Subject: [PATCH 066/150] AddressForm --- .../website_sale_autocomplete/__manifest__.py | 2 +- .../static/src/interactions/address_form.js | 98 ++++++++++++++++ .../static/src/js/address_form.js | 107 ------------------ 3 files changed, 99 insertions(+), 108 deletions(-) create mode 100644 addons/website_sale_autocomplete/static/src/interactions/address_form.js delete mode 100644 addons/website_sale_autocomplete/static/src/js/address_form.js diff --git a/addons/website_sale_autocomplete/__manifest__.py b/addons/website_sale_autocomplete/__manifest__.py index e0ceaab4d5225..c3872d380ea07 100644 --- a/addons/website_sale_autocomplete/__manifest__.py +++ b/addons/website_sale_autocomplete/__manifest__.py @@ -15,7 +15,7 @@ ], 'assets': { 'web.assets_frontend': [ - 'website_sale_autocomplete/static/src/js/address_form.js', + 'website_sale_autocomplete/static/src/interactions/address_form.js', 'website_sale_autocomplete/static/src/xml/autocomplete.xml', ], 'web.assets_tests': [ diff --git a/addons/website_sale_autocomplete/static/src/interactions/address_form.js b/addons/website_sale_autocomplete/static/src/interactions/address_form.js new file mode 100644 index 0000000000000..add4c8a084b16 --- /dev/null +++ b/addons/website_sale_autocomplete/static/src/interactions/address_form.js @@ -0,0 +1,98 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { rpc } from "@web/core/network/rpc"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { renderToElement } from "@web/core/utils/render"; + +class AddressForm extends Interaction { + static selector = ".oe_cart .checkout_autoformat"; + static selectorHas = "input[name='street'][data-autocomplete-enabled='1']"; + dynamicContent = { + "input[name='street']": { "t-on-input": (ev) => this.debounced(this.onInputStreet(ev.currentTarget), 200) }, + ".js_autocomplete_result": { "t-on-click": this.onClickAutocompleteResult }, + }; + + setup() { + this.streetAndNumberInput = this.el.querySelector("input[name='street']"); + this.cityInput = this.el.querySelector("input[name='city']"); + this.zipInput = this.el.querySelector("input[name='zip']"); + this.countrySelect = this.el.querySelector("select[name='country_id']"); + this.stateSelect = this.el.querySelector("select[name='state_id']"); + this.keepLast = new KeepLast(); + this.sessionId = this.generateUUID(); + } + + generateUUID() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + async onInputStreet(inputEl) { + const inputContainerEl = inputEl.parentNode; + if (inputEl.value.length >= 5) { + this.keepLast.add( + rpc("/autocomplete/address", { + partial_address: inputEl.value, + session_id: this.sessionId || null, + }).then((response) => { + inputContainerEl.querySelector(".dropdown-menu")?.remove(); + inputContainerEl.appendChild(renderToElement("website_sale_autocomplete.AutocompleteDropDown", { + results: response.results, + })); + if (response.session_id) { + this.sessionId = response.session_id; + } + }) + ); + } else { + inputContainerEl.querySelector(".dropdown-menu")?.remove(); + } + } + + async onClickAutocompleteResult(ev) { + const dropdownEl = ev.currentTarget.parentNode; + dropdownEl.innerText = ""; + dropdownEl.classList.add("d-flex", "justify-content-center", "align-items-center"); + + const spinnerEl = document.createElement("div"); + spinnerEl.classList.add("spinner-border", "text-warning", "text-center", "m-auto"); + dropdownEl.appendChild(spinnerEl); + + const address = await this.waitFor(rpc("/autocomplete/address_full", { + address: ev.currentTarget.innerText, + google_place_id: ev.currentTarget.dataset.googlePlaceId, + session_id: this.sessionId || null, + })); + + if (address.formatted_street_number) { + this.streetAndNumberInput.value = address.formatted_street_number; + } + // Text fields, empty if no value in order to avoid the user missing old data. + this.zipInput.value = address.zip || ""; + this.cityInput.value = address.city || ""; + + // Selects based on odoo ids + if (address.country) { + this.countrySelect.value = address.country; + // Let the state select know that the country has changed so that it may fetch the correct states or disappear. + this.countrySelect.dispatchEvent(new Event("change", { bubbles: true })); + } + if (address.state) { + // Waits for the stateSelect to update before setting the state. + new MutationObserver((entries, observer) => { + this.stateSelect.value = address.state; + observer.disconnect(); + }).observe(this.stateSelect, { + childList: true, // Trigger only if the options change + }); + } + dropdownEl.remove(); + } +} + +registry + .category("public.interactions") + .add("website_sale_autocomplete.address_form", AddressForm); diff --git a/addons/website_sale_autocomplete/static/src/js/address_form.js b/addons/website_sale_autocomplete/static/src/js/address_form.js deleted file mode 100644 index f7637e2d5eae7..0000000000000 --- a/addons/website_sale_autocomplete/static/src/js/address_form.js +++ /dev/null @@ -1,107 +0,0 @@ - -import { rpc } from "@web/core/network/rpc"; -import { KeepLast } from "@web/core/utils/concurrency"; -import { renderToElement } from "@web/core/utils/render"; -import { debounce } from "@web/core/utils/timing"; -import publicWidget from '@web/legacy/js/public/public_widget'; - -publicWidget.registry.AddressForm = publicWidget.Widget.extend({ - selector: '.oe_cart .checkout_autoformat', - selectorHas: 'input[name="street"][data-autocomplete-enabled="1"]', - events: { - 'input input[name="street"]': '_onChangeStreet', - 'click .js_autocomplete_result': '_onClickAutocompleteResult' - }, - init: function() { - this.streetAndNumberInput = document.querySelector('input[name="street"]'); - this.cityInput = document.querySelector('input[name="city"]'); - this.zipInput = document.querySelector('input[name="zip"]'); - this.countrySelect = document.querySelector('select[name="country_id"]'); - this.stateSelect = document.querySelector('select[name="state_id"]'); - this.keepLast = new KeepLast(); - this.sessionId = this._generateUUID(); - - this._onChangeStreet = debounce(this._onChangeStreet, 200); - this._super.apply(this, arguments); - }, - - /** - * Used to generate a unique session ID for the places API. - * - * @private - */ - _generateUUID: function() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - }, - - _hideAutocomplete: function (inputContainer) { - const dropdown = inputContainer.querySelector('.dropdown-menu'); - if (dropdown) { - dropdown.remove(); - } - }, - - _onChangeStreet: async function (ev) { - const inputContainer = ev.currentTarget.parentNode; - if (ev.currentTarget.value.length >= 5) { - this.keepLast.add( - rpc('/autocomplete/address', { - partial_address: ev.currentTarget.value, - session_id: this.sessionId || null - })).then((response) => { - this._hideAutocomplete(inputContainer); - inputContainer.appendChild(renderToElement("website_sale_autocomplete.AutocompleteDropDown", { - results: response.results - })); - if (response.session_id) { - this.sessionId = response.session_id; - } - } - ); - } else { - this._hideAutocomplete(inputContainer); - } - }, - - _onClickAutocompleteResult: async function(ev) { - const dropDown = ev.currentTarget.parentNode; - - const spinner = document.createElement('div'); - dropDown.innerText = ''; - dropDown.classList.add('d-flex', 'justify-content-center', 'align-items-center'); - spinner.classList.add('spinner-border', 'text-warning', 'text-center', 'm-auto'); - dropDown.appendChild(spinner); - - const address = await rpc('/autocomplete/address_full', { - address: ev.currentTarget.innerText, - google_place_id: ev.currentTarget.dataset.googlePlaceId, - session_id: this.sessionId || null - }); - if (address.formatted_street_number) { - this.streetAndNumberInput.value = address.formatted_street_number; - } - // Text fields, empty if no value in order to avoid the user missing old data. - this.zipInput.value = address.zip || ''; - this.cityInput.value = address.city || ''; - - // Selects based on odoo ids - if (address.country) { - this.countrySelect.value = address.country; - // Let the state select know that the country has changed so that it may fetch the correct states or disappear. - this.countrySelect.dispatchEvent(new Event('change', {bubbles: true})); - } - if (address.state) { - // Waits for the stateSelect to update before setting the state. - new MutationObserver((entries, observer) => { - this.stateSelect.value = address.state; - observer.disconnect(); - }).observe(this.stateSelect, { - childList: true, // Trigger only if the options change - }); - } - dropDown.remove(); - }, -}); From 16930df66270205af131689689b0bbc8a45dab6c Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 23 Dec 2024 15:22:46 +0100 Subject: [PATCH 067/150] CountdownPatch also in edit --- addons/website/__manifest__.py | 2 +- ...widget.js => lifecycle_dep_interaction.js} | 18 +++++----- ..._wysiwyg.js => lifecycle_patch_wysiwyg.js} | 6 ++-- ..._lifecycle.js => interaction_lifecycle.js} | 36 +++++++++---------- addons/website/tests/test_ui.py | 6 ++-- 5 files changed, 35 insertions(+), 33 deletions(-) rename addons/website/static/tests/tour_utils/{widget_lifecycle_dep_widget.js => lifecycle_dep_interaction.js} (72%) rename addons/website/static/tests/tour_utils/{widget_lifecycle_patch_wysiwyg.js => lifecycle_patch_wysiwyg.js} (89%) rename addons/website/static/tests/tours/{widget_lifecycle.js => interaction_lifecycle.js} (56%) diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index f2d862c7cc7df..09e02ab5f4abf 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -256,7 +256,7 @@ 'web.assets_tests': [ 'website/static/tests/tour_utils/focus_blur_snippets_options.js', 'website/static/tests/tour_utils/website_preview_test.js', - 'website/static/tests/tour_utils/widget_lifecycle_dep_widget.js', + 'website/static/tests/tour_utils/lifecycle_dep_interaction.js', 'website/static/tests/tours/**/*', ], 'web.assets_backend': [ diff --git a/addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js b/addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js similarity index 72% rename from addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js rename to addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js index 67641445eab83..c560a701d4c12 100644 --- a/addons/website/static/tests/tour_utils/widget_lifecycle_dep_widget.js +++ b/addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js @@ -9,7 +9,7 @@ odoo.loader.bus.addEventListener("module-started", (e) => { const { Interaction } = e.detail.module; - const localStorageKey = 'widgetAndWysiwygLifecycle'; + const localStorageKey = 'interactionAndWysiwygLifecycle'; if (!localStorage.getItem(localStorageKey)) { localStorage.setItem(localStorageKey, '[]'); } @@ -20,28 +20,30 @@ odoo.loader.bus.addEventListener("module-started", (e) => { localStorage.setItem(localStorageKey, newValue); } - // TODO Re-evaluate: possibly became obsolete. class CountdownPatch extends Interaction { static selector = ".s_countdown"; dynamicContent = { "_root": { - // TODO Adapt naming if still needed. - "t-att-class": () => ({ "public_widget_started": true }), + "t-att-class": () => ({ "interaction_started": true }), }, }; - // TODO Handle edit mode. - disabledInEditableMode = false; start() { - addLifecycleStep('widgetStart'); + addLifecycleStep('interactionStart'); } destroy() { - addLifecycleStep('widgetStop'); + addLifecycleStep('interactionStop'); } } registry .category("public.interactions") .add("website.countdown_patch", CountdownPatch); + + registry + .category("public.interactions.edit") + .add("website.countdown_patch", { + Interaction: CountdownPatch, + }); }); diff --git a/addons/website/static/tests/tour_utils/widget_lifecycle_patch_wysiwyg.js b/addons/website/static/tests/tour_utils/lifecycle_patch_wysiwyg.js similarity index 89% rename from addons/website/static/tests/tour_utils/widget_lifecycle_patch_wysiwyg.js rename to addons/website/static/tests/tour_utils/lifecycle_patch_wysiwyg.js index 6136083059244..dedc1595fabff 100644 --- a/addons/website/static/tests/tour_utils/widget_lifecycle_patch_wysiwyg.js +++ b/addons/website/static/tests/tour_utils/lifecycle_patch_wysiwyg.js @@ -8,11 +8,11 @@ odoo.loader.bus.addEventListener("module-started", (e) => { const { WysiwygAdapterComponent } = e.detail.module; - // Duplicated from "@website/../tests/tour_utils/widget_lifecycle_dep_widget" + // Duplicated from "@website/../tests/tour_utils/lifecycle_dep_interaction" // Cannot be imported for some reason, probably because of this being lazy // loaded? function addLifecycleStep(step) { - const localStorageKey = 'widgetAndWysiwygLifecycle'; + const localStorageKey = 'interactionAndWysiwygLifecycle'; const oldValue = window.localStorage.getItem(localStorageKey); const newValue = JSON.stringify(JSON.parse(oldValue).concat(step)); window.localStorage.setItem(localStorageKey, newValue); @@ -26,7 +26,7 @@ odoo.loader.bus.addEventListener("module-started", (e) => { super.setup(...arguments); // The Wysiwyg class is very messy at the moment: it touches the DOM in - // onWillStart hook, mixes OWL & legacy widget, etc. Here we want to + // onWillStart hook, mixes OWL & interaction, etc. Here we want to // test "when the Wysiwyg is started"... for now we will settle on // testing "the first time it touches the DOM", relying on it to be when // he reads what "editable elements" are for the first time, thanks to diff --git a/addons/website/static/tests/tours/widget_lifecycle.js b/addons/website/static/tests/tours/interaction_lifecycle.js similarity index 56% rename from addons/website/static/tests/tours/widget_lifecycle.js rename to addons/website/static/tests/tours/interaction_lifecycle.js index 8dc1668dcbc35..0329bbf2f2778 100644 --- a/addons/website/static/tests/tours/widget_lifecycle.js +++ b/addons/website/static/tests/tours/interaction_lifecycle.js @@ -5,14 +5,14 @@ import { registerWebsitePreviewTour, } from '@website/js/tours/tour_utils'; -// Note: cannot import @website/../tests/tour_utils/widget_lifecycle_dep_widget -// here because that module requires web.public.widget which is not available +// Note: cannot import @website/../tests/tour_utils/lifecycle_dep_interaction +// here because that module requires web.public.interaction which is not available // in the backend, where this tour definition is loaded. Easier to duplicate // that key for now rather than create a whole file to handle this localStorage // key only. -const localStorageKey = 'widgetAndWysiwygLifecycle'; +const localStorageKey = 'interactionAndWysiwygLifecycle'; -registerWebsitePreviewTour("widget_lifecycle", { +registerWebsitePreviewTour("interaction_lifecycle", { url: "/", edition: true, }, () => [ @@ -22,30 +22,30 @@ registerWebsitePreviewTour("widget_lifecycle", { groupName: "Content", }), { - content: "Wait for the widget to be started and empty the widgetAndWysiwygLifecycle list", - trigger: ":iframe .s_countdown.public_widget_started", + content: "Wait for the interaction to be started and empty the interactionAndWysiwygLifecycle list", + trigger: ":iframe .s_countdown.interaction_started", run: () => { // Start recording the calls to the "start" and "destroy" method of - // the widget and the wysiwyg. + // the interaction and the wysiwyg. window.localStorage.setItem(localStorageKey, '[]'); }, }, ...clickOnSave(), { - content: "Wait for the widget to be started", - trigger: ":iframe .s_countdown.public_widget_started", + content: "Wait for the interaction to be started", + trigger: ":iframe .s_countdown.interaction_started", }, ...clickOnEditAndWaitEditMode(), { - content: "Wait for the widget to be started and check the order of the lifecycle method call of the widget and the wysiwyg", - trigger: ":iframe .s_countdown.public_widget_started", + content: "Wait for the interaction to be started and check the order of the lifecycle method call of the interaction and the wysiwyg", + trigger: ":iframe .s_countdown.interaction_started", run() { - const result = JSON.parse(window.localStorage.widgetAndWysiwygLifecycle); - const expected = ["widgetStop", "wysiwygStop", "widgetStart", - "widgetStop", "wysiwygStart", "wysiwygStarted", "widgetStart", + const result = JSON.parse(window.localStorage.interactionAndWysiwygLifecycle); + const expected = ["interactionStop", "wysiwygStop", "interactionStart", + "interactionStop", "wysiwygStart", "wysiwygStarted", "interactionStart", ]; - const alternative = ["widgetStop", "widgetStart", "wysiwygStop", - "widgetStop", "wysiwygStart", "wysiwygStarted", "widgetStart", + const alternative = ["interactionStop", "interactionStart", "wysiwygStop", + "interactionStop", "wysiwygStart", "wysiwygStarted", "interactionStart", ]; const resultIsEqualTo = (arr) => { return arr.length === result.length @@ -58,8 +58,8 @@ registerWebsitePreviewTour("widget_lifecycle", { // comes from the OWL mechanism as the wysiwyg is not present in // the DOM when the page is reloaded. Because it is not // guaranteed that this last call happens before the start of - // the widget at the page reload, two sequences are acceptable - // as a result. + // the interaction at the page reload, two sequences are + // acceptable as a result. console.error(` Expected: ${expected.toString()} Or: ${alternative.toString()} diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index a4988d34109df..142d31cc01cee 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -598,13 +598,13 @@ def test_website_no_dirty_page(self): self.start_tour('/', 'website_no_dirty_page', login='admin') - def test_widget_lifecycle(self): + def test_interaction_lifecycle(self): self.env['ir.asset'].create({ 'name': 'wysiwyg_patch_start_and_destroy', 'bundle': 'website.assets_wysiwyg', - 'path': 'website/static/tests/tour_utils/widget_lifecycle_patch_wysiwyg.js', + 'path': 'website/static/tests/tour_utils/lifecycle_patch_wysiwyg.js', }) - self.start_tour(self.env['website'].get_client_action_url('/'), 'widget_lifecycle', login='admin') + self.start_tour(self.env['website'].get_client_action_url('/'), 'interaction_lifecycle', login='admin') def test_drop_404_ir_attachment_url(self): website_snippets = self.env.ref('website.snippets') From 0fcc8e83c0a2c750d1f9ffb83be292336813def4 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 15:59:50 +0100 Subject: [PATCH 068/150] PortalInvoicePagePayment & PortalMyInvoicesPaymentList --- addons/account_payment/__manifest__.py | 3 +- .../portal_invoice_page_payment.js | 17 +++++++++ .../portal_my_invoices_payment.js | 33 +++++++++++++++++ .../src/js/portal_invoice_page_payment.js | 18 ---------- .../src/js/portal_my_invoices_payment.js | 35 ------------------- 5 files changed, 51 insertions(+), 55 deletions(-) create mode 100644 addons/account_payment/static/src/interactions/portal_invoice_page_payment.js create mode 100644 addons/account_payment/static/src/interactions/portal_my_invoices_payment.js delete mode 100644 addons/account_payment/static/src/js/portal_invoice_page_payment.js delete mode 100644 addons/account_payment/static/src/js/portal_my_invoices_payment.js diff --git a/addons/account_payment/__manifest__.py b/addons/account_payment/__manifest__.py index 81427948dc73c..e5e3599901aa6 100644 --- a/addons/account_payment/__manifest__.py +++ b/addons/account_payment/__manifest__.py @@ -31,8 +31,7 @@ 'assets': { 'web.assets_frontend': [ 'account_payment/static/src/js/payment_form.js', - 'account_payment/static/src/js/portal_invoice_page_payment.js', - 'account_payment/static/src/js/portal_my_invoices_payment.js', + 'account_payment/static/src/interactions/*', ], }, 'post_init_hook': 'post_init_hook', diff --git a/addons/account_payment/static/src/interactions/portal_invoice_page_payment.js b/addons/account_payment/static/src/interactions/portal_invoice_page_payment.js new file mode 100644 index 0000000000000..d8e25fe234390 --- /dev/null +++ b/addons/account_payment/static/src/interactions/portal_invoice_page_payment.js @@ -0,0 +1,17 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +class PortalInvoicePagePayment extends Interaction { + static selector = "#portal_pay"; + + setup() { + if (this.el.dataset.payment) { + (new Modal("#pay_with")).show(); + } + } +} + +registry + .category("public.interactions") + .add("account_payment.portal_invoice_page_payment", PortalInvoicePagePayment); + diff --git a/addons/account_payment/static/src/interactions/portal_my_invoices_payment.js b/addons/account_payment/static/src/interactions/portal_my_invoices_payment.js new file mode 100644 index 0000000000000..fef9c57b9fda6 --- /dev/null +++ b/addons/account_payment/static/src/interactions/portal_my_invoices_payment.js @@ -0,0 +1,33 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { _t } from "@web/core/l10n/translation"; +import { deserializeDateTime } from "@web/core/l10n/dates"; + +const { DateTime } = luxon; + +class PortalMyInvoicesPaymentList extends Interaction { + static selector = ".o_portal_my_doc_table"; + + start() { + const today = DateTime.now().startOf("day"); + const dueDateEls = this.el.querySelectorAll(".o_portal_invoice_due_date"); + for (const dueDateEl of dueDateEls) { + const dateTime = deserializeDateTime(dueDateEl.getAttribute("datetime")).startOf('day'); + const diff = dateTime.diff(today).as("days"); + + const dueDateLabel = + (diff === 0) ? _t("due today") : + (diff > 0) + ? _t("due in %s day(s)", Math.abs(diff).toFixed()) + : _t("%s day(s) overdue", Math.abs(diff).toFixed()); + + // We use `.createTextNode()` to escape possible HTML in translations (XSS) + dueDateEl.replaceChildren(document.createTextNode(dueDateLabel)); + } + } +} + +registry + .category("public.interactions") + .add("account_payment.portal_my_invoices_payment_list", PortalMyInvoicesPaymentList); diff --git a/addons/account_payment/static/src/js/portal_invoice_page_payment.js b/addons/account_payment/static/src/js/portal_invoice_page_payment.js deleted file mode 100644 index 61dce259cd1c1..0000000000000 --- a/addons/account_payment/static/src/js/portal_invoice_page_payment.js +++ /dev/null @@ -1,18 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; - -publicWidget.registry.PortalInvoicePagePayment = publicWidget.Widget.extend({ - selector: "#portal_pay", - - /** - * Show the payment dialog when the context parameter is set. - * - * @returns {void} - */ - start() { - if (this.el.dataset.payment) { - const paymentDialog = new Modal("#pay_with"); - paymentDialog.show(); - } - return this._super(...arguments); - }, -}); diff --git a/addons/account_payment/static/src/js/portal_my_invoices_payment.js b/addons/account_payment/static/src/js/portal_my_invoices_payment.js deleted file mode 100644 index 2e8bbde6905b5..0000000000000 --- a/addons/account_payment/static/src/js/portal_my_invoices_payment.js +++ /dev/null @@ -1,35 +0,0 @@ -import {_t} from "@web/core/l10n/translation"; -import {deserializeDateTime} from "@web/core/l10n/dates"; -import publicWidget from "@web/legacy/js/public/public_widget"; - -const {DateTime} = luxon; - -publicWidget.registry.PortalMyInvoicesPaymentList = publicWidget.Widget.extend({ - selector: ".o_portal_my_doc_table", - - start() { - this._setDueDateLabel(); - return this._super(...arguments); - }, - - _setDueDateLabel() { - const dueDateLabels = this.el.querySelectorAll(".o_portal_invoice_due_date"); - const today = DateTime.now().startOf("day"); - dueDateLabels.forEach((label) => { - const dateTime = deserializeDateTime(label.getAttribute("datetime")).startOf('day'); - const diff = dateTime.diff(today).as("days"); - - let dueDateLabel = ""; - - if (diff === 0) { - dueDateLabel = _t("due today"); - } else if (diff > 0) { - dueDateLabel = _t("due in %s day(s)", Math.abs(diff).toFixed()); - } else { - dueDateLabel = _t("%s day(s) overdue", Math.abs(diff).toFixed()); - } - // We use `.createTextNode()` to escape possible HTML in translations (XSS) - label.replaceChildren(document.createTextNode(dueDateLabel)); - }); - }, -}); From 98c9f6d62223a5517f4b979a96868bae373a616d Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 13:50:48 +0100 Subject: [PATCH 069/150] ProjectRatingImage --- addons/project/__manifest__.py | 2 +- .../src/interactions/portal_rating_image.js | 26 +++++++++++++++++ addons/project/static/src/js/portal_rating.js | 28 ------------------- 3 files changed, 27 insertions(+), 29 deletions(-) create mode 100644 addons/project/static/src/interactions/portal_rating_image.js delete mode 100644 addons/project/static/src/js/portal_rating.js diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 9a67ceebe75af..020cf6caf9eda 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -86,7 +86,7 @@ ], 'web.assets_frontend': [ 'project/static/src/scss/portal_rating.scss', - 'project/static/src/js/portal_rating.js', + 'project/static/src/interactions/*', ], 'web.assets_unit_tests': [ 'project/static/tests/mock_server/**/*', diff --git a/addons/project/static/src/interactions/portal_rating_image.js b/addons/project/static/src/interactions/portal_rating_image.js new file mode 100644 index 0000000000000..fdc6fe0300af3 --- /dev/null +++ b/addons/project/static/src/interactions/portal_rating_image.js @@ -0,0 +1,26 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { parseDate } from '@web/core/l10n/dates'; + +export class ProjectRatingImage extends Interaction { + static selector = ".o_portal_project_rating .o_rating_image"; + + start() { + window.Popover.getOrCreateInstance(this.el, { + placement: "bottom", + trigger: "hover", + html: true, + content: () => { + const duration = parseDate(this.el.dataset.ratingDate).toRelative(); + const ratingEl = document.querySelector('#rating_' + this.el.dataset.id); + ratingEl.querySelector(".rating_timeduration").textContent = duration; + return ratingEl.outerHTML; + }, + }); + } +} + +registry + .category("public.interactions") + .add("project.project_rating_image", ProjectRatingImage); diff --git a/addons/project/static/src/js/portal_rating.js b/addons/project/static/src/js/portal_rating.js deleted file mode 100644 index 580c870795bdf..0000000000000 --- a/addons/project/static/src/js/portal_rating.js +++ /dev/null @@ -1,28 +0,0 @@ -import publicWidget from '@web/legacy/js/public/public_widget'; -import { parseDate } from '@web/core/l10n/dates'; - -publicWidget.registry.ProjectRatingImage = publicWidget.Widget.extend({ - selector: '.o_portal_project_rating .o_rating_image', - - /** - * @override - */ - start: function () { - this.$el.popover({ - placement: 'bottom', - trigger: 'hover', - html: true, - content: function () { - var $elem = $(this); - var id = $elem.data('id'); - var ratingDate = $elem.data('rating-date'); - var baseDate = parseDate(ratingDate); - var duration = baseDate.toRelative(); - var $rating = $('#rating_' + id); - $rating.find('.rating_timeduration').text(duration); - return $rating.html(); - }, - }); - return this._super.apply(this, arguments); - }, -}); From 9e84547c911e1d7b688ddd1eaafbf7b787a54658 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 13:59:51 +0100 Subject: [PATCH 070/150] LoyaltyCard --- addons/loyalty/__manifest__.py | 1 + .../static/src/interactions/loyalty_card.js | 21 +++++++++++++++++++ .../static/src/js/portal/loyalty_card.js | 18 ---------------- 3 files changed, 22 insertions(+), 18 deletions(-) create mode 100644 addons/loyalty/static/src/interactions/loyalty_card.js delete mode 100644 addons/loyalty/static/src/js/portal/loyalty_card.js diff --git a/addons/loyalty/__manifest__.py b/addons/loyalty/__manifest__.py index 2b8bfb529994e..af1771c25d015 100644 --- a/addons/loyalty/__manifest__.py +++ b/addons/loyalty/__manifest__.py @@ -37,6 +37,7 @@ ], 'web.assets_frontend': [ 'loyalty/static/src/js/portal/**/*', + 'loyalty/static/src/interactions/*', ], }, 'installable': True, diff --git a/addons/loyalty/static/src/interactions/loyalty_card.js b/addons/loyalty/static/src/interactions/loyalty_card.js new file mode 100644 index 0000000000000..464c2134f6e3b --- /dev/null +++ b/addons/loyalty/static/src/interactions/loyalty_card.js @@ -0,0 +1,21 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { rpc } from '@web/core/network/rpc'; +import { PortalLoyaltyCardDialog } from '../js/portal/loyalty_card_dialog/loyalty_card_dialog'; + +export class LoyaltyCard extends Interaction { + static selector = ".o_loyalty_container"; + dynamicContent = { + ".o_loyalty_card": { "t-on-click": (ev) => this.onClickLoyaltyCard(ev.currentTarget.dataset.card_id) }, + }; + + async onClickLoyaltyCard(cardId) { + const data = await this.waitFor(rpc(`/my/loyalty_card/${cardId}/values`)); + this.services.dialog.add(PortalLoyaltyCardDialog, data); + } +} + +registry + .category("public.interactions") + .add("loyalty.loyalty_card", LoyaltyCard); diff --git a/addons/loyalty/static/src/js/portal/loyalty_card.js b/addons/loyalty/static/src/js/portal/loyalty_card.js deleted file mode 100644 index 8779389da611a..0000000000000 --- a/addons/loyalty/static/src/js/portal/loyalty_card.js +++ /dev/null @@ -1,18 +0,0 @@ -import { rpc } from '@web/core/network/rpc'; -import publicWidget from '@web/legacy/js/public/public_widget'; - -import { PortalLoyaltyCardDialog } from './loyalty_card_dialog/loyalty_card_dialog'; - -publicWidget.registry.PortalLoyaltyWidget = publicWidget.Widget.extend({ - selector: '.o_loyalty_container', - events: { - 'click .o_loyalty_card': '_onClickLoyaltyCard', - }, - - async _onClickLoyaltyCard(ev) { - const card_id = ev.currentTarget.dataset.card_id; - let data = await rpc(`/my/loyalty_card/${card_id}/values`); - this.call("dialog", "add", PortalLoyaltyCardDialog, data); - }, - -}); From 4c73804185c8bda0b19278d9758909aec110f8a1 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 14:09:32 +0100 Subject: [PATCH 071/150] SignUpForm --- .../static/src/interactions/sign_up_form.js | 28 +++++++++++++++++++ addons/auth_signup/static/src/js/signup.js | 23 --------------- 2 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 addons/auth_signup/static/src/interactions/sign_up_form.js delete mode 100644 addons/auth_signup/static/src/js/signup.js diff --git a/addons/auth_signup/static/src/interactions/sign_up_form.js b/addons/auth_signup/static/src/interactions/sign_up_form.js new file mode 100644 index 0000000000000..2b7ff5ba03328 --- /dev/null +++ b/addons/auth_signup/static/src/interactions/sign_up_form.js @@ -0,0 +1,28 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +export class SignUpForm extends Interaction { + static selector = ".oe_signup_form"; + dynamicContent = { + _root: { "t-on-submit": this.onSubmit }, + ".oe_login_buttons > button[type='submit']": { "t-att-disabled": this.submitElStatus }, + }; + + setup() { + this.submitElStatus = null; + } + + onSubmit() { + const submitEl = document.querySelector(".oe_login_buttons > button[type='submit']"); + if (!this.submitElStatus) { + this.submitElStatus = "disabled"; + const refreshEl = document.createElement("i"); + refreshEl.classList.add("fa fa-refresh fa-spin"); + this.insert(refreshEl, submitEl, "beforebegin"); + } + } +} + +registry + .category("public.interactions") + .add("auth_signup.sign_up_form", SignUpForm); diff --git a/addons/auth_signup/static/src/js/signup.js b/addons/auth_signup/static/src/js/signup.js deleted file mode 100644 index 7a098a9b8e7ec..0000000000000 --- a/addons/auth_signup/static/src/js/signup.js +++ /dev/null @@ -1,23 +0,0 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; - -publicWidget.registry.SignUpForm = publicWidget.Widget.extend({ - selector: '.oe_signup_form', - events: { - 'submit': '_onSubmit', - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onSubmit: function () { - const btn = this.$('.oe_login_buttons > button[type="submit"]'); - if (!btn.prop("disabled")) { - btn.attr("disabled", "disabled"); - btn.prepend(' '); - } - }, -}); From be349402039aeca11a9c70473bea0f5591bb5b7c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 14:14:14 +0100 Subject: [PATCH 072/150] PasskeyLogin --- addons/auth_passkey/__manifest__.py | 2 +- .../static/src/interactions/passkey_login.js | 28 +++++++++++++++++++ .../auth_passkey/static/src/login_passkeys.js | 18 ------------ 3 files changed, 29 insertions(+), 19 deletions(-) create mode 100644 addons/auth_passkey/static/src/interactions/passkey_login.js delete mode 100644 addons/auth_passkey/static/src/login_passkeys.js diff --git a/addons/auth_passkey/__manifest__.py b/addons/auth_passkey/__manifest__.py index dc23ccb854a0a..6d70dbe17e676 100644 --- a/addons/auth_passkey/__manifest__.py +++ b/addons/auth_passkey/__manifest__.py @@ -26,7 +26,7 @@ ], 'web.assets_frontend': [ 'auth_passkey/static/lib/simplewebauthn.js', - 'auth_passkey/static/src/login_passkeys.js', + 'auth_passkey/static/src/interactions/*', ], }, 'license': 'LGPL-3', diff --git a/addons/auth_passkey/static/src/interactions/passkey_login.js b/addons/auth_passkey/static/src/interactions/passkey_login.js new file mode 100644 index 0000000000000..c6eb8bb309687 --- /dev/null +++ b/addons/auth_passkey/static/src/interactions/passkey_login.js @@ -0,0 +1,28 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { rpc } from "@web/core/network/rpc"; +import { startAuthentication } from "../../lib/simplewebauthn.js"; + +export class PasskeyLogin extends Interaction { + static selector = ".passkey_login_link"; + dynamicContent = { + _root: { "t-on-click": this.onClick }, + }; + + async onClick() { + const serverOptions = await this.waitFor(rpc("/auth/passkey/start-auth")); + const auth = await this.waitFor(startAuthentication(serverOptions).catch(e => console.error(e))); + if (!auth) { + return false; + } + const form = document.querySelector('form.oe_login_form'); + form.querySelector('input[name="webauthn_response"]').value = JSON.stringify(auth); + form.querySelector('input[name="type"]').value = 'webauthn'; + form.submit(); + } +} + +registry + .category("public.interactions") + .add("auth_passkey.passkey_login", PasskeyLogin); diff --git a/addons/auth_passkey/static/src/login_passkeys.js b/addons/auth_passkey/static/src/login_passkeys.js deleted file mode 100644 index 1a614d48539dd..0000000000000 --- a/addons/auth_passkey/static/src/login_passkeys.js +++ /dev/null @@ -1,18 +0,0 @@ -import { rpc } from "@web/core/network/rpc"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { startAuthentication } from "../lib/simplewebauthn.js"; - -publicWidget.registry.passkeyLogin = publicWidget.Widget.extend({ - selector: '.passkey_login_link', - events: { 'click': '_onclick' }, - - async _onclick() { - const serverOptions = await rpc("/auth/passkey/start-auth"); - const auth = await startAuthentication(serverOptions).catch(e => console.error(e)); - if(!auth) return false; - const form = document.querySelector('form.oe_login_form'); - form.querySelector('input[name="webauthn_response"]').value = JSON.stringify(auth); - form.querySelector('input[name="type"]').value = 'webauthn'; - form.submit(); - } -}) From 09f79e7f438d45f0a5f273741e04d7f4b1d2e610 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 14:29:41 +0100 Subject: [PATCH 073/150] DatetimePicker --- addons/web/__manifest__.py | 1 - .../web/static/src/public/datetime_picker.js | 41 ++++++++++++++++++ .../src/public/datetime_picker_widget.js | 42 ------------------- 3 files changed, 41 insertions(+), 43 deletions(-) create mode 100644 addons/web/static/src/public/datetime_picker.js delete mode 100644 addons/web/static/src/public/datetime_picker_widget.js diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 16061a204a3f2..6fa01d7909082 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -448,7 +448,6 @@ 'web/static/src/public/**/*.js', ('remove', 'web/static/src/public/database_manager.js'), - ('remove', 'web/static/src/public/datetime_picker_widget.js'), # remove this remove when it has been converted ('remove', 'web/static/src/public/error_notifications.js'), 'web/static/src/public/public_component_service.js', 'web/static/src/webclient/clickbot/clickbot.js', diff --git a/addons/web/static/src/public/datetime_picker.js b/addons/web/static/src/public/datetime_picker.js new file mode 100644 index 0000000000000..ddb3dd3f7d40a --- /dev/null +++ b/addons/web/static/src/public/datetime_picker.js @@ -0,0 +1,41 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { + deserializeDate, + deserializeDateTime, + parseDate, + parseDateTime, +} from "@web/core/l10n/dates"; + +class DatetimePicker extends Interaction { + static selector = "[data-widget='datetime-picker']"; + + setup() { + this.minDate = this.el.dataset.minDate; + this.maxDate = this.el.dataset.maxDate; + this.type = this.el.dataset.widgetType || "datetime"; + this.parseFunction = type === "date" ? parseDate : parseDateTime; + this.deserializeFunction = type === "date" ? deserializeDate : deserializeDateTime; + } + + start() { + this.disableDateTimePicker = this.call("datetime_picker", "create", { + target: this.el, + pickerProps: { + type: this.type, + minDate: this.minDate && this.deserializeFunction(this.minDate), + maxDate: this.maxDate && this.deserializeFunction(this.maxDate), + value: this.parseFunction(this.el.value), + }, + }).enable(); + } + + destroy() { + this.disableDateTimePicker(); + } +} + +registry + .category("public.interactions") + .add("web.datetime_picker", DatetimePicker); diff --git a/addons/web/static/src/public/datetime_picker_widget.js b/addons/web/static/src/public/datetime_picker_widget.js deleted file mode 100644 index 6ec448f4ef507..0000000000000 --- a/addons/web/static/src/public/datetime_picker_widget.js +++ /dev/null @@ -1,42 +0,0 @@ -import { - deserializeDate, - deserializeDateTime, - parseDate, - parseDateTime, -} from "@web/core/l10n/dates"; -import PublicWidget from "@web/legacy/js/public/public_widget"; - -export const DateTimePickerWidget = PublicWidget.Widget.extend({ - selector: "[data-widget='datetime-picker']", - disabledInEditableMode: true, - - /** - * @override - */ - start() { - this._super(...arguments); - const { widgetType, minDate, maxDate } = this.el.dataset; - const type = widgetType || "datetime"; - const { value } = this.el; - const [parse, deserialize] = - type === "date" ? [parseDate, deserializeDate] : [parseDateTime, deserializeDateTime]; - this.disableDateTimePicker = this.call("datetime_picker", "create", { - target: this.el, - pickerProps: { - type, - minDate: minDate && deserialize(minDate), - maxDate: maxDate && deserialize(maxDate), - value: parse(value), - }, - }).enable(); - }, - /** - * @override - */ - destroy() { - this.disableDateTimePicker(); - return this._super(...arguments); - }, -}); - -PublicWidget.registry.DateTimePickerWidget = DateTimePickerWidget; From 62c4ed4c727960adf1810295c99e54fbde71700c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 15:21:41 +0100 Subject: [PATCH 074/150] PurchaseDatetimePicker --- addons/purchase/__manifest__.py | 2 +- .../interactions/purchase_datetimepicker.js | 34 ++++++++++++++++++ .../static/src/js/purchase_datetimepicker.js | 35 ------------------- 3 files changed, 35 insertions(+), 36 deletions(-) create mode 100644 addons/purchase/static/src/interactions/purchase_datetimepicker.js delete mode 100644 addons/purchase/static/src/js/purchase_datetimepicker.js diff --git a/addons/purchase/__manifest__.py b/addons/purchase/__manifest__.py index 2fac157274320..d3986010a9d67 100644 --- a/addons/purchase/__manifest__.py +++ b/addons/purchase/__manifest__.py @@ -50,7 +50,7 @@ 'purchase/static/src/**/*.xml', ], 'web.assets_frontend': [ - 'purchase/static/src/js/purchase_datetimepicker.js', + 'purchase/static/src/interactions/*', 'purchase/static/src/js/purchase_portal_sidebar.js', ], }, diff --git a/addons/purchase/static/src/interactions/purchase_datetimepicker.js b/addons/purchase/static/src/interactions/purchase_datetimepicker.js new file mode 100644 index 0000000000000..7aea411559c23 --- /dev/null +++ b/addons/purchase/static/src/interactions/purchase_datetimepicker.js @@ -0,0 +1,34 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { rpc } from "@web/core/network/rpc"; + +export class PurchaseDatetimePicker extends Interaction { + static selector = ".o-purchase-datetimepicker"; + + start() { + this.disableDateTimePicker = this.call("datetime_picker", "create", { + target: this.el, + onChange: (newDate) => { + const accessToken = this.el.dataset.accessToken; + const orderId = this.el.dataset.orderId; + const lineId = this.el.dataset.lineId; + this.waitFor(rpc(`/my/purchase/${orderId}/update?access_token=${accessToken}`, { + [lineId]: newDate.toISODate(), + })); + }, + pickerProps: { + type: "date", + value: luxon.DateTime.fromISO(this.el.dataset.value), + }, + }).enable(); + } + + destroy() { + this.disableDateTimePicker(); + } +} + +registry + .category("public.interactions") + .add("purchase.purchase_datetime_picker", PurchaseDatetimePicker); diff --git a/addons/purchase/static/src/js/purchase_datetimepicker.js b/addons/purchase/static/src/js/purchase_datetimepicker.js deleted file mode 100644 index 0a40925eaa308..0000000000000 --- a/addons/purchase/static/src/js/purchase_datetimepicker.js +++ /dev/null @@ -1,35 +0,0 @@ -import PublicWidget from "@web/legacy/js/public/public_widget"; -import { rpc } from "@web/core/network/rpc"; - -export const PurchaseDatePicker = PublicWidget.Widget.extend({ - selector: ".o-purchase-datetimepicker", - disabledInEditableMode: true, - - /** - * @override - */ - start() { - this.disableDateTimePicker = this.call("datetime_picker", "create", { - target: this.el, - onChange: (newDate) => { - const { accessToken, orderId, lineId } = this.el.dataset; - rpc(`/my/purchase/${orderId}/update?access_token=${accessToken}`, { - [lineId]: newDate.toISODate(), - }); - }, - pickerProps: { - type: "date", - value: luxon.DateTime.fromISO(this.el.dataset.value), - }, - }).enable(); - }, - /** - * @override - */ - destroy() { - this.disableDateTimePicker(); - return this._super(...arguments); - }, -}); - -PublicWidget.registry.PurchaseDatePicker = PurchaseDatePicker; From 6d4283bf312dac500fd59166383acc11032bd6e9 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 24 Dec 2024 09:34:12 +0100 Subject: [PATCH 075/150] fix some conversions --- .../src/interactions/purchase_datetimepicker.js | 6 ++---- addons/web/static/src/public/datetime_picker.js | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/addons/purchase/static/src/interactions/purchase_datetimepicker.js b/addons/purchase/static/src/interactions/purchase_datetimepicker.js index 7aea411559c23..66837b931d989 100644 --- a/addons/purchase/static/src/interactions/purchase_datetimepicker.js +++ b/addons/purchase/static/src/interactions/purchase_datetimepicker.js @@ -7,12 +7,10 @@ export class PurchaseDatetimePicker extends Interaction { static selector = ".o-purchase-datetimepicker"; start() { - this.disableDateTimePicker = this.call("datetime_picker", "create", { + this.disableDateTimePicker = this.services.datetime_picker.create({ target: this.el, onChange: (newDate) => { - const accessToken = this.el.dataset.accessToken; - const orderId = this.el.dataset.orderId; - const lineId = this.el.dataset.lineId; + const { accessToken, orderId, lineId } = this.el.dataset; this.waitFor(rpc(`/my/purchase/${orderId}/update?access_token=${accessToken}`, { [lineId]: newDate.toISODate(), })); diff --git a/addons/web/static/src/public/datetime_picker.js b/addons/web/static/src/public/datetime_picker.js index ddb3dd3f7d40a..a427d8639750e 100644 --- a/addons/web/static/src/public/datetime_picker.js +++ b/addons/web/static/src/public/datetime_picker.js @@ -15,18 +15,18 @@ class DatetimePicker extends Interaction { this.minDate = this.el.dataset.minDate; this.maxDate = this.el.dataset.maxDate; this.type = this.el.dataset.widgetType || "datetime"; - this.parseFunction = type === "date" ? parseDate : parseDateTime; - this.deserializeFunction = type === "date" ? deserializeDate : deserializeDateTime; } start() { - this.disableDateTimePicker = this.call("datetime_picker", "create", { + const parseFunction = this.type === "date" ? parseDate : parseDateTime; + const deserializeFunction = this.type === "date" ? deserializeDate : deserializeDateTime; + this.disableDateTimePicker = this.services.datetime_picker.create({ target: this.el, pickerProps: { type: this.type, - minDate: this.minDate && this.deserializeFunction(this.minDate), - maxDate: this.maxDate && this.deserializeFunction(this.maxDate), - value: this.parseFunction(this.el.value), + minDate: this.minDate && deserializeFunction(this.minDate), + maxDate: this.maxDate && deserializeFunction(this.maxDate), + value: parseFunction(this.el.value), }, }).enable(); } From 1ab1dddc296fdf16521f652cb496b9b0816f768a Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 24 Dec 2024 10:37:54 +0100 Subject: [PATCH 076/150] .withTarget qualifier (making sure it is before throttled/debounced) --- addons/web/static/src/public/colibri.js | 42 ++++--- .../static/tests/public/interaction.test.js | 103 ++++++++++++++++++ 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/addons/web/static/src/public/colibri.js b/addons/web/static/src/public/colibri.js index d54373c460c7e..c626008a494ab 100644 --- a/addons/web/static/src/public/colibri.js +++ b/addons/web/static/src/public/colibri.js @@ -47,35 +47,47 @@ export class Colibri { "this.addListener can only be called after the interaction is started. Maybe move the call in the start method." ); } - const re = /^(?.*)\.(?prevent|stop|capture|noupdate)$/; + const re = /^(?.*)\.(?prevent|stop|capture|noupdate|withTarget)$/; let groups = re.exec(event)?.groups; while (groups) { fn = { - prevent: (f) => (ev) => { - ev.preventDefault(); - return f.call(this.interaction, ev); - }, - stop: (f) => (ev) => { - ev.stopPropagation(); - return f.call(this.interaction, ev); - }, + 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; }, - noupdate: (f) => (ev) => { - f.call(this.interaction, ev); - return SKIP_IMPLICIT_UPDATE; - }, + 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 - : (ev) => { - if (SKIP_IMPLICIT_UPDATE !== fn.call(this.interaction, ev)) { + : (...args) => { + if (SKIP_IMPLICIT_UPDATE !== fn.call(this.interaction, ...args)) { this.updateContent(); } }; diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index f2a6cb7a0da50..53bec54034679 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -709,6 +709,31 @@ describe("using qualifiers", () => { expect("span").toHaveClass("a"); }); + test("add a listener with the .withTarget qualifier", async () => { + let clicked = false; + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + span: { + "t-on-click.withTarget": this.doSomething, + "t-att-class": () => ({ a: clicked }), + }, + }; + doSomething(ev, el) { + clicked = true; + expect(ev.defaultPrevented).toBe(false); + expect(ev.cancelBubble).toBe(false); + expect(el.tagName).toBe("SPAN"); + } + } + + await startInteraction(Test, TemplateTest); + expect(clicked).toBe(false); + await click("span"); + expect(clicked).toBe(true); + expect("span").toHaveClass("a"); + }); + test("add a listener with several qualifiers", async () => { let clicked = false; class Test extends Interaction { @@ -1956,6 +1981,48 @@ describe("debounced (2)", () => { await advanceTime(500); expect.verifySteps(["click"]); }); + + test("debounced requires .withTarget to access currentTarget", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + _root: { + "t-on-click": this.debounced((ev) => { + expect(ev.currentTarget).toBe(null); + expect.step(ev.type); + }, 500), + }, + }; + } + await startInteraction(Test, TemplateTest); + expect.verifySteps([]); + await click(".test"); + await advanceTime(25); + expect.verifySteps([]); + await advanceTime(500); + expect.verifySteps(["click"]); + }); + + test("debounced receives currentTarget when using .withTarget", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + _root: { + "t-on-click.withTarget": this.debounced((ev, el) => { + expect(el.tagName).toBe("DIV"); + expect.step(ev.type); + }, 500), + }, + }; + } + await startInteraction(Test, TemplateTest); + expect.verifySteps([]); + await click(".test"); + await advanceTime(25); + expect.verifySteps([]); + await advanceTime(500); + expect.verifySteps(["click"]); + }); }); describe("throttled_for_animation (1)", () => { @@ -2070,6 +2137,42 @@ describe("throttled_for_animation (2)", () => { await click(".test"); expect.verifySteps(["click"]); }); + + test("throttledForAnimation does not require .withTarget to access currentTarget", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + _root: { + "t-on-click": this.throttledForAnimation((ev) => { + expect(ev.currentTarget.tagName).toBe("DIV"); + expect.step(ev.type); + }), + }, + }; + } + await startInteraction(Test, TemplateTest); + expect.verifySteps([]); + await click(".test"); + expect.verifySteps(["click"]); + }); + + test("throttledForAnimation receives currentTarget when using .withTarget", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + _root: { + "t-on-click.withTarget": this.throttledForAnimation((ev, el) => { + expect(el.tagName).toBe("DIV"); + expect.step(ev.type); + }), + }, + }; + } + await startInteraction(Test, TemplateTest); + expect.verifySteps([]); + await click(".test"); + expect.verifySteps(["click"]); + }); }); describe("patching", () => { From 45bf27a22da91774549b5e6e9ad47a1ef751a3c9 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 24 Dec 2024 10:56:41 +0100 Subject: [PATCH 077/150] include `minimal_dom` in hoot asset --- addons/website/__manifest__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 09e02ab5f4abf..5add8b499cf38 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -304,6 +304,7 @@ 'web.assets_unit_tests_setup': [ 'web/static/src/legacy/js/core/class.js', 'web/static/src/legacy/js/public/lazyloader.js', + 'web/static/src/legacy/js/public/minimal_dom.js', 'web/static/src/legacy/js/public/public_widget.js', 'web/static/src/legacy/js/public/public_root.js', 'website/static/lib/multirange/*.js', From 8c891c21c565709e70c3d7000c7394edeb6234c1 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 24 Dec 2024 09:48:21 +0100 Subject: [PATCH 078/150] fix donation_snippet --- .../static/src/snippets/s_donation/donation_snippet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js b/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js index 86b425e008af2..9949afcbb7c9b 100644 --- a/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js +++ b/addons/website_payment/static/src/snippets/s_donation/donation_snippet.js @@ -111,7 +111,7 @@ export class DonationSnippet extends Interaction { } if (errorMessage) { const pEl = document.createElement("p"); - pEl.classList.add("alert alert-danger"); + pEl.classList.add("alert", "alert-danger"); pEl.innerText = errorMessage; this.insert(pEl, ev.currentTarget, "beforebegin"); return; From b914f96d6334097e7014bedef8cbc4038d2c1c82 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 24 Dec 2024 09:50:17 +0100 Subject: [PATCH 079/150] fix address_form --- .../static/src/interactions/address_form.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website_sale_autocomplete/static/src/interactions/address_form.js b/addons/website_sale_autocomplete/static/src/interactions/address_form.js index add4c8a084b16..8cd5fa48c1793 100644 --- a/addons/website_sale_autocomplete/static/src/interactions/address_form.js +++ b/addons/website_sale_autocomplete/static/src/interactions/address_form.js @@ -9,7 +9,7 @@ class AddressForm extends Interaction { static selector = ".oe_cart .checkout_autoformat"; static selectorHas = "input[name='street'][data-autocomplete-enabled='1']"; dynamicContent = { - "input[name='street']": { "t-on-input": (ev) => this.debounced(this.onInputStreet(ev.currentTarget), 200) }, + "input[name='street']": { "t-on-input.withTarget": this.debounced(this.onInputStreet, 200) }, ".js_autocomplete_result": { "t-on-click": this.onClickAutocompleteResult }, }; @@ -30,7 +30,7 @@ class AddressForm extends Interaction { }); } - async onInputStreet(inputEl) { + async onInputStreet(ev, inputEl) { const inputContainerEl = inputEl.parentNode; if (inputEl.value.length >= 5) { this.keepLast.add( From b62cde989268797d1bef3f3dd3096f09cd64ae91 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 24 Dec 2024 16:58:23 +0100 Subject: [PATCH 080/150] fixed anchor slide test --- .../tests/interactions/anchor_slide.test.js | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/addons/website/static/tests/interactions/anchor_slide.test.js b/addons/website/static/tests/interactions/anchor_slide.test.js index 3d0bd46baf849..de218742cdd48 100644 --- a/addons/website/static/tests/interactions/anchor_slide.test.js +++ b/addons/website/static/tests/interactions/anchor_slide.test.js @@ -2,7 +2,7 @@ import { describe, expect, test } from "@odoo/hoot"; import { animationFrame, click } from "@odoo/hoot-dom"; import { advanceTime } from "@odoo/hoot-mock"; import { - isElementInViewport, + isElementVerticallyInViewportOf, startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; @@ -27,14 +27,15 @@ test("anchor slide scrolls to targetted location", async () => {
Target
`); - const targetEl = el.querySelector("div#target"); + const scrollEl = el.querySelector("#wrapwrap"); + const targetEl = scrollEl.querySelector("div#target"); expect(core.interactions.length).toBe(1); - expect(isElementInViewport(targetEl)).toBe(false); - await click("a[href]"); - expect(isElementInViewport(targetEl)).toBe(false); + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); + click("a[href]"); // Intentionally not awaited + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); await animationFrame(); await advanceTime(500); // Duration defined in AnchorSlide. - expect(isElementInViewport(targetEl)).toBe(true); + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(true); }); test("without anchor slide instantly reach the targetted location", async () => { @@ -45,14 +46,15 @@ test("without anchor slide instantly reach the targetted location", async () =>
Target
`); - const targetEl = el.querySelector("div#target"); + const scrollEl = el.querySelector("#wrapwrap"); + const targetEl = scrollEl.querySelector("div#target"); expect(core.interactions.length).toBe(1); core.stopInteractions(); expect(core.interactions.length).toBe(0); - expect(isElementInViewport(targetEl)).toBe(false); - await click("a[href]"); + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); + click("a[href]"); // Intentionally not awaited await animationFrame(); - expect(isElementInViewport(targetEl)).toBe(true); + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(true); }); test("anchor slide scrolls to targetted location - with non-ASCII7 characters", async () => { @@ -63,12 +65,13 @@ test("anchor slide scrolls to targetted location - with non-ASCII7 characters",
Target
`); - const targetEl = el.querySelector("div.target"); + const scrollEl = el.querySelector("#wrapwrap"); + const targetEl = scrollEl.querySelector("div.target"); expect(core.interactions.length).toBe(1); - expect(isElementInViewport(targetEl)).toBe(false); - await click("a[href]"); - expect(isElementInViewport(targetEl)).toBe(false); + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); + click("a[href]"); // Intentionally not awaited + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); await animationFrame(); await advanceTime(500); // Duration defined in AnchorSlide. - expect(isElementInViewport(targetEl)).toBe(true); + expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(true); }); From 8922bda66c4a58724c28851b275724e011e2516c Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 23 Dec 2024 14:21:33 +0100 Subject: [PATCH 081/150] fix animation: be visible upon adjusting option --- addons/website/static/src/interactions/animation.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/addons/website/static/src/interactions/animation.js b/addons/website/static/src/interactions/animation.js index dcf330100b7df..1e8ce46146e73 100644 --- a/addons/website/static/src/interactions/animation.js +++ b/addons/website/static/src/interactions/animation.js @@ -7,14 +7,14 @@ export class Animation extends Interaction { static selector = ".o_animate"; dynamicSelectors = { ...this.dynamicSelectors, - _wrapwrap: () => this.wrapwrapEl, + _windowUnlessDropdown: () => this.windowUnlessDropdown, _scrollingTarget: () => this.scrollingTarget, }; dynamicContent = { _window: { "t-on-resize": this.scrollWebsiteAnimate, }, - _wrapwrap: { + _windowUnlessDropdown: { "t-on-shown.bs.modal": this.scrollWebsiteAnimate, "t-on-slid.bs.carousel": this.scrollWebsiteAnimate, "t-on-shown.bs.tab": this.scrollWebsiteAnimate, @@ -52,6 +52,7 @@ export class Animation extends Interaction { setup() { this.wrapwrapEl = document.querySelector("#wrapwrap"); + this.windowUnlessDropdown = this.el.closest(".dropdown") ? [] : window; this.scrollingElement = this.findScrollingElement(); this.scrollingTarget = isScrollableY(this.scrollingElement) ? this.scrollingElement : this.scrollingElement.ownerDocument.defaultView; this.isAnimating = false; @@ -64,12 +65,17 @@ export class Animation extends Interaction { } start() { + if (this.el.closest(".dropdown")) { + return; + } // By default, elements are hidden by the css of o_animate. // Render elements and trigger the animation then pause it in state 0. - if (!this.el.closest(".dropdown") && !this.isAnimateOnScroll) { + if (!this.isAnimateOnScroll) { this.resetAnimation(); this.updateContent(); } + this.scrollWebsiteAnimate(); + this.updateContent(); } findScrollingElement() { From eebd779706cd2e6b6f5d4d5ab2ffb02472b63b31 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 24 Dec 2024 15:12:03 +0100 Subject: [PATCH 082/150] clicked element are far far away off screen, and show/shown confusion --- .../static/src/interactions/popup/no_backdrop_popup.js | 2 +- .../static/tests/tours/snippet_popup_and_animations.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/website/static/src/interactions/popup/no_backdrop_popup.js b/addons/website/static/src/interactions/popup/no_backdrop_popup.js index 8bcb7ee11fecb..12e83167ecc2c 100644 --- a/addons/website/static/src/interactions/popup/no_backdrop_popup.js +++ b/addons/website/static/src/interactions/popup/no_backdrop_popup.js @@ -6,7 +6,7 @@ export class NoBackdropPopup extends Interaction { static selector = ".s_popup_no_backdrop"; dynamicContent = { "_root": { - "t-on-shown.bs.modal": this.addModalNoBackdropEvents, + "t-on-show.bs.modal": this.addModalNoBackdropEvents, "t-on-hide.bs.modal": this.removeModalNoBackdropEvents, } }; diff --git a/addons/website/static/tests/tours/snippet_popup_and_animations.js b/addons/website/static/tests/tours/snippet_popup_and_animations.js index 44d025e423de1..8dad913cddab9 100644 --- a/addons/website/static/tests/tours/snippet_popup_and_animations.js +++ b/addons/website/static/tests/tours/snippet_popup_and_animations.js @@ -157,7 +157,7 @@ registerWebsitePreviewTour("snippet_popup_and_animations", { }, ...clickOnSave(), ...clickOnEditAndWaitEditMode(), - clickOnElement("Image of the 'Columns' snippet with the overlay effect", ":iframe .s_three_columns .o_animate_on_scroll img[data-hover-effect='overlay']"), + clickOnElement("Image of the 'Columns' snippet with the overlay effect", ":iframe .s_three_columns .o_animate_on_scroll img[data-hover-effect='overlay']:not(:visible)"), changeOption("WebsiteAnimate", 'we-toggler:contains("Overlay")'), changeOption("WebsiteAnimate", 'we-button[data-select-data-attribute="outline"]'), { @@ -180,7 +180,7 @@ registerWebsitePreviewTour("snippet_popup_and_animations", { }, }, ...clickOnEditAndWaitEditMode(), - clickOnElement("Image of the 'Columns' snippet with the outline effect", ":iframe .s_three_columns .o_animate_on_scroll img[data-hover-effect='outline']"), + clickOnElement("Image of the 'Columns' snippet with the outline effect", ":iframe .s_three_columns .o_animate_on_scroll img[data-hover-effect='outline']:not(:visible)"), changeOption("ImageTools", 'we-select:contains("Filter") we-toggler:contains("None")'), changeOption("ImageTools", 'we-button:contains("Blur")'), { From d1742d4ec2c83f71e9d9bb583151f44eabe1d2c9 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 26 Dec 2024 07:09:06 +0100 Subject: [PATCH 083/150] fix mega menu dropdown --- .../static/src/interactions/dropdown/mega_menu_dropdown.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js b/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js index 2a952eb7f0305..c2775c595a0da 100644 --- a/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js +++ b/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js @@ -106,10 +106,10 @@ class MegaMenuDropdown extends Interaction { * @param {Event} ev */ onTriggerExtraMenu(ev) { - if (!ev.currentTarget.closest(".o_extra_menu_items")) { + if (!ev.target.closest(".o_extra_menu_items")) { return; } - const megaMenuToggleEls = ev.currentTarget + const megaMenuToggleEls = ev.target .closest(".o_extra_menu_items") .querySelectorAll(".o_mega_menu_toggle"); megaMenuToggleEls.forEach((megaMenuToggleEl) => From 10a80b6bdd7147987237780a955ccc5a0e7207a1 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 24 Dec 2024 15:02:50 +0100 Subject: [PATCH 084/150] also show popup when `t-on-show` is not triggered --- addons/website/static/src/interactions/popup/shared_popup.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/website/static/src/interactions/popup/shared_popup.js b/addons/website/static/src/interactions/popup/shared_popup.js index d4edc3a05cf39..7114377154c06 100644 --- a/addons/website/static/src/interactions/popup/shared_popup.js +++ b/addons/website/static/src/interactions/popup/shared_popup.js @@ -18,6 +18,7 @@ export class SharedPopup extends Interaction { // tl;dr: this is keeping those 2 elements visibility synchronized. "_root": { "t-on-show.bs.modal": () => this.popupShown = true, + "t-on-shown.bs.modal": () => this.popupShown = true, "t-on-hidden.bs.modal": this.onModalHidden, "t-att-class": () => ({ "d-none": !this.popupShown }), }, From 396d276cdd7170c2c67cd130786ac67090761047 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Thu, 26 Dec 2024 08:40:00 +0100 Subject: [PATCH 085/150] Forward args in stealth function --- addons/website/static/src/core/website_edit_service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website/static/src/core/website_edit_service.js b/addons/website/static/src/core/website_edit_service.js index 7db89593d3a37..bf6f90166d01d 100644 --- a/addons/website/static/src/core/website_edit_service.js +++ b/addons/website/static/src/core/website_edit_service.js @@ -102,9 +102,9 @@ patch(Colibri.prototype, { let stealthFn = fn; if (wysiwyg?.odooEditor && !fn.isHandler) { const name = `${this.interaction.constructor.name}/${event}`; - stealthFn = async (ev) => { + stealthFn = async (...args) => { wysiwyg.odooEditor.observerUnactive(name); - const result = await fn(ev); + const result = await fn(...args); wysiwyg.odooEditor.observerActive(name); return result; }; From 0dc0e1e74efa6453809d4736ce09104c9ac91c31 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 26 Dec 2024 08:33:08 +0100 Subject: [PATCH 086/150] fix dropdown & header hide on scroll tour --- .../src/snippets/s_searchbar/search_bar.js | 5 +- .../dropdowns_and_header_hide_on_scroll.js | 49 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar.js b/addons/website/static/src/snippets/s_searchbar/search_bar.js index 14b7604f48dc6..2de2a48d7fbb8 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar.js @@ -153,7 +153,10 @@ export class SearchBar extends Interaction { if (this.searchType === "all" && !this.inputEl.value.trim().length) { this.render(); } else { - this.keepLast.add(this.waitFor(this.fetch())).then(this.render.bind(this)); + this.keepLast.add(this.waitFor(this.fetch())).then((res) => { + this.render(res); + this.updateContent(); + }); } } diff --git a/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js b/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js index 9758febc8846e..3abee579b6aa6 100644 --- a/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js +++ b/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js @@ -7,19 +7,6 @@ import { selectHeader, } from "@website/js/tours/tour_utils"; -const checkIfUserMenuNotMasked = function () { - return [ - { - content: "Click on the user dropdown", - trigger: ":iframe #wrapwrap header li.dropdown > a:contains(mitchell admin)", - run: "click", - }, - checkIfVisibleOnScreen( - ":iframe #wrapwrap header li.dropdown .dropdown-menu.show a[href='/my/home']" - ), - ]; -}; - const scrollDownToMediaList = function () { return { content: "Scroll down the page a little to leave the dropdown partially visible", @@ -31,12 +18,32 @@ const scrollDownToMediaList = function () { }; }; +const checkScrollingPosition = function () { + return { + content: "Check if we are at the top", + trigger: ":iframe #wrapwrap", + run: (el) => { + if (!el.anchor.ownerDocument.scrollingElement.scrollTop == 0) { + throw new Error("The page is not at the top"); + } + }, + }; +}; + +const clickOnUserDropdown = function () { + return { + content: "Click on the user dropdown", + trigger: ":iframe #wrapwrap header li.dropdown > a:contains(mitchell admin)", + run: "click", + }; +}; + registerWebsitePreviewTour("dropdowns_and_header_hide_on_scroll", { url: "/", edition: true, checkDelay: 100, }, () => [ - ...insertSnippet({id: "s_media_list", name: "Media List", groupName: "Content"}), + ...insertSnippet({ id: "s_media_list", name: "Media List", groupName: "Content" }), selectHeader(), changeOption("undefined", 'we-select[data-variable="header-scroll-effect"]'), changeOption("undefined", 'we-button[data-name="header_effect_fixed_opt"]'), @@ -60,11 +67,16 @@ registerWebsitePreviewTour("dropdowns_and_header_hide_on_scroll", { trigger: ":iframe #wrapwrap header.o_header_fixed div[aria-label=Middle] div[role=search]", }, ...clickOnSave(undefined, 30000), - ...checkIfUserMenuNotMasked(), - // We scroll the page a little because when clicking on the dropdown, the - // page needs to scroll to the top first and then open the dropdown menu. + clickOnUserDropdown(), + checkIfVisibleOnScreen(":iframe #wrapwrap header li.dropdown .dropdown-menu.show a[href='/my/home']"), + scrollDownToMediaList(), + checkIfVisibleOnScreen(":iframe #wrapwrap header li.dropdown .dropdown-menu.show a[href='/my/home']"), + // We close the dropdown + clickOnUserDropdown(), scrollDownToMediaList(), - ...checkIfUserMenuNotMasked(), + // We open the dropdown. The page should scroll top when opening the dropdown. + clickOnUserDropdown(), + checkScrollingPosition(), // We scroll the page again because when typing in the searchbar input, the // page needs also to scroll to the top first and then open the dropdown // with the search results. @@ -75,4 +87,5 @@ registerWebsitePreviewTour("dropdowns_and_header_hide_on_scroll", { run: "edit a", }, checkIfVisibleOnScreen(":iframe #wrapwrap header .s_searchbar_input.show .o_dropdown_menu.show"), + checkScrollingPosition(), ]); From 21032ccae0d4811b27916fae396997ff9ec14345 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 24 Dec 2024 11:49:10 +0100 Subject: [PATCH 087/150] totp --- .../revoke_all_trusted_devices.js | 26 ++ .../src/interactions/revoke_trusted_device.js | 24 ++ .../static/src/interactions/totp_disable.js | 27 ++ .../static/src/interactions/totp_enable.js | 198 +++++++++++++ .../static/src/js/totp_frontend.js | 266 ------------------ 5 files changed, 275 insertions(+), 266 deletions(-) create mode 100644 addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js create mode 100644 addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js create mode 100644 addons/auth_totp_portal/static/src/interactions/totp_disable.js create mode 100644 addons/auth_totp_portal/static/src/interactions/totp_enable.js delete mode 100644 addons/auth_totp_portal/static/src/js/totp_frontend.js diff --git a/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js b/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js new file mode 100644 index 0000000000000..5e520a993a958 --- /dev/null +++ b/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js @@ -0,0 +1,26 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { user } from "@web/core/user"; + +import { handleCheckIdentity } from "@portal/js/portal_security"; + +export class RevokeAllTrustedDevices extends Interaction { + static selector = "#auth_totp_portal_revoke_all_devices"; + dynamiContent = { + _root: { "t-on-click.prevent": this.onClick }, + }; + + async onClick() { + await this.waitFor(handleCheckIdentity( + this.services.orm.call("res.users", "revoke_all_devices", [user.userId]), + this.services.orm, + this.services.dialog, + )); + location.reload(); + } +} + +registry + .category("public.interactions") + .add("auth_totp_portal.revoke_all_trusted_devices", RevokeAllTrustedDevices); diff --git a/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js b/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js new file mode 100644 index 0000000000000..f648ec0b6cdbe --- /dev/null +++ b/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js @@ -0,0 +1,24 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { handleCheckIdentity } from "@portal/js/portal_security"; + +export class RevokeTrustedDevice extends Interaction { + static selector = "#totp_wizard_view + * .fa.fa-trash.text-danger"; + dynamiContent = { + _root: { "t-on-click.prevent": this.onClick }, + }; + + async onClick() { + await this.waitFor(handleCheckIdentity( + this.services.orm.call("auth_totp.device", "remove", [parseInt(this.el.id)]), + this.services.orm, + this.services.dialog, + )); + location.reload(); + } +} + +registry + .category("public.interactions") + .add("auth_totp_portal.revoke_trusted_device", RevokeTrustedDevice); diff --git a/addons/auth_totp_portal/static/src/interactions/totp_disable.js b/addons/auth_totp_portal/static/src/interactions/totp_disable.js new file mode 100644 index 0000000000000..2c857d0b11d55 --- /dev/null +++ b/addons/auth_totp_portal/static/src/interactions/totp_disable.js @@ -0,0 +1,27 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { user } from "@web/core/user"; + +import { handleCheckIdentity } from "@portal/js/portal_security"; + +export class TOTPDisable extends Interaction { + static selector = "#auth_totp_portal_disable"; + dynamicContent = { + _root: { "t-on-click": this.onClick } + } + + async onClick() { + await this.waitFor(handleCheckIdentity( + this.services.orm.call("res.users", "action_totp_disable", [user.userId]), + this.services.orm, + this.services.dialog, + )); + location.reload(); + } +} + +registry + .category("public.interactions") + .add("auth_totp_portal.totp_disable", TOTPDisable); + diff --git a/addons/auth_totp_portal/static/src/interactions/totp_enable.js b/addons/auth_totp_portal/static/src/interactions/totp_enable.js new file mode 100644 index 0000000000000..0a3f8c7fb9da8 --- /dev/null +++ b/addons/auth_totp_portal/static/src/interactions/totp_enable.js @@ -0,0 +1,198 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { browser } from "@web/core/browser/browser"; +import { user } from "@web/core/user"; +import { _t } from "@web/core/l10n/translation"; + +import { markup } from "@odoo/owl"; + +import { InputConfirmationDialog } from "@portal/js/components/input_confirmation_dialog/input_confirmation_dialog"; +import { handleCheckIdentity } from "@portal/js/portal_security"; + +/** + * Replaces specific elements by normal HTML, strip out the rest entirely + */ +function fromField(f, record) { + switch (f.getAttribute('name')) { + case 'qrcode': + const qrcode = document.createElement('img'); + qrcode.setAttribute('class', 'img img-fluid'); + qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']); + return qrcode; + case 'url': + const url = document.createElement('a'); + url.setAttribute('href', record['url']); + url.textContent = f.getAttribute('text') || record['url']; + return url; + case 'code': + const code = document.createElement('input'); + code.setAttribute('name', 'code'); + code.setAttribute('class', 'form-control col-10 col-md-6'); + code.setAttribute('placeholder', '6-digit code'); + code.required = true; + code.maxLength = 6; + code.minLength = 6; + return code; + case 'secret': + // As CopyClipboard wizard is backend only, mimic his behaviour to use it in frontend. + // Field + const secretSpan = document.createElement('span'); + secretSpan.setAttribute('name', 'secret'); + secretSpan.setAttribute('class', 'o_field_copy_url'); + secretSpan.textContent = record['secret']; + + // Copy Button + const copySpanIcon = document.createElement('span'); + copySpanIcon.setAttribute('class', 'fa fa-clipboard'); + const copySpanText = document.createElement('span'); + copySpanText.textContent = _t(' Copy'); + + const copyButton = document.createElement('button'); + copyButton.setAttribute('class', 'btn btn-sm btn-primary o_clipboard_button o_btn_char_copy py-0 px-2'); + copyButton.onclick = async function (event) { + event.preventDefault(); + $(copyButton).tooltip({ title: _t("Copied!"), trigger: "manual", placement: "bottom" }); + await browser.navigator.clipboard.writeText($(secretSpan)[0].innerText); + $(copyButton).tooltip('show'); + setTimeout(() => $(copyButton).tooltip("hide"), 800); + }; + + copyButton.appendChild(copySpanIcon); + copyButton.appendChild(copySpanText); + + // CopyClipboard Div + const secretDiv = document.createElement('div'); + secretDiv.setAttribute('class', 'o_field_copy d-flex justify-content-center align-items-center'); + secretDiv.appendChild(secretSpan); + secretDiv.appendChild(copyButton); + + return secretDiv; + default: // just display the field's data + return document.createTextNode(record[f.getAttribute('name')] || ''); + } +} + +/** + * Apparently chrome literally absolutely can't handle parsing XML and using + * those nodes in an HTML document (even when parsing as application/xhtml+xml), + * this results in broken rendering and a number of things not working (e.g. + * classes) without any specific warning in the console or anything, things are + * just broken with no indication of why. + * + * So... rebuild the entire f'ing body using document.createElement to ensure + * we have HTML elements. + * + * This is a recursive implementation so it's not super efficient but the views + * to fixup *should* be relatively simple. + */ +function fixupViewBody(oldNode, record) { + let qrcode = null, code = null, node = null; + + switch (oldNode.nodeType) { + case 1: // element + if (oldNode.tagName === 'field') { + node = fromField(oldNode, record); + switch (oldNode.getAttribute('name')) { + case 'qrcode': + qrcode = node; + break; + case 'code': + code = node; + break + } + break; // no need to recurse here + } + node = document.createElement(oldNode.tagName); + for (let i = 0; i < oldNode.attributes.length; ++i) { + const attr = oldNode.attributes[i]; + node.setAttribute(attr.name, attr.value); + } + for (let j = 0; j < oldNode.childNodes.length; ++j) { + const [ch, qr, co] = fixupViewBody(oldNode.childNodes[j], record); + if (ch) { node.appendChild(ch); } + if (qr) { qrcode = qr; } + if (co) { code = co; } + } + break; + case 3: case 4: // text, cdata + node = document.createTextNode(oldNode.data); + break; + default: + // don't care about PI & al + } + + return [node, qrcode, code] +} + +class TOTPEnable extends Interaction { + static selector = "#auth_totp_portal_enable"; + dynamicContent = { + _root: { "t-on-click.prevent": this.onClick }, + }; + + async onClick(e) { + const data = await this.waitFor(handleCheckIdentity( + this.services.orm.call("res.users", "action_totp_enable_wizard", [user.userId]), + this.services.orm, + this.services.dialog, + )); + + if (!data) { + // TOTP probably already enabled, just reload page + location.reload() + return; + } + + const model = data.res_model; + const wizard_id = data.res_id; + const record = (await this.services.orm.read(model, [wizard_id], []))[0]; + + const doc = new DOMParser().parseFromString( + document.getElementById('totp_wizard_view').textContent, + 'application/xhtml+xml' + ); + + const xmlBody = doc.querySelector('sheet *'); + const [body, ,] = fixupViewBody(xmlBody, record); + + this.services.dialog.add(InputConfirmationDialog, { + body: markup(body.outerHTML), + onInput: ({ inputEl }) => { inputEl.setCustomValidity("") }, + confirmLabel: _t("Activate"), + confirm: async ({ inputEl }) => { + if (!inputEl.reportValidity()) { + inputEl.classList.add("is-invalid"); + return false; + } + + try { + await this.services.orm.write(model, [record.id], { code: inputEl.value }); + await handleCheckIdentity( + this.services.orm.call(model, "enable", [record.id]), + this.services.orm, + this.services.dialog + ); + } catch (e) { + const errorMessage = ( + !e.message ? e.toString() + : !e.message.data ? e.message.message + : e.message.data.message || _t("Operation failed for unknown reason.") + ); + inputEl.classList.add("is-invalid"); + // show custom validity error message + inputEl.setCustomValidity(errorMessage); + inputEl.reportValidity(); + return false; + } + // reloads page, avoid window.location.reload() because it re-posts forms + location.reload(); + }, + cancel: () => { }, + }); + } +} + +registry + .category("public.interactions") + .add("auth_totp_portal.totp_enable", TOTPEnable); diff --git a/addons/auth_totp_portal/static/src/js/totp_frontend.js b/addons/auth_totp_portal/static/src/js/totp_frontend.js deleted file mode 100644 index fe8de841b3c86..0000000000000 --- a/addons/auth_totp_portal/static/src/js/totp_frontend.js +++ /dev/null @@ -1,266 +0,0 @@ -import { _t } from "@web/core/l10n/translation"; -import { markup } from "@odoo/owl"; -import { InputConfirmationDialog } from "@portal/js/components/input_confirmation_dialog/input_confirmation_dialog"; -import { handleCheckIdentity } from "@portal/js/portal_security"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { browser } from "@web/core/browser/browser"; -import { user } from "@web/core/user"; - -/** - * Replaces specific elements by normal HTML, strip out the rest entirely - */ -function fromField(f, record) { - switch (f.getAttribute('name')) { - case 'qrcode': - const qrcode = document.createElement('img'); - qrcode.setAttribute('class', 'img img-fluid'); - qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']); - return qrcode; - case 'url': - const url = document.createElement('a'); - url.setAttribute('href', record['url']); - url.textContent = f.getAttribute('text') || record['url']; - return url; - case 'code': - const code = document.createElement('input'); - code.setAttribute('name', 'code'); - code.setAttribute('class', 'form-control col-10 col-md-6'); - code.setAttribute('placeholder', '6-digit code'); - code.required = true; - code.maxLength = 6; - code.minLength = 6; - return code; - case 'secret': - // As CopyClipboard wizard is backend only, mimic his behaviour to use it in frontend. - // Field - const secretSpan = document.createElement('span'); - secretSpan.setAttribute('name', 'secret'); - secretSpan.setAttribute('class', 'o_field_copy_url'); - secretSpan.textContent = record['secret']; - - // Copy Button - const copySpanIcon = document.createElement('span'); - copySpanIcon.setAttribute('class', 'fa fa-clipboard'); - const copySpanText = document.createElement('span'); - copySpanText.textContent = _t(' Copy'); - - const copyButton = document.createElement('button'); - copyButton.setAttribute('class', 'btn btn-sm btn-primary o_clipboard_button o_btn_char_copy py-0 px-2'); - copyButton.onclick = async function(event) { - event.preventDefault(); - $(copyButton).tooltip({title: _t("Copied!"), trigger: "manual", placement: "bottom"}); - await browser.navigator.clipboard.writeText($(secretSpan)[0].innerText); - $(copyButton).tooltip('show'); - setTimeout(() => $(copyButton).tooltip("hide"), 800); - }; - - copyButton.appendChild(copySpanIcon); - copyButton.appendChild(copySpanText); - - // CopyClipboard Div - const secretDiv = document.createElement('div'); - secretDiv.setAttribute('class', 'o_field_copy d-flex justify-content-center align-items-center'); - secretDiv.appendChild(secretSpan); - secretDiv.appendChild(copyButton); - - return secretDiv; - default: // just display the field's data - return document.createTextNode(record[f.getAttribute('name')] || ''); - } -} - -/** - * Apparently chrome literally absolutely can't handle parsing XML and using - * those nodes in an HTML document (even when parsing as application/xhtml+xml), - * this results in broken rendering and a number of things not working (e.g. - * classes) without any specific warning in the console or anything, things are - * just broken with no indication of why. - * - * So... rebuild the entire f'ing body using document.createElement to ensure - * we have HTML elements. - * - * This is a recursive implementation so it's not super efficient but the views - * to fixup *should* be relatively simple. - */ -function fixupViewBody(oldNode, record) { - let qrcode = null, code = null, node = null; - - switch (oldNode.nodeType) { - case 1: // element - if (oldNode.tagName === 'field') { - node = fromField(oldNode, record); - switch (oldNode.getAttribute('name')) { - case 'qrcode': - qrcode = node; - break; - case 'code': - code = node; - break - } - break; // no need to recurse here - } - node = document.createElement(oldNode.tagName); - for(let i=0; i ar[0]); - - const doc = new DOMParser().parseFromString( - document.getElementById('totp_wizard_view').textContent, - 'application/xhtml+xml' - ); - - const xmlBody = doc.querySelector('sheet *'); - const [body, ,] = fixupViewBody(xmlBody, record); - - this.call("dialog", "add", InputConfirmationDialog, { - body: markup(body.outerHTML), - onInput: ({ inputEl }) => { - inputEl.setCustomValidity(""); - }, - confirmLabel: _t("Activate"), - confirm: async ({ inputEl }) => { - if (!inputEl.reportValidity()) { - inputEl.classList.add("is-invalid"); - return false; - } - - try { - await this.orm.write(model, [record.id], { code: inputEl.value }); - await handleCheckIdentity( - this.orm.call(model, "enable", [record.id]), - this.orm, - this.dialog - ); - } catch (e) { - const errorMessage = ( - !e.message ? e.toString() - : !e.message.data ? e.message.message - : e.message.data.message || _t("Operation failed for unknown reason.") - ); - inputEl.classList.add("is-invalid"); - // show custom validity error message - inputEl.setCustomValidity(errorMessage); - inputEl.reportValidity(); - return false; - } - // reloads page, avoid window.location.reload() because it re-posts forms - window.location = window.location; - }, - cancel: () => {}, - }); - }, -}); -publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({ - selector: '#auth_totp_portal_disable', - events: { - click: '_onClick' - }, - - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.dialog = this.bindService("dialog"); - }, - - async _onClick(e) { - e.preventDefault(); - await handleCheckIdentity( - this.orm.call("res.users", "action_totp_disable", [user.userId]), - this.orm, - this.dialog - ) - window.location = window.location; - } -}); -publicWidget.registry.RevokeTrustedDeviceButton = publicWidget.Widget.extend({ - selector: '#totp_wizard_view + * .fa.fa-trash.text-danger', - events: { - click: '_onClick' - }, - - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.dialog = this.bindService("dialog"); - }, - - async _onClick(e){ - e.preventDefault(); - await handleCheckIdentity( - this.orm.call("auth_totp.device", "remove", [parseInt(this.el.id)]), - this.orm, - this.dialog - ); - window.location = window.location; - } -}); -publicWidget.registry.RevokeAllTrustedDevicesButton = publicWidget.Widget.extend({ - selector: '#auth_totp_portal_revoke_all_devices', - events: { - click: '_onClick' - }, - - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.dialog = this.bindService("dialog"); - }, - - async _onClick(e){ - e.preventDefault(); - await handleCheckIdentity( - this.orm.call("res.users", "revoke_all_devices", [user.userId]), - this.orm, - this.dialog - ); - window.location = window.location; - } -}); From 125f1acb1e0470aeb3d537b2cacb7c619216ed9d Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 26 Dec 2024 10:53:04 +0100 Subject: [PATCH 088/150] add waitFor on orm.call --- .../static/src/interactions/revoke_all_trusted_devices.js | 2 +- .../static/src/interactions/revoke_trusted_device.js | 2 +- .../auth_totp_portal/static/src/interactions/totp_disable.js | 2 +- .../auth_totp_portal/static/src/interactions/totp_enable.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js b/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js index 5e520a993a958..345e248311452 100644 --- a/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js +++ b/addons/auth_totp_portal/static/src/interactions/revoke_all_trusted_devices.js @@ -13,7 +13,7 @@ export class RevokeAllTrustedDevices extends Interaction { async onClick() { await this.waitFor(handleCheckIdentity( - this.services.orm.call("res.users", "revoke_all_devices", [user.userId]), + this.waitFor(this.services.orm.call("res.users", "revoke_all_devices", [user.userId])), this.services.orm, this.services.dialog, )); diff --git a/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js b/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js index f648ec0b6cdbe..6cd3fdf9c5ea5 100644 --- a/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js +++ b/addons/auth_totp_portal/static/src/interactions/revoke_trusted_device.js @@ -11,7 +11,7 @@ export class RevokeTrustedDevice extends Interaction { async onClick() { await this.waitFor(handleCheckIdentity( - this.services.orm.call("auth_totp.device", "remove", [parseInt(this.el.id)]), + this.waitFor(this.services.orm.call("auth_totp.device", "remove", [parseInt(this.el.id)])), this.services.orm, this.services.dialog, )); diff --git a/addons/auth_totp_portal/static/src/interactions/totp_disable.js b/addons/auth_totp_portal/static/src/interactions/totp_disable.js index 2c857d0b11d55..1c9bd68187a31 100644 --- a/addons/auth_totp_portal/static/src/interactions/totp_disable.js +++ b/addons/auth_totp_portal/static/src/interactions/totp_disable.js @@ -13,7 +13,7 @@ export class TOTPDisable extends Interaction { async onClick() { await this.waitFor(handleCheckIdentity( - this.services.orm.call("res.users", "action_totp_disable", [user.userId]), + this.waitFor(this.services.orm.call("res.users", "action_totp_disable", [user.userId])), this.services.orm, this.services.dialog, )); diff --git a/addons/auth_totp_portal/static/src/interactions/totp_enable.js b/addons/auth_totp_portal/static/src/interactions/totp_enable.js index 0a3f8c7fb9da8..3484b3f2434ae 100644 --- a/addons/auth_totp_portal/static/src/interactions/totp_enable.js +++ b/addons/auth_totp_portal/static/src/interactions/totp_enable.js @@ -133,7 +133,7 @@ class TOTPEnable extends Interaction { async onClick(e) { const data = await this.waitFor(handleCheckIdentity( - this.services.orm.call("res.users", "action_totp_enable_wizard", [user.userId]), + this.waitFor(this.services.orm.call("res.users", "action_totp_enable_wizard", [user.userId])), this.services.orm, this.services.dialog, )); @@ -169,7 +169,7 @@ class TOTPEnable extends Interaction { try { await this.services.orm.write(model, [record.id], { code: inputEl.value }); await handleCheckIdentity( - this.services.orm.call(model, "enable", [record.id]), + this.waitFor(this.services.orm.call(model, "enable", [record.id])), this.services.orm, this.services.dialog ); From 4990678fbc2dbd9a0c5b1076a3b34e48c1cd3260 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Tue, 24 Dec 2024 07:23:30 +0100 Subject: [PATCH 089/150] BoothRegistration --- addons/website_event_booth/__manifest__.py | 2 +- .../src/interactions/booth_registration.js | 213 ++++++++++++++++ .../static/src/js/booth_register.js | 239 ------------------ 3 files changed, 214 insertions(+), 240 deletions(-) create mode 100644 addons/website_event_booth/static/src/interactions/booth_registration.js delete mode 100644 addons/website_event_booth/static/src/js/booth_register.js diff --git a/addons/website_event_booth/__manifest__.py b/addons/website_event_booth/__manifest__.py index ea934b28001b4..a7c3ed9a90c78 100644 --- a/addons/website_event_booth/__manifest__.py +++ b/addons/website_event_booth/__manifest__.py @@ -24,7 +24,7 @@ 'auto_install': True, 'assets': { 'web.assets_frontend': [ - '/website_event_booth/static/src/js/booth_register.js', + '/website_event_booth/static/src/interactions/*', '/website_event_booth/static/src/scss/website_event_booth.scss', 'website_event_booth/static/src/xml/event_booth_registration_templates.xml', ], diff --git a/addons/website_event_booth/static/src/interactions/booth_registration.js b/addons/website_event_booth/static/src/interactions/booth_registration.js new file mode 100644 index 0000000000000..7feade59b5efc --- /dev/null +++ b/addons/website_event_booth/static/src/interactions/booth_registration.js @@ -0,0 +1,213 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; +import { post } from "@web/core/network/http_service"; +import { redirect } from "@web/core/utils/urls"; + +import { renderToElement, renderToFragment } from "@web/core/utils/render"; + +export class BoothRegistration extends Interaction { + static selector = ".o_wbooth_registration"; + dynamicContent = { + "input[name='booth_category_id']": { + "t-on-change.prevent": (ev) => this.onChangeBoothType(ev.currentTarget), + }, + ".form-check > input[type='checkbox']": { + "t-on-change": (ev) => this.onChangeBooth(ev.currentTarget), + }, + ".o_wbooth_registration_submit": { + "t-on-click.prevent": this.onClickSubmit, + }, + ".o_wbooth_registration_confirm": { + "t-on-click.prevent.stop": (ev) => this.onClickConfirm(ev.currentTarget), + }, + ".o_wbooth_registration_error_section": { + "t-att-class": () => ({ + "d-none": !this.inError, + }), + }, + "button.o_wbooth_registration_submit": { + "t-att-disabled": () => this.isSelectionEmpty ? true : undefined, + }, + }; + + setup() { + this.inError = false; + this.boothCache = {}; + this.isFirstRender = true; + + this.eventId = parseInt(this.el.dataset.eventId); + + this.activeBoothCategoryId = false; + this.selectedBoothIds = []; + this.selectedBoothCategory = this.el.querySelector('input[name="booth_category_id"]:checked'); + if (this.selectedBoothCategory) { + const boothEl = this.el.querySelector('.o_wbooth_booths'); + this.selectedBoothIds = boothEl.dataset.selectedBoothIds.split(',').map(Number); + this.activeBoothCategoryId = this.selectedBoothCategory.value; + this.updateAvailableBoothsUI(); + } + } + + async checkBoothsAvailability(eventBoothIds) { + const data = await this.waitFor(rpc("/event/booth/check_availability", { + event_booth_ids: eventBoothIds, + })); + if (data && data.unavailable_booths.length) { + const boothIdEls = this.el.querySelectorAll("input[name='event_booth_ids']"); + for (const boothIdEl of boothIdEls) { + if (result.unavailable_booths.includes(parseInt(boothIdEl.value))) { + boothIdEl.closest(".form-check").classList.add("text-danger"); + } + } + const unavailableBoothAlertEl = this.el.querySelector(".o_wbooth_unavailable_booth_alert"); + unavailableBoothAlertEl.classList.remove("d-none"); + return false; + } + return true; + } + + countSelectedBooths() { + return this.el.querySelectorAll(".form-check > input[type='checkbox']:checked").length; + } + + updateBoothsList() { + const boothsElem = this.el.querySelector('.o_wbooth_booths'); + boothsElem.replaceChildren(renderToFragment('event_booth_checkbox_list', { + 'event_booth_ids': this.boothCache[this.activeBoothCategoryId], + 'selected_booth_ids': this.isFirstRender ? this.selectedBoothIds : [], + })); + this.isFirstRender = false; + } + + /** + * Check if the confirmation form is valid by testing each of its inputs + * + * @param formEl + * @return {boolean} - true if no errors else false + */ + checkConfirmationForm(formEl) { + const formControlEls = formEl.querySelectorAll(".form-control"); + const formErrors = []; + for (const formControlEl of formControlEls) { + formControlEl.classList.remove("is-invalid"); + if (!formControlEl.checkValidity()) { + formControlEl.classList.add("is-invalid"); + formErrors.push('invalidFormInputs'); + } + } + this.updateErrorDisplay(formErrors); + return formErrors.length === 0; + } + + showBoothCategoryDescription() { + const boothCategoryDescriptionEls = this.el.querySelectorAll(".o_wbooth_booth_category_description"); + for (const boothCategoryDescriptionEl of boothCategoryDescriptionEls) { + boothCategoryDescriptionEl.classList.add("d-none"); + } + const activeBoothEl = this.el.querySelector("#o_wbooth_booth_description_" + this.activeBoothCategoryId); + activeBoothEl.classList.remove("d-none"); + } + + /** + * Display the errors with a custom message when confirming + * the registration if there is any. + * + * @param errors + */ + updateErrorDisplay(errors) { + this.inError = errors.length; + + const errorMessages = []; + if (errors.includes('invalidFormInputs')) { + errorMessages.push(_t("Please fill out the form correctly.")); + } + if (errors.includes('boothError')) { + errorMessages.push(_t("Booth registration failed.")); + } + if (errors.includes('boothCategoryError')) { + errorMessages.push(_t("The booth category doesn't exist.")); + } + + const errorMessageEl = this.el.querySelector(".o_wbooth_registration_error_message"); + errorMessageEl.textContent = errorMessages.join(" "); + errorMessageEl.dispatchEvent(new Event("change")); + } + + /** + * Load all the booths related to the activeBoothCategoryId booth category and + * add them to a local dictionary to avoid making rpc each time the + * user change the booth category. + * + * Then the selection input will be filled with the fetched booth values. + */ + async updateAvailableBoothsUI() { + if (this.boothCache[this.activeBoothCategoryId] === undefined) { + const data = await this.waitFor(rpc('/event/booth_category/get_available_booths', { + event_id: this.eventId, + booth_category_id: this.activeBoothCategoryId, + })); + if (data) { + this.boothCache[this.activeBoothCategoryId] = data; + } + } + this.updateBoothsList(); + this.showBoothCategoryDescription(); + this.isSelectionEmpty = !!this.countSelectedBooths().length; + } + + onChangeBoothType(targetEl) { + this.activeBoothCategoryId = parseInt(targetEl.value); + this.updateAvailableBoothsUI(); + } + + onChangeBooth(targetEl) { + targetEl.closest(".form-check").classList.remove("text-danger"); + this.isSelectionEmpty = !!this.countSelectedBooths().length; + } + + async onClickSubmit() { + const selectedBoothEls = this.el.querySelectorAll("input[name=event_booth_ids]:checked"); + const selectedBoothIds = [...selectedBoothEls].map((el) => parseInt(el.value)); + const data = await this.waitFor(this.checkBoothsAvailability(selectedBoothIds)); + if (data) { + this.el.querySelector(".o_wbooth_registration_form").submit(); + } + } + + async onClickConfirm(targetEl) { + targetEl.classList.add("disabled"); + targetEl.disabled = true; + + const formEl = this.el.querySelector("#o_wbooth_contact_details_form"); + if (this.checkConfirmationForm(formEl)) { + const formData = new FormData(formEl); + const jsonResponse = await this.waitFor(post(`/event/${encodeURIComponent(this.el.dataset.eventId)}/booth/confirm`, formData)); + if (jsonResponse.success) { + this.el.querySelector('.o_wevent_booth_order_progress').remove(); + const boothCategoryId = this.el.querySelector('input[name=booth_category_id]').value; + const boothRegistrationCompleteFormEl = renderToElement("event_booth_registration_complete", { + booth_category_id: boothCategoryId, + event_id: this.eventId, + event_name: jsonResponse.event_name, + contact: jsonResponse.contact, + }); + this.insert(boothRegistrationCompleteFormEl, formEl, "afterend"); + formEl.remove(); + } else if (jsonResponse.redirect) { + redirect(jsonResponse.redirect); + } else if (jsonResponse.error) { + this.updateErrorDisplay(jsonResponse.error); + } + } + + targetEl.classList.remove("disabled"); + targetEl.removeAttribute("disabled"); + } +} + +registry + .category("public.interactions") + .add("website_event_booth.booth_registration", BoothRegistration); diff --git a/addons/website_event_booth/static/src/js/booth_register.js b/addons/website_event_booth/static/src/js/booth_register.js deleted file mode 100644 index 6203ded037c1d..0000000000000 --- a/addons/website_event_booth/static/src/js/booth_register.js +++ /dev/null @@ -1,239 +0,0 @@ -import { renderToElement, renderToFragment } from "@web/core/utils/render"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { _t } from "@web/core/l10n/translation"; -import { rpc } from "@web/core/network/rpc"; -import { post } from "@web/core/network/http_service"; -import { redirect } from "@web/core/utils/urls"; - -publicWidget.registry.boothRegistration = publicWidget.Widget.extend({ - selector: '.o_wbooth_registration', - events: { - 'change input[name="booth_category_id"]': '_onChangeBoothType', - 'change .form-check > input[type="checkbox"]': '_onChangeBooth', - 'click .o_wbooth_registration_submit': '_onSubmitBoothSelectionClick', - 'click .o_wbooth_registration_confirm': '_onConfirmRegistrationClick', - }, - - start() { - this.eventId = parseInt(this.el.dataset.eventId); - this.activeBoothCategoryId = false; - this.boothCache = {}; - this.boothsFirstRendering = true; - this.selectedBoothIds = []; - return this._super.apply(this, arguments).then(() => { - this.selectedBoothCategory = this.el.querySelector('input[name="booth_category_id"]:checked'); - if (this.selectedBoothCategory) { - this.selectedBoothIds = this.el.querySelector('.o_wbooth_booths').dataset.selectedBoothIds.split(',').map(Number); - this.activeBoothCategoryId = this.selectedBoothCategory.value; - this._fetchBoothsAndUpdateUI(); - } - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - _check_booths_availability(eventBoothIds) { - const self = this; - return rpc("/event/booth/check_availability", { - event_booth_ids: eventBoothIds, - }).then(function (result) { - if (result.unavailable_booths.length) { - for (const el of self.el.querySelectorAll("input[name='event_booth_ids']")) { - if (result.unavailable_booths.includes(parseInt(el.value))) { - el.closest(".form-check").classList.add("text-danger"); - } - } - self.el - .querySelector(".o_wbooth_unavailable_booth_alert") - .classList.remove("d-none"); - return Promise.resolve(false); - } - return Promise.resolve(true); - }) - }, - - _countSelectedBooths() { - return this.el.querySelectorAll(".form-check > input[type='checkbox']:checked").length; - }, - - _fillBooths() { - const boothsElem = this.el.querySelector('.o_wbooth_booths'); - boothsElem.replaceChildren(renderToFragment('event_booth_checkbox_list', { - 'event_booth_ids': this.boothCache[this.activeBoothCategoryId], - 'selected_booth_ids': this.boothsFirstRendering ? this.selectedBoothIds : [], - })); - - this.boothsFirstRendering = false; - }, - - /** - * Check if the confirmation form is valid by testing each of its inputs - * - * @private - * @param formEl - * @return {boolean} - true if no errors else false - */ - _isConfirmationFormValid(formEl) { - const formErrors = []; - for (const el of formEl.querySelectorAll(".form-control")) { - el.classList.remove("is-invalid"); - if (!el.checkValidity()) { - el.classList.add("is-invalid"); - formErrors.push('invalidFormInputs'); - } - } - - this._updateErrorDisplay(formErrors); - return formErrors.length === 0; - }, - - _showBoothCategoryDescription() { - for (const el of this.el.querySelectorAll(".o_wbooth_booth_category_description")) { - el.classList.add("d-none"); - } - this.el - .querySelector("#o_wbooth_booth_description_" + this.activeBoothCategoryId) - .classList.remove("d-none"); - }, - - /** - * Display the errors with a custom message when confirming - * the registration if there is any. - * - * @private - * @param errors - */ - _updateErrorDisplay(errors) { - this.el - .querySelector(".o_wbooth_registration_error_section") - .classList.toggle("d-none", !errors.length); - - let errorMessages = []; - const errorMessageEl = this.el.querySelector(".o_wbooth_registration_error_message"); - - if (errors.includes('invalidFormInputs')) { - errorMessages.push(_t("Please fill out the form correctly.")); - } - - if (errors.includes('boothError')) { - errorMessages.push(_t("Booth registration failed.")); - } - - if (errors.includes('boothCategoryError')) { - errorMessages.push(_t("The booth category doesn't exist.")); - } - - errorMessageEl.textContent = errorMessages.join(" "); - errorMessageEl.dispatchEvent(new Event("change")); - }, - - _updateUiAfterBoothCategoryChange() { - this._fillBooths(); - this._showBoothCategoryDescription(); - this._updateUiAfterBoothChange(this._countSelectedBooths()); - }, - - _updateUiAfterBoothChange(boothCount) { - const buttonEl = this.el.querySelector("button.o_wbooth_registration_submit"); - if (buttonEl) { - buttonEl.disabled = !boothCount; - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - _onChangeBooth(ev) { - ev.currentTarget.closest(".form-check").classList.remove("text-danger"); - this._updateUiAfterBoothChange(this._countSelectedBooths()); - }, - - _onChangeBoothType(ev) { - ev.preventDefault(); - this.activeBoothCategoryId = parseInt(ev.currentTarget.value); - this._fetchBoothsAndUpdateUI(); - }, - - /** - * Load all the booths related to the activeBoothCategoryId booth category and - * add them to a local dictionary to avoid making rpc each time the - * user change the booth category. - * - * Then the selection input will be filled with the fetched booth values. - * - * @private - */ - _fetchBoothsAndUpdateUI() { - if (this.boothCache[this.activeBoothCategoryId] === undefined) { - var self = this; - rpc('/event/booth_category/get_available_booths', { - event_id: this.eventId, - booth_category_id: this.activeBoothCategoryId, - }).then(function (result) { - self.boothCache[self.activeBoothCategoryId] = result; - self._updateUiAfterBoothCategoryChange(); - }); - } else { - this._updateUiAfterBoothCategoryChange(); - } - }, - - async _onSubmitBoothSelectionClick(ev) { - ev.preventDefault(); - const formEl = this.el.querySelector(".o_wbooth_registration_form"); - const eventBoothIds = [ - ...this.el.querySelectorAll("input[name=event_booth_ids]:checked"), - ].map((el) => parseInt(el.value)); - if (await this._check_booths_availability(eventBoothIds)) { - formEl.submit(); - } - }, - - /** - * Submit the confirmation form if no errors are present after validation. - * - * If the submission succeed, we replace the form with a success message template. - * - * @param ev - * @return {Promise} - * @private - */ - async _onConfirmRegistrationClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - - ev.currentTarget.classList.add("disabled"); - ev.currentTarget.disabled = true; - - const formEl = this.el.querySelector("#o_wbooth_contact_details_form"); - if (this._isConfirmationFormValid(formEl)) { - const formData = new FormData(formEl); - const jsonResponse = await post(`/event/${encodeURIComponent(this.el.dataset.eventId)}/booth/confirm`, formData); - if (jsonResponse.success) { - this.el.querySelector('.o_wevent_booth_order_progress').remove(); - const boothCategoryId = this.el.querySelector('input[name=booth_category_id]').value; - const boothRegistrationCompleteFormEl = renderToElement("event_booth_registration_complete", { - booth_category_id: boothCategoryId, - event_id: this.eventId, - event_name: jsonResponse.event_name, - contact: jsonResponse.contact, - }); - formEl.insertAdjacentElement("afterend", boothRegistrationCompleteFormEl); - formEl.remove(); - } else if (jsonResponse.redirect) { - redirect(jsonResponse.redirect); - } else if (jsonResponse.error) { - this._updateErrorDisplay(jsonResponse.error); - } - } - - ev.currentTarget.classList.remove("disabled"); - ev.currentTarget.removeAttribute("disabled"); - }, - -}); - -export default publicWidget.registry.boothRegistration; From aa1b0954ba2df650b0b407c974a9549391f4bad7 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 23 Dec 2024 13:10:43 +0100 Subject: [PATCH 090/150] SaleUpdateLineButton --- addons/sale_management/__manifest__.py | 2 +- .../interactions/sale_update_line_button.js | 67 +++++++++++ .../static/src/js/sale_management.js | 105 ------------------ 3 files changed, 68 insertions(+), 106 deletions(-) create mode 100644 addons/sale_management/static/src/interactions/sale_update_line_button.js delete mode 100644 addons/sale_management/static/src/js/sale_management.js diff --git a/addons/sale_management/__manifest__.py b/addons/sale_management/__manifest__.py index 639788bff61d0..ac48ebffc314b 100644 --- a/addons/sale_management/__manifest__.py +++ b/addons/sale_management/__manifest__.py @@ -59,7 +59,7 @@ ], 'assets': { 'web.assets_frontend': [ - 'sale_management/static/src/js/**/*', + 'sale_management/static/src/interactions/**/*', ], }, 'application': True, diff --git a/addons/sale_management/static/src/interactions/sale_update_line_button.js b/addons/sale_management/static/src/interactions/sale_update_line_button.js new file mode 100644 index 0000000000000..2a119f9d40bcf --- /dev/null +++ b/addons/sale_management/static/src/interactions/sale_update_line_button.js @@ -0,0 +1,67 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { rpc } from "@web/core/network/rpc"; + +export class SaleUpdateLineButton extends Interaction { + static selector = ".o_portal_sale_sidebar"; + dynamicContent = { + "a.js_update_line_json": { + "t-on-click.prevent": (ev) => this.onClickOptionQuantityButton(ev.currentTarget), + }, + "a.js_add_optional_products": { + "t-on-click.prevent": (ev) => this.onClickAddOptionalProduct(ev.currentTarget), + }, + ".js_quantity": { + "t-on-change.prevent": (ev) => this.onChangeOptionQuantity(ev.currentTarget), + }, + }; + + setup() { + this.orderDetail = this.el.querySelector('table#sales_order_table').dataset; + } + + callUpdateLineRoute(orderId, params) { + return rpc("/my/orders/" + orderId + "/update_line_dict", params); + } + + callAddOptionRoute(orderId, optionId, params) { + return rpc("/my/orders/" + orderId + "/add_option" + optionId, params); + } + + refreshOrderUI(data) { + window.location.reload(); + } + + async onChangeOptionQuantity(targetEl) { + const quantity = parseInt(targetEl.value); + const data = await this.waitFor(this.callUpdateLineRoute(this.orderDetail.orderId, { + 'access_token': this.orderDetail.token, + 'input_quantity': quantity >= 0 ? quantity : false, + 'line_id': targetEl.dataset.lineId, + })); + this.refreshOrderUI(data); + } + + async onClickOptionQuantityButton(targetEl) { + const data = await this.waitFor(this.callUpdateLineRoute(this.orderDetail.orderId, { + 'access_token': this.orderDetail.token, + 'line_id': targetEl.dataset.lineId, + 'remove': targetEl.dataset.remove, + 'unlink': targetEl.dataset.unlink, + })); + this.refreshOrderUI(data); + } + + async onClickAddOptionalProduct(targetEl) { + targetEl.style.setProperty('pointer-events', 'none'); + const data = await this.waitFor(this.callAddOptionRoute(this.orderDetail.orderId, targetEl.dataset.optionId, { + 'access_token': this.orderDetail.token, + })); + this.refreshOrderUI(data); + } +} + +registry + .category("public.interactions") + .add("sale_management.sale_update_line_button", SaleUpdateLineButton); diff --git a/addons/sale_management/static/src/js/sale_management.js b/addons/sale_management/static/src/js/sale_management.js deleted file mode 100644 index b483e7410705d..0000000000000 --- a/addons/sale_management/static/src/js/sale_management.js +++ /dev/null @@ -1,105 +0,0 @@ -import { rpc } from "@web/core/network/rpc"; -import publicWidget from "@web/legacy/js/public/public_widget"; - -publicWidget.registry.SaleUpdateLineButton = publicWidget.Widget.extend({ - selector: '.o_portal_sale_sidebar', - events: { - 'click a.js_update_line_json': '_onClickOptionQuantityButton', - 'click a.js_add_optional_products': '_onClickAddOptionalProduct', - 'change .js_quantity': '_onChangeOptionQuantity', - }, - - /** - * @override - */ - async start() { - await this._super(...arguments); - this.orderDetail = this.$el.find('table#sales_order_table').data(); - }, - - /** - * Calls the route to get updated values of the line and order - * when the quantity of a product has changed - * - * @private - * @param {integer} order_id - * @param {Object} params - * @return {Deferred} - */ - _callUpdateLineRoute(order_id, params) { - return rpc("/my/orders/" + order_id + "/update_line_dict", params); - }, - - /** - * Refresh the UI of the order details - * - * @private - * @param {Object} data: contains order html details - */ - _refreshOrderUI(data){ - window.location.reload(); - }, - - /** - * Process the change in line quantity - * - * @private - * @param {Event} ev - */ - async _onChangeOptionQuantity(ev) { - ev.preventDefault(); - let self = this, - $target = $(ev.currentTarget), - quantity = parseInt($target.val()); - - const result = await this._callUpdateLineRoute(self.orderDetail.orderId, { - 'line_id': $target.data('lineId'), - 'input_quantity': quantity >= 0 ? quantity : false, - 'access_token': self.orderDetail.token - }); - this._refreshOrderUI(result); - }, - - /** - * Reacts to the click on the -/+ buttons - * - * @private - * @param {Event} ev - */ - async _onClickOptionQuantityButton(ev) { - ev.preventDefault(); - let self = this, - $target = $(ev.currentTarget); - - const result = await this._callUpdateLineRoute(self.orderDetail.orderId, { - 'line_id': $target.data('lineId'), - 'remove': $target.data('remove'), - 'unlink': $target.data('unlink'), - 'access_token': self.orderDetail.token - }); - this._refreshOrderUI(result); - }, - - /** - * Triggered when optional product added to order from portal. - * - * @private - * @param {Event} ev - */ - _onClickAddOptionalProduct(ev) { - ev.preventDefault(); - let self = this, - $target = $(ev.currentTarget); - - // to avoid double click on link with href. - $target.css('pointer-events', 'none'); - - rpc( - "/my/orders/" + self.orderDetail.orderId + "/add_option/" + $target.data('optionId'), - {access_token: self.orderDetail.token} - ).then((data) => { - this._refreshOrderUI(data); - }); - }, - -}); From 2e47a68471c9044c1a8dbc582375535c38f31af3 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Thu, 26 Dec 2024 09:32:39 +0100 Subject: [PATCH 091/150] interactions must also be started if nested in public widget --- .../src/legacy/js/public/public_root.js | 17 ++++++++++--- .../web/static/src/public/datetime_picker.js | 24 +++++++++---------- .../static/src/public/interaction_service.js | 4 +++- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index c5f8f6f129c15..b6c6f791e7234 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -58,7 +58,7 @@ export const PublicRoot = publicWidget.Widget.extend({ patch(interactionsService.constructor.prototype, { startInteractions(el) { super.startInteractions(el); - if (!this.startFromEventHandler) { + if (!publicRoot.startFromEventHandler) { // this.editMode is assigned by website_edit_service publicRoot._startWidgets($(el || this.el), { fromInteractionPatch: true, editableMode: this.editMode }) } @@ -66,7 +66,7 @@ export const PublicRoot = publicWidget.Widget.extend({ stopInteractions(el) { super.stopInteractions(el); // Call to interactions is only from the event handler. - if (!this.stopFromEventHandler) { + if (!publicRoot.stopFromEventHandler) { publicRoot._stopWidgets($(el || this.el)); } }, @@ -212,7 +212,18 @@ export const PublicRoot = publicWidget.Widget.extend({ }); return Promise.all(proms); }); - return Promise.all(defs); + return Promise.all(defs).then(() => { + // TODO Find a better way. + // Also start interactions that might be needed within started widgets. + const targetEl = $from ? $from[0] : undefined; + const publicInteractions = this.bindService("public.interactions"); + this.startFromEventHandler = true; + try { + publicInteractions.startInteractions(targetEl); + } finally { + this.startFromEventHandler = false; + } + }); }, /** * Destroys all registered widget instances. Website would need this before diff --git a/addons/web/static/src/public/datetime_picker.js b/addons/web/static/src/public/datetime_picker.js index a427d8639750e..7c1b03fd5fafc 100644 --- a/addons/web/static/src/public/datetime_picker.js +++ b/addons/web/static/src/public/datetime_picker.js @@ -20,15 +20,17 @@ class DatetimePicker extends Interaction { start() { const parseFunction = this.type === "date" ? parseDate : parseDateTime; const deserializeFunction = this.type === "date" ? deserializeDate : deserializeDateTime; - this.disableDateTimePicker = this.services.datetime_picker.create({ - target: this.el, - pickerProps: { - type: this.type, - minDate: this.minDate && deserializeFunction(this.minDate), - maxDate: this.maxDate && deserializeFunction(this.maxDate), - value: parseFunction(this.el.value), - }, - }).enable(); + this.disableDateTimePicker = this.services.datetime_picker + .create({ + target: this.el, + pickerProps: { + type: this.type, + minDate: this.minDate && deserializeFunction(this.minDate), + maxDate: this.maxDate && deserializeFunction(this.maxDate), + value: parseFunction(this.el.value), + }, + }) + .enable(); } destroy() { @@ -36,6 +38,4 @@ class DatetimePicker extends Interaction { } } -registry - .category("public.interactions") - .add("web.datetime_picker", DatetimePicker); +registry.category("public.interactions").add("web.datetime_picker", DatetimePicker); diff --git a/addons/web/static/src/public/interaction_service.js b/addons/web/static/src/public/interaction_service.js index 2a5bf6d3fced7..8499f4f15d4c4 100644 --- a/addons/web/static/src/public/interaction_service.js +++ b/addons/web/static/src/public/interaction_service.js @@ -108,7 +108,9 @@ class InteractionService { let targets; try { const isMatch = el.matches(I.selector); - targets = isMatch ? [el] : el.querySelectorAll(I.selector); + targets = isMatch + ? [el, ...el.querySelectorAll(I.selector)] + : el.querySelectorAll(I.selector); if (I.selectorHas) { targets = [...targets].filter((el) => !!el.querySelector(I.selectorHas)); } From deb76cae6ee4aa4a0c072f27937e92ef2bc00c45 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 20 Dec 2024 14:16:15 +0100 Subject: [PATCH 092/150] CRMPartnerAssign --- .../src/interactions/crm_partner_assign.js | 152 ++++++++++ .../static/src/js/crm_partner_assign.js | 260 ------------------ 2 files changed, 152 insertions(+), 260 deletions(-) create mode 100644 addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js delete mode 100644 addons/website_crm_partner_assign/static/src/js/crm_partner_assign.js diff --git a/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js b/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js new file mode 100644 index 0000000000000..86cd9af17581b --- /dev/null +++ b/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js @@ -0,0 +1,152 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { _t } from "@web/core/l10n/translation"; +import { parseDate, formatDate, serializeDate } from "@web/core/l10n/dates"; + +const { DateTime } = luxon; + +export class CRMPartnerAssign extends Interaction { + static selector = "#wrapwrap"; + static selectorHas = ".interested_partner_assign_form, .desinterested_partner_assign_form, .opp-stage-button, .new_opp_form"; + + dynamicContent = { + ".interested_partner_assign_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.onInterestedPartnerConfirm) }, + ".desinterested_partner_assign_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.onDesinterestedPartnerConfirm) }, + ".opp-stage-button": { "t-on-click": this.blockedUntilDone(this.onOppStageButtonClick) }, + ".edit_contact_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.editContact) }, + ".new_opp_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.createOpportunity) }, + ".edit_opp_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.editOpportunity) }, + ".edit_opp_form .next_activity": { "t-on-change": this.onChangeNextActivity }, + ".edit_opp_form .activity_date_deadline": { "t-att-value": () => formatDate(this.dateNextActivity) }, + "#new-opp-dialog .contact_name": { "t-on-change": (ev) => this.contactName = ev.currentTarget.value.trim() }, + ".title": { "t-att-value": (el) => this.contactName && !el.value.trim() ? _t("%s's Opportunity", this.contactName) : "" }, + ".edit_contact_form .country_id": { "t-on-change": (ev) => this.countryID = parseInt(ev.currentTarget.selectedOptions[0].value) }, + ".edit_contact_form .state": { + "t-att-style": (el) => ({ + "display": el.getAttribute("country") != this.countryID ? "none" : "block", + }), + }, + ".interested_partner_assign_form .error_partner_assign_interested": { + "t-att-style": () => ({ + "display": this.confirmationFailed ? "block" : undefined, + }), + }, + + }; + + async confirmInterestedPartner() { + await this.services.orm.call("crm.lead", "partner_interested", [ + [parseInt(this.el.querySelector(".interested_partner_assign_form .assign_lead_id").value)], + this.el.querySelector(".interested_partner_assign_form .comment_interested").value, + ]); + window.location.href = "/my/leads"; + } + + async onDesinterestedPartnerConfirm() { + await this.services.orm.call("crm.lead", "partner_desinterested", [ + [parseInt(this.el.querySelector(".desinterested_partner_assign_form .assign_lead_id").value)], + this.el.querySelector(".desinterested_partner_assign_form .comment_desinterested").value, + this.el.querySelector(".desinterested_partner_assign_form .contacted_desinterested").checked, + this.el.querySelector(".desinterested_partner_assign_form .customer_mark_spam").checked, + ]); + window.location.href = "/my/leads"; + } + + async changeOppStage(leadID, stageID) { + await this.services.orm.write("crm.lead", [leadID], { stage_id: stageID }, { + context: Object.assign({ website_partner_assign: 1 }), + }); + window.location.reload(); + } + + async editContact() { + await this.services.orm.call("crm.lead", "update_contact_details_from_portal", [ + [parseInt(this.el.querySelector(".edit_contact_form .opportunity_id").value)], + { + partner_name: this.el.querySelector(".edit_contact_form .partner_name").value, + phone: this.el.querySelector(".edit_contact_form .phone").value, + mobile: this.el.querySelector(".edit_contact_form .mobile").value, + email_from: this.el.querySelector(".edit_contact_form .email_from").value, + street: this.el.querySelector(".edit_contact_form .street").value, + street2: this.el.querySelector(".edit_contact_form .street2").value, + city: this.el.querySelector(".edit_contact_form .city").value, + zip: this.el.querySelector(".edit_contact_form .zip").value, + state_id: parseInt(this.el.querySelector(".edit_contact_form .state_id").selectedOptions[0].value), + country_id: parseInt(this.el.querySelector(".edit_contact_form .country_id").selectedOptions[0].value), + }, + ]); + window.location.reload(); + } + + async createOpportunity() { + const response = await this.services.orm.call("crm.lead", "create_opp_portal", [{ + contact_name: this.el.querySelector(".new_opp_form .contact_name").value, + title: this.el.querySelector(".new_opp_form .title").value, + description: this.el.querySelector(".new_opp_form .description").value, + }]); + if (response.errors) { + this.el.querySelector("#new-opp-dialog .alert")?.remove(); + const alertEl = this.el.createElement("div"); + alertEl.classList.add("alert", "alert-danger"); + alertEl.textContent = response.errors; + const parentEl = this.el.querySelector("#new-opp-dialog"); + this.insert(alertEl, parentEl, "afterbegin"); + } else { + window.location = "/my/opportunity/" + response.id; + } + } + + async editOpportunity() { + const checkAndParseDate = function (value) { + var date = parseDate(value); + if (!date.isValid || date.year < 1900) { + return false; + } + return serializeDate(date); + } + + await this.services.orm.call("crm.lead", "update_lead_portal", [ + [parseInt(this.el.querySelector(".edit_opp_form .opportunity_id").value)], + { + date_deadline: checkAndParseDate(this.el.querySelector(".edit_opp_form .date_deadline").value), + expected_revenue: parseFloat(this.el.querySelector(".edit_opp_form .expected_revenue").value), + probability: parseFloat(this.el.querySelector(".edit_opp_form .probability").value), + activity_type_id: parseInt(this.el.querySelector(".edit_opp_form .next_activity").selectedOptions[0].getAttribute("data")), + activity_summary: this.el.querySelector(".edit_opp_form .activity_summary").value, + activity_date_deadline: checkAndParseDate(this.el.querySelector(".edit_opp_form .activity_date_deadline").value), + priority: this.el.querySelector("input[name='PriorityRadioOptions']:checked").value, + }, + ]); + window.location.reload(); + } + + async onInterestedPartnerConfirm() { + const comment = this.el.querySelector(".interested_partner_assign_form .comment_interested").value; + const contacted = this.el.querySelector(".interested_partner_assign_form .contacted_interested").checked; + this.confirmationFailed = !(comment && contacted); + if (!this.confirmationFailed) { + await this.confirmInterestedPartner(); + } + } + + async onOppStageButtonClick(ev) { + await this.changeOppStage(parseInt(ev.currentTarget.getAttribute("opp")), parseInt(ev.currentTarget.getAttribute("data"))); + } + + onChangeNextActivity() { + const selectedEl = this.el.querySelector(".edit_opp_form .next_activity").selectedOptions[0]; + if (selectedEl.getAttribute("activity_summary")) { + this.el.querySelector(".edit_opp_form .activity_summary").value = selectedEl.getAttribute("activity_summary"); + } + if (selectedEl.getAttribute("delay_count")) { + const delayCount = parseInt(selectedEl.getAttribute("delay_count")); + const delayUnit = selectedEl.getAttribute("delay_unit"); + this.dateNextActivity = DateTime.now().plus({ [delayUnit]: delayCount }); + } + } +} + +registry + .category("public.interactions") + .add("website_crm_partner_assign.crm_partner_assign", CRMPartnerAssign); diff --git a/addons/website_crm_partner_assign/static/src/js/crm_partner_assign.js b/addons/website_crm_partner_assign/static/src/js/crm_partner_assign.js deleted file mode 100644 index 508f8cc5af4f4..0000000000000 --- a/addons/website_crm_partner_assign/static/src/js/crm_partner_assign.js +++ /dev/null @@ -1,260 +0,0 @@ -import { _t } from "@web/core/l10n/translation"; -import publicWidget from "@web/legacy/js/public/public_widget"; -import { parseDate, formatDate, serializeDate } from "@web/core/l10n/dates"; -const { DateTime } = luxon; - -publicWidget.registry.crmPartnerAssign = publicWidget.Widget.extend({ - selector: '#wrapwrap', - selectorHas: '.interested_partner_assign_form, .desinterested_partner_assign_form, .opp-stage-button, .new_opp_form', - events: { - 'click .interested_partner_assign_confirm': '_onInterestedPartnerAssignConfirm', - 'click .desinterested_partner_assign_confirm': '_onDesinterestedPartnerAssignConfirm', - 'click .opp-stage-button': '_onOppStageButtonClick', - 'change .edit_contact_form .country_id': '_onEditContactFormChange', - 'click .edit_contact_confirm': '_onEditContactConfirm', - 'click .new_opp_confirm': '_onNewOppConfirm', - 'click .edit_opp_confirm': '_onEditOppConfirm', - 'change .edit_opp_form .next_activity': '_onChangeNextActivity', - 'change #new-opp-dialog .contact_name': '_onChangeContactName', - }, - - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Element} btnEl - * @param {function} callback - * @returns {Promise} - */ - _buttonExec: function (btnEl, callback) { - // TODO remove once the automatic system which does this lands in master - btnEl.setAttribute("disabled", true); - return callback.call(this).catch(function (e) { - btnEl.removeAttribute("disabled"); - if (e instanceof Error) { - return Promise.reject(e); - } - }); - }, - /** - * @private - * @returns {Promise} - */ - _confirmInterestedPartner: function () { - return this.orm.call("crm.lead", "partner_interested", [ - [parseInt(document.querySelector(".interested_partner_assign_form .assign_lead_id").value)], - document.querySelector(".interested_partner_assign_form .comment_interested").value - ]).then(function () { - window.location.href = '/my/leads'; - }); - }, - /** - * @private - * @returns {Promise} - */ - _confirmDesinterestedPartner: function () { - return this.orm.call("crm.lead", "partner_desinterested", [ - [parseInt(document.querySelector(".desinterested_partner_assign_form .assign_lead_id").value)], - document.querySelector(".desinterested_partner_assign_form .comment_desinterested").value, - document.querySelector(".desinterested_partner_assign_form .contacted_desinterested").checked, - document.querySelector(".desinterested_partner_assign_form .customer_mark_spam").checked, - ]).then(function () { - window.location.href = '/my/leads'; - }); - }, - /** - * @private - * @param {} - * @returns {Promise} - */ - _changeOppStage: function (leadID, stageID) { - return this.orm.write("crm.lead", [leadID], { stage_id: stageID }, { - context: Object.assign({website_partner_assign: 1}), - }).then(function () { - window.location.reload(); - }); - }, - /** - * @private - * @returns {Promise} - */ - _editContact: function () { - return this.orm.call("crm.lead", "update_contact_details_from_portal", [ - [parseInt(document.querySelector(".edit_contact_form .opportunity_id").value)], - { - partner_name: document.querySelector(".edit_contact_form .partner_name").value, - phone: document.querySelector(".edit_contact_form .phone").value, - mobile: document.querySelector(".edit_contact_form .mobile").value, - email_from: document.querySelector(".edit_contact_form .email_from").value, - street: document.querySelector(".edit_contact_form .street").value, - street2: document.querySelector(".edit_contact_form .street2").value, - city: document.querySelector(".edit_contact_form .city").value, - zip: document.querySelector(".edit_contact_form .zip").value, - state_id: parseInt(document.querySelector(".edit_contact_form .state_id").selectedOptions[0].value), - country_id: parseInt(document.querySelector(".edit_contact_form .country_id").selectedOptions[0].value), - }, - ]).then(function () { - window.location.reload(); - }); - }, - /** - * @private - * @returns {Promise} - */ - _createOpportunity: function () { - return this.orm.call("crm.lead", "create_opp_portal", [{ - contact_name: document.querySelector(".new_opp_form .contact_name").value, - title: document.querySelector(".new_opp_form .title").value, - description: document.querySelector(".new_opp_form .description").value, - }]).then(function (response) { - if (response.errors) { - document.querySelector("#new-opp-dialog .alert")?.remove(); - const alertEl = document.createElement("div"); - alertEl.classList.add("alert", "alert-danger"); - alertEl.textContent = response.errors; - const parentEl = document.querySelector("#new-opp-dialog"); - parentEl.insertBefore(alertEl, parentEl.firstElementChild); - return Promise.reject(response); - } else { - window.location = '/my/opportunity/' + response.id; - } - }); - }, - /** - * @private - * @returns {Promise} - */ - _editOpportunity: function () { - return this.orm.call("crm.lead", "update_lead_portal", [ - [parseInt(document.querySelector(".edit_opp_form .opportunity_id").value)], - { - date_deadline: this._parse_date(document.querySelector(".edit_opp_form .date_deadline").value), - expected_revenue: parseFloat(document.querySelector(".edit_opp_form .expected_revenue").value), - probability: parseFloat(document.querySelector(".edit_opp_form .probability").value), - activity_type_id: parseInt(document.querySelector(".edit_opp_form .next_activity").selectedOptions[0].getAttribute("data")), - activity_summary: document.querySelector(".edit_opp_form .activity_summary").value, - activity_date_deadline: this._parse_date(document.querySelector(".edit_opp_form .activity_date_deadline").value), - priority: document.querySelector("input[name='PriorityRadioOptions']:checked").value, - }, - ]).then(function () { - window.location.reload(); - }); - }, - - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onInterestedPartnerAssignConfirm: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - if (document.querySelector(".interested_partner_assign_form .comment_interested").value && document.querySelector(".interested_partner_assign_form .contacted_interested").checked) { - this._buttonExec(ev.currentTarget, this._confirmInterestedPartner); - } else { - document.querySelector(".interested_partner_assign_form .error_partner_assign_interested").style.display = "block"; - } - }, - /** - * @private - * @param {Event} ev - */ - _onDesinterestedPartnerAssignConfirm: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._buttonExec(ev.currentTarget, this._confirmDesinterestedPartner); - }, - /** - * @private - * @param {Event} ev - */ - _onOppStageButtonClick: function (ev) { - const btnEl = ev.currentTarget; - this._buttonExec( - btnEl, - this._changeOppStage.bind(this, parseInt(btnEl.getAttribute("opp")), parseInt(btnEl.getAttribute("data"))) - ); - }, - /** - * @private - * @param {Event} ev - */ - _onEditContactFormChange: function (ev) { - var countryID = document.querySelector(".edit_contact_form .country_id").selectedOptions[0].value; - document.querySelectorAll(".edit_contact_form .state").forEach(state => { - state.style.display = state.getAttribute("country") != countryID ? "none" : "block"; - }); - }, - /** - * @private - * @param {Event} ev - */ - _onEditContactConfirm: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._buttonExec(ev.currentTarget, this._editContact); - }, - /** - * @private - * @param {Event} ev - */ - _onNewOppConfirm: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._buttonExec(ev.currentTarget, this._createOpportunity); - }, - /** - * @private - * @param {Event} ev - */ - _onEditOppConfirm: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._buttonExec(ev.currentTarget, this._editOpportunity); - }, - /** - * @private - * @param {Event} ev - */ - _onChangeContactName: function (ev) { - const contactName = ev.currentTarget.value.trim(); - let titleEl = this.el.querySelector('.title'); - if (!titleEl.value.trim()) { - titleEl.value = contactName ? _t("%s's Opportunity", contactName) : ''; - } - }, - /** - * @private - * @param {Event} ev - */ - _onChangeNextActivity: function (ev) { - const selectedEl = document.querySelector(".edit_opp_form .next_activity").selectedOptions[0]; - if (selectedEl.getAttribute("activity_summary")) { - document.querySelector(".edit_opp_form .activity_summary").value = selectedEl.getAttribute("activity_summary"); - } - if (selectedEl.getAttribute("delay_count")) { - const value = +selectedEl.getAttribute("delay_count"); - const unit = selectedEl.getAttribute("delay_unit"); - const date = DateTime.now().plus({ [unit]: value}); - document.querySelector(".edit_opp_form .activity_date_deadline").value = formatDate(date); - } - }, - _parse_date: function (value) { - var date = parseDate(value); - if (!date.isValid || date.year < 1900) { - return false; - } - return serializeDate(date); - }, -}); From f72d31cc3f5316a30edf34a194581650e73c3f27 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 27 Dec 2024 08:10:59 +0100 Subject: [PATCH 093/150] partial revert: no second start --- .../web/static/src/legacy/js/public/public_root.js | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index b6c6f791e7234..f53ce9a515f01 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -212,18 +212,7 @@ export const PublicRoot = publicWidget.Widget.extend({ }); return Promise.all(proms); }); - return Promise.all(defs).then(() => { - // TODO Find a better way. - // Also start interactions that might be needed within started widgets. - const targetEl = $from ? $from[0] : undefined; - const publicInteractions = this.bindService("public.interactions"); - this.startFromEventHandler = true; - try { - publicInteractions.startInteractions(targetEl); - } finally { - this.startFromEventHandler = false; - } - }); + return Promise.all(defs); }, /** * Destroys all registered widget instances. Website would need this before From e000fef26abc15401e129f27e495fd47852c1dd9 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Thu, 26 Dec 2024 16:31:12 +0100 Subject: [PATCH 094/150] Simplify some functions name blockedUntilDone => locked throttledForAnimation => throttled --- addons/web/static/src/public/interaction.js | 36 +++++++++---------- .../static/tests/public/interaction.test.js | 30 ++++++++-------- .../static/src/interactions/animation.js | 2 +- .../interactions/popup/no_backdrop_popup.js | 2 +- .../interactions/video/background_video.js | 4 +-- .../interactions/zoomed_background_shape.js | 4 +-- .../src/interactions/crm_partner_assign.js | 12 +++---- .../src/interactions/website_forum_spam.js | 16 ++++----- .../src/interactions/carousel_product.js | 4 +-- 9 files changed, 55 insertions(+), 55 deletions(-) diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index 8ac3c2425a9eb..8fff4e8bbf0de 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -113,28 +113,28 @@ export class Interaction { * initialize everything needed by the interaction. The el element is * available and can be used. Services are ready and available as well. */ - setup() {} + 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() {} + 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() {} + 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() {} + destroy() { } // ------------------------------------------------------------------------- // helpers @@ -228,23 +228,10 @@ export class Interaction { ); } - /** - * 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. - */ - blockedUntilDone(fn, useLoadingAnimation = false) { - if (useLoadingAnimation) { - return makeButtonHandler(fn); - } else { - return makeAsyncHandler(fn); - } - } - /** * Throttles a function for animation and makes sure it is cancelled upon destroy. */ - throttledForAnimation(fn) { + throttled(fn) { const throttledFn = throttleForAnimation((...args) => { fn.apply(this, args); if (this.isReady) { @@ -267,6 +254,19 @@ export class Interaction { ); } + /** + * 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) { + 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 diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index 53bec54034679..f98a16637a79c 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -553,7 +553,7 @@ describe("handling crashes", () => { class Test extends Interaction { static selector = ".test"; dynamicContent = { - span: { click: () => {} }, + span: { click: () => { } }, }; } let error = null; @@ -1746,15 +1746,15 @@ describe("insert", () => { }); }); -describe("blockedUntilDone", () => { - test("blockedUntilDone disable any further execution while already executing", async () => { +describe("locked", () => { + test("locked disable any further execution while already executing", async () => { let started = 0; let finished = 0; class Test extends Interaction { static selector = ".test"; dynamicContent = { button: { - "t-on-click": this.blockedUntilDone(this.onClickLong), + "t-on-click": this.locked(this.onClickLong), }, }; async onClickLong() { @@ -1775,12 +1775,12 @@ describe("blockedUntilDone", () => { expect(finished).toBe(1); }); - test("blockedUntilDone doesn't add a loading icon if not required", async () => { + test("locked doesn't add a loading icon if not required", async () => { class Test extends Interaction { static selector = ".test"; dynamicContent = { button: { - "t-on-click": this.blockedUntilDone(this.onClickLong), + "t-on-click": this.locked(this.onClickLong), }, }; async onClickLong() { @@ -1794,12 +1794,12 @@ describe("blockedUntilDone", () => { expect(el.querySelectorAll("span")).toHaveLength(0); }); - test("blockedUntilDone add a loading icon when the execution takes more than 400ms", async () => { + test("locked add a loading icon when the execution takes more than 400ms", async () => { class Test extends Interaction { static selector = ".test"; dynamicContent = { button: { - "t-on-click": this.blockedUntilDone(this.onClickLong, true), + "t-on-click": this.locked(this.onClickLong, true), }, }; async onClickLong() { @@ -1813,11 +1813,11 @@ describe("blockedUntilDone", () => { expect(el.querySelectorAll("span")).toHaveLength(1); }); - test("blockedUntilDone automatically binds functions", async () => { + test("locked automatically binds functions", async () => { class Test extends Interaction { static selector = ".test"; dynamicContent = { - button: { "t-on-click": this.blockedUntilDone(this.sayValue) }, + button: { "t-on-click": this.locked(this.sayValue) }, }; setup() { this.value = "value"; @@ -2039,7 +2039,7 @@ describe("throttled_for_animation (1)", () => { _root: { "t-on-click": () => this.throttle() }, }; setup() { - this.throttle = this.throttledForAnimation(this.doSomething); + this.throttle = this.throttled(this.doSomething); } doSomething() { expect.step("done"); @@ -2106,7 +2106,7 @@ describe("throttled_for_animation (2)", () => { static selector = ".test"; dynamicContent = { _root: { "t-att-a": () => "b" } }; setup() { - const fn = this.throttledForAnimation(() => expect.step("throttle")); + const fn = this.throttled(() => expect.step("throttle")); fn(); } async willStart() { @@ -2129,7 +2129,7 @@ describe("throttled_for_animation (2)", () => { class Test extends Interaction { static selector = ".test"; dynamicContent = { - _root: { "t-on-click": this.throttledForAnimation((ev) => expect.step(ev.type)) }, + _root: { "t-on-click": this.throttled((ev) => expect.step(ev.type)) }, }; } await startInteraction(Test, TemplateTest); @@ -2143,7 +2143,7 @@ describe("throttled_for_animation (2)", () => { static selector = ".test"; dynamicContent = { _root: { - "t-on-click": this.throttledForAnimation((ev) => { + "t-on-click": this.throttled((ev) => { expect(ev.currentTarget.tagName).toBe("DIV"); expect.step(ev.type); }), @@ -2161,7 +2161,7 @@ describe("throttled_for_animation (2)", () => { static selector = ".test"; dynamicContent = { _root: { - "t-on-click.withTarget": this.throttledForAnimation((ev, el) => { + "t-on-click.withTarget": this.throttled((ev, el) => { expect(el.tagName).toBe("DIV"); expect.step(ev.type); }), diff --git a/addons/website/static/src/interactions/animation.js b/addons/website/static/src/interactions/animation.js index 1e8ce46146e73..97e9899fcca7b 100644 --- a/addons/website/static/src/interactions/animation.js +++ b/addons/website/static/src/interactions/animation.js @@ -24,7 +24,7 @@ export class Animation extends Interaction { // Setting capture to true allows to take advantage of event // bubbling for events that otherwise don’t support it. (e.g. useful // when scrolling a modal) - "t-on-scroll.capture": this.throttledForAnimation(this.scrollWebsiteAnimate), + "t-on-scroll.capture": this.throttled(this.scrollWebsiteAnimate), }, _root: { "t-att-class": (el) => ({ diff --git a/addons/website/static/src/interactions/popup/no_backdrop_popup.js b/addons/website/static/src/interactions/popup/no_backdrop_popup.js index 12e83167ecc2c..554f479661bcf 100644 --- a/addons/website/static/src/interactions/popup/no_backdrop_popup.js +++ b/addons/website/static/src/interactions/popup/no_backdrop_popup.js @@ -12,7 +12,7 @@ export class NoBackdropPopup extends Interaction { }; setup() { - this.throttledUpdateScrollbar = this.throttledForAnimation(this.updateScrollbar); + this.throttledUpdateScrollbar = this.throttled(this.updateScrollbar); this.removeResizeListener = null; this.resizeObserver = null; } diff --git a/addons/website/static/src/interactions/video/background_video.js b/addons/website/static/src/interactions/video/background_video.js index 9536af561fd99..ffdafbc0bff53 100644 --- a/addons/website/static/src/interactions/video/background_video.js +++ b/addons/website/static/src/interactions/video/background_video.js @@ -18,10 +18,10 @@ class BackgroundVideo extends Interaction { "t-on-optionalCookiesAccepted": () => this.iframeEl.src = this.videoSrc, }, _window: { - "t-on-resize": this.throttledForAnimation(this.adjustIframe), + "t-on-resize": this.throttled(this.adjustIframe), }, _dropdown: { - "t-on-shown.bs.dropdown": this.throttledForAnimation(this.adjustIframe), + "t-on-shown.bs.dropdown": this.throttled(this.adjustIframe), }, _modal: { "t-on-show.bs.modal": () => this.hideVideoContainer = true, diff --git a/addons/website/static/src/interactions/zoomed_background_shape.js b/addons/website/static/src/interactions/zoomed_background_shape.js index ea7934abf485f..b8631359930a1 100644 --- a/addons/website/static/src/interactions/zoomed_background_shape.js +++ b/addons/website/static/src/interactions/zoomed_background_shape.js @@ -26,7 +26,7 @@ export class ZoomedBackgroundShape extends Interaction { static selector = ".o_we_shape"; dynamicContent = { _window: { - "t-on-resize": this.throttledForAnimation(this.resizeBackgroundShape), + "t-on-resize": this.throttled(this.resizeBackgroundShape), }, }; @@ -75,4 +75,4 @@ registry registry .category("public.interactions.edit") - .add("website.zoomed_background_shape", { Interaction: ZoomedBackgroundShape}); + .add("website.zoomed_background_shape", { Interaction: ZoomedBackgroundShape }); diff --git a/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js b/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js index 86cd9af17581b..1c8359b5991ca 100644 --- a/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js +++ b/addons/website_crm_partner_assign/static/src/interactions/crm_partner_assign.js @@ -11,12 +11,12 @@ export class CRMPartnerAssign extends Interaction { static selectorHas = ".interested_partner_assign_form, .desinterested_partner_assign_form, .opp-stage-button, .new_opp_form"; dynamicContent = { - ".interested_partner_assign_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.onInterestedPartnerConfirm) }, - ".desinterested_partner_assign_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.onDesinterestedPartnerConfirm) }, - ".opp-stage-button": { "t-on-click": this.blockedUntilDone(this.onOppStageButtonClick) }, - ".edit_contact_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.editContact) }, - ".new_opp_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.createOpportunity) }, - ".edit_opp_confirm": { "t-on-click.prevent.stop": this.blockedUntilDone(this.editOpportunity) }, + ".interested_partner_assign_confirm": { "t-on-click.prevent.stop": this.locked(this.onInterestedPartnerConfirm) }, + ".desinterested_partner_assign_confirm": { "t-on-click.prevent.stop": this.locked(this.onDesinterestedPartnerConfirm) }, + ".opp-stage-button": { "t-on-click": this.locked(this.onOppStageButtonClick) }, + ".edit_contact_confirm": { "t-on-click.prevent.stop": this.locked(this.editContact) }, + ".new_opp_confirm": { "t-on-click.prevent.stop": this.locked(this.createOpportunity) }, + ".edit_opp_confirm": { "t-on-click.prevent.stop": this.locked(this.editOpportunity) }, ".edit_opp_form .next_activity": { "t-on-change": this.onChangeNextActivity }, ".edit_opp_form .activity_date_deadline": { "t-att-value": () => formatDate(this.dateNextActivity) }, "#new-opp-dialog .contact_name": { "t-on-change": (ev) => this.contactName = ev.currentTarget.value.trim() }, diff --git a/addons/website_forum/static/src/interactions/website_forum_spam.js b/addons/website_forum/static/src/interactions/website_forum_spam.js index b986a72c8a961..631bab8857428 100644 --- a/addons/website_forum/static/src/interactions/website_forum_spam.js +++ b/addons/website_forum/static/src/interactions/website_forum_spam.js @@ -9,7 +9,7 @@ class WebsiteForumSpam extends Interaction { static selector = ".o_wforum_moderation_queue"; dynamicContent = { ".o_wforum_select_all_spam": { "t-on-click": this.onSelectAllSpamClick }, - ".o_wforum_mark_spam": { "t-on-click": this.blockedUntilDone(this.onMarkSpamClick, true) }, + ".o_wforum_mark_spam": { "t-on-click": this.locked(this.onMarkSpamClick, true) }, "#spamSearch": { "t-on-input": this.debounced(this.onSpamSearchInput, 200) }, }; @@ -32,13 +32,13 @@ class WebsiteForumSpam extends Interaction { const toSearch = ev.target.value; const posts = await this.keepLast.add( this.waitFor(this.services.orm.searchRead( - "forum.post", - [["id", "in", this.spamIDs], - "|", - ["name", "ilike", toSearch], - ["content", "ilike", toSearch]], - ["name", "content"] - )) + "forum.post", + [["id", "in", this.spamIDs], + "|", + ["name", "ilike", toSearch], + ["content", "ilike", toSearch]], + ["name", "content"] + )) ); const postSpamEl = this.el.querySelector("div.post_spam"); const postSpamElContent = postSpamEl.children; diff --git a/addons/website_sale/static/src/interactions/carousel_product.js b/addons/website_sale/static/src/interactions/carousel_product.js index a9e432131c016..84dc6a551e6da 100644 --- a/addons/website_sale/static/src/interactions/carousel_product.js +++ b/addons/website_sale/static/src/interactions/carousel_product.js @@ -12,7 +12,7 @@ export class CarouselProduct extends Interaction { }), }, _window: { - "t-on-resize.noupdate": this.throttledForAnimation(this.onSlideCarouselProduct), + "t-on-resize.noupdate": this.throttled(this.onSlideCarouselProduct), }, ".carousel-indicators": { "t-att-style": () => ({ @@ -70,7 +70,7 @@ export class CarouselProduct extends Interaction { const indicatorSize = isVertical ? indicatorRect.height : indicatorRect.width; const indicatorPosition = isVertical ? indicatorRect.top - indicatorsDivRect.top - parseFloat(indicatorStyle.marginTop) : indicatorRect.left - indicatorsDivRect.left - parseFloat(indicatorStyle.marginLeft); const scrollSize = isVertical ? indicatorsDivEl.scrollHeight : indicatorsDivEl.scrollWidth; - let indicatorsPositionDiff = (indicatorPosition + (indicatorSize/2)) - (indicatorsDivSize/2); + let indicatorsPositionDiff = (indicatorPosition + (indicatorSize / 2)) - (indicatorsDivSize / 2); indicatorsPositionDiff = Math.min(indicatorsPositionDiff, scrollSize - indicatorsDivSize); this.updateJustifyContent(); this.updateContent(); From d71f091ddcda4478cdf33d56afa921c0e6ba8aea Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 27 Dec 2024 09:56:24 +0100 Subject: [PATCH 095/150] default super function if missing when patching dynamic content --- addons/web/static/src/public/utils.js | 7 +++++-- addons/web/static/tests/public/utils.test.js | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/addons/web/static/src/public/utils.js b/addons/web/static/src/public/utils.js index 39bb103cb03ca..7fba6dcd8f9ef 100644 --- a/addons/web/static/src/public/utils.js +++ b/addons/web/static/src/public/utils.js @@ -124,7 +124,10 @@ export function patchDynamicContentEntry(dynamicContent, selector, t, replacemen const forSelector = dynamicContent[selector]; if (replacement === undefined) { delete forSelector[t]; - } else if (typeof replacement === "function" && forSelector[t] && t !== "t-component") { + } 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) => { @@ -157,7 +160,7 @@ export function patchDynamicContentEntry(dynamicContent, selector, t, replacemen * "test": this.condition && old.test, * }), * "t-on-click": (el, oldFn) => { - * oldFn?.(el); + * oldFn.(el); * this.doMoreStuff(); * }, * }, diff --git a/addons/web/static/tests/public/utils.test.js b/addons/web/static/tests/public/utils.test.js index 4747c9f5535d1..1d5610c286ff2 100644 --- a/addons/web/static/tests/public/utils.test.js +++ b/addons/web/static/tests/public/utils.test.js @@ -151,4 +151,21 @@ describe("patch dynamic content", () => { parent.somewhere["t-on-click"](); expect.verifySteps(["base", "patch"]); }); + + test("patch t-on-... does not require knowledge about there being a super", () => { + const parent = { + // No t-on-click here. + }; + const patch = { + somewhere: { + "t-on-click": (el, oldFn) => { + oldFn(); + expect.step("patch"); + }, + }, + }; + patchDynamicContent(parent, patch); + parent.somewhere["t-on-click"](); + expect.verifySteps(["patch"]); + }); }); From 3c65c19864b75ed936b973b1522b9a9dbe62fed8 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 11:09:50 +0100 Subject: [PATCH 096/150] convert booth_registration patch --- .../src/interactions/booth_registration.js | 28 +++++++++++ .../static/src/js/booth_register.js | 46 ------------------- 2 files changed, 28 insertions(+), 46 deletions(-) create mode 100644 addons/website_event_booth_sale/static/src/interactions/booth_registration.js delete mode 100644 addons/website_event_booth_sale/static/src/js/booth_register.js diff --git a/addons/website_event_booth_sale/static/src/interactions/booth_registration.js b/addons/website_event_booth_sale/static/src/interactions/booth_registration.js new file mode 100644 index 0000000000000..71cded95b75fc --- /dev/null +++ b/addons/website_event_booth_sale/static/src/interactions/booth_registration.js @@ -0,0 +1,28 @@ +import { BoothRegistration } from "@website_event_booth/interactions/booth_registration"; +import { patch } from "@web/core/utils/patch"; + +/** + * This class changes the displayed price after selecting the requested booths. + */ +patch(BoothRegistration.prototype, { + start() { + super.start(); + this.categoryPrice = this.selectedBoothCategory ? this.selectedBoothCategory.dataset.price : undefined; + }, + onChangeBoothType(targetEl) { + super.onChangeBoothType(targetEl); + this.categoryPrice = parseFloat(targetEl.dataset.price); + }, + onChangeBooth(targetEl) { + super.onChangeBooth(targetEl); + const boothTotalPriceEl = this.el.querySelector(".o_wbooth_booth_total_price"); + boothTotalPriceEl?.classList.toggle("d-none", !boothCount || !this.categoryPrice); + this.updatePrice(boothCount); + }, + updatePrice(boothCount) { + const boothCurrencyEl = this.el.querySelector(".o_wbooth_booth_total_price .oe_currency_value"); + if (boothCurrencyEl) { + boothCurrencyEl.textContent = `${boothCount * this.categoryPrice}`; + } + }, +}); diff --git a/addons/website_event_booth_sale/static/src/js/booth_register.js b/addons/website_event_booth_sale/static/src/js/booth_register.js deleted file mode 100644 index 101210aeddfbb..0000000000000 --- a/addons/website_event_booth_sale/static/src/js/booth_register.js +++ /dev/null @@ -1,46 +0,0 @@ -import BoothRegistration from "@website_event_booth/js/booth_register"; - -/** - * This class changes the displayed price after selecting the requested booths. - */ -BoothRegistration.include({ - - //-------------------------------------------------------------------------- - // Overrides - //-------------------------------------------------------------------------- - - start() { - return this._super.apply(this, arguments).then(() => { - this.categoryPrice = this.selectedBoothCategory ? this.selectedBoothCategory.dataset.price : undefined; - }); - }, - - _onChangeBoothType(ev) { - this.categoryPrice = parseFloat(ev.currentTarget.dataset.price); - return this._super.apply(this, arguments); - }, - - /** - * Updates the displayed total price after selecting the requested booths - * @param boothCount - * @private - */ - _updateUiAfterBoothChange(boothCount) { - this._super.apply(this, arguments); - const boothTotalPriceEl = this.el.querySelector(".o_wbooth_booth_total_price"); - boothTotalPriceEl?.classList.toggle("d-none", !boothCount || !this.categoryPrice); - this._updatePrice(boothCount); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - _updatePrice(boothsCount) { - const boothCurrencyEl = this.el.querySelector(".o_wbooth_booth_total_price .oe_currency_value"); - if (boothCurrencyEl) { - boothCurrencyEl.textContent = `${boothsCount * this.categoryPrice}`; - } - }, - -}); From 615fba8b073ca417e209b541ea73d30b075e4278 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 27 Dec 2024 11:15:58 +0100 Subject: [PATCH 097/150] typo --- addons/web/static/src/public/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/web/static/src/public/utils.js b/addons/web/static/src/public/utils.js index 7fba6dcd8f9ef..6d21ffd2dcbd6 100644 --- a/addons/web/static/src/public/utils.js +++ b/addons/web/static/src/public/utils.js @@ -160,7 +160,7 @@ export function patchDynamicContentEntry(dynamicContent, selector, t, replacemen * "test": this.condition && old.test, * }), * "t-on-click": (el, oldFn) => { - * oldFn.(el); + * oldFn(el); * this.doMoreStuff(); * }, * }, From 54dc791d28c1eeee24ec41bdf1b89a3456b18db1 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 27 Dec 2024 12:29:08 +0100 Subject: [PATCH 098/150] fix test_05_certification_failure_tour --- addons/web/static/src/legacy/js/public/public_root.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index f53ce9a515f01..b6b68e84322e7 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -177,8 +177,13 @@ export const PublicRoot = publicWidget.Widget.extend({ this._stopWidgets($from); if (!options?.starting && !options?.fromInteractionPatch) { - const targetEl = $from ? $from[0] : undefined; - this._restartInteractions(targetEl, options); + if ($from) { + for (const fromEl of $from) { + this._restartInteractions(fromEl, options); + } + } else { + this._restartInteractions(undefined, options); + } } var defs = Object.values(this._getPublicWidgetsRegistry(options)).map((PublicWidget) => { From 78d04dc51d0ac94f43a4ed10797635eebd7a6c74 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 13:49:39 +0100 Subject: [PATCH 099/150] fix manifest --- addons/website_event_booth_sale/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website_event_booth_sale/__manifest__.py b/addons/website_event_booth_sale/__manifest__.py index 2e51b122010da..4285edffa9100 100644 --- a/addons/website_event_booth_sale/__manifest__.py +++ b/addons/website_event_booth_sale/__manifest__.py @@ -16,7 +16,7 @@ 'auto_install': True, 'assets': { 'web.assets_frontend': [ - '/website_event_booth_sale/static/src/js/booth_register.js', + '/website_event_booth_sale/static/src/interactions/*', ], 'web.assets_tests': [ '/website_event_booth_sale/static/tests/tours/**/*.js' From ab3272b684c5a101a0c32a811d5db02017a9ad46 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 27 Dec 2024 13:27:13 +0100 Subject: [PATCH 100/150] fix test_autocomplete --- .../static/src/interactions/address_form.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/website_sale_autocomplete/static/src/interactions/address_form.js b/addons/website_sale_autocomplete/static/src/interactions/address_form.js index 8cd5fa48c1793..5ae6e8d2a7ba3 100644 --- a/addons/website_sale_autocomplete/static/src/interactions/address_form.js +++ b/addons/website_sale_autocomplete/static/src/interactions/address_form.js @@ -42,6 +42,7 @@ class AddressForm extends Interaction { inputContainerEl.appendChild(renderToElement("website_sale_autocomplete.AutocompleteDropDown", { results: response.results, })); + this.refreshListeners(); if (response.session_id) { this.sessionId = response.session_id; } From 9c41bea9ebaa501ba840b3abd978a0ce133943f4 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 30 Dec 2024 07:59:03 +0100 Subject: [PATCH 101/150] small fix booth registration might be the reason the test fail impossible to check in local --- .../static/src/interactions/booth_registration.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/website_event_booth_sale/static/src/interactions/booth_registration.js b/addons/website_event_booth_sale/static/src/interactions/booth_registration.js index 71cded95b75fc..7540d59e01738 100644 --- a/addons/website_event_booth_sale/static/src/interactions/booth_registration.js +++ b/addons/website_event_booth_sale/static/src/interactions/booth_registration.js @@ -15,6 +15,7 @@ patch(BoothRegistration.prototype, { }, onChangeBooth(targetEl) { super.onChangeBooth(targetEl); + const boothCount = this.countSelectedBooths(); const boothTotalPriceEl = this.el.querySelector(".o_wbooth_booth_total_price"); boothTotalPriceEl?.classList.toggle("d-none", !boothCount || !this.categoryPrice); this.updatePrice(boothCount); From 33a7aa48aadefeb00d3b4ccafe4d0216fcb3e6d6 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 27 Dec 2024 15:09:05 +0100 Subject: [PATCH 102/150] autostart on insert & renderAt --- addons/web/__manifest__.py | 1 + addons/web/static/src/public/interaction.js | 23 ++++++--- .../static/tests/public/interaction.test.js | 47 ++++++++++++++++++- .../static/tests/public/interaction.test.xml | 7 +++ 4 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 addons/web/static/tests/public/interaction.test.xml diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 6fa01d7909082..2348d459f51ed 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -447,6 +447,7 @@ ('include', 'web.assets_backend_lazy'), 'web/static/src/public/**/*.js', + 'web/static/tests/public/**/*.xml', ('remove', 'web/static/src/public/database_manager.js'), ('remove', 'web/static/src/public/error_notifications.js'), 'web/static/src/public/public_component_service.js', diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index 8fff4e8bbf0de..e6a1f5871de63 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -1,3 +1,4 @@ +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"; @@ -113,28 +114,28 @@ export class Interaction { * initialize everything needed by the interaction. The el element is * available and can be used. Services are ready and available as well. */ - setup() { } + 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() { } + 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() { } + 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() { } + destroy() {} // ------------------------------------------------------------------------- // helpers @@ -290,8 +291,8 @@ export class Interaction { } /** - * Insert an node at a specific location. The inserted node will be removed - * when the interaction is destroyed. + * Insert and activate an element at a specific location. + * The inserted element will be removed when the interaction is destroyed. * * @param { HTMLElement } el * @param { HTMLElement } [locationEl] the target @@ -300,6 +301,16 @@ export class Interaction { insert(el, locationEl = this.el, position = "beforeend") { locationEl.insertAdjacentElement(position, el); this.registerCleanup(() => el.remove()); + this.services["public.interactions"].startInteractions(el); + this.refreshListeners(); + } + + renderAt(template, renderContext, locationEl, position) { + const fragment = renderToFragment(template, renderContext); + for (const el of [...fragment.children]) { + this.insert(el, locationEl, position); + } + return fragment; } /** diff --git a/addons/web/static/tests/public/interaction.test.js b/addons/web/static/tests/public/interaction.test.js index f98a16637a79c..19b1530d7749d 100644 --- a/addons/web/static/tests/public/interaction.test.js +++ b/addons/web/static/tests/public/interaction.test.js @@ -553,7 +553,7 @@ describe("handling crashes", () => { class Test extends Interaction { static selector = ".test"; dynamicContent = { - span: { click: () => { } }, + span: { click: () => {} }, }; } let error = null; @@ -1675,7 +1675,7 @@ describe("insert", () => { static selector = ".test"; setup() { const node = document.createElement("inserted"); - this.insert(node, this.el); // "beforeend" + this.insert(node, this.el); } } @@ -1746,6 +1746,49 @@ describe("insert", () => { }); }); +describe("renderAt", () => { + test("can render a template inside an element", async () => { + class Test extends Interaction { + static selector = ".test"; + dynamicContent = { + "[data-which]": { + "t-on-click": (ev) => expect.step(ev.target.dataset.which), + }, + }; + setup() { + this.renderAt( + "web.TestSubInteraction1", + { + first: "one", + second: "two", + }, + this.el + ); + } + } + class Test2 extends Interaction { + static selector = "[data-which]"; + dynamicContent = { + _root: { + "t-att-x": () => "x", + }, + }; + } + + const { core, el } = await startInteraction([Test, Test2], TemplateTest); + expect(core.interactions).toHaveLength(3); // 1*Test + 2*Test2 + const testEl = el.querySelector(".test"); + const subEls = testEl.querySelectorAll("[data-which][x=x]"); + await click(subEls[1]); + await click(subEls[0]); + expect.verifySteps(["two", "one"]); + core.stopInteractions(); + expect(testEl.querySelectorAll("[data-which]")).toHaveLength(0); + await click(subEls[0]); + expect.verifySteps([]); + }); +}); + describe("locked", () => { test("locked disable any further execution while already executing", async () => { let started = 0; diff --git a/addons/web/static/tests/public/interaction.test.xml b/addons/web/static/tests/public/interaction.test.xml new file mode 100644 index 0000000000000..f6d9c1922e7e7 --- /dev/null +++ b/addons/web/static/tests/public/interaction.test.xml @@ -0,0 +1,7 @@ + + + +
Sub 1
+
Sub 2
+
+
From 107700c3b07a37d2ba9acd3c0e69e4f398e998f6 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 30 Dec 2024 13:28:06 +0100 Subject: [PATCH 103/150] use renderAt and avoid double startInteractions --- addons/web/static/src/public/interaction.js | 3 ++- .../src/interactions/cookies/cookies_approval.js | 7 ++----- .../static/src/interactions/cookies/cookies_bar.js | 1 - .../static/src/snippets/s_countdown/countdown.js | 6 ++---- .../static/src/snippets/s_searchbar/search_bar.js | 7 ++----- .../static/src/snippets/s_website_form/form.js | 10 ++++------ .../static/src/interactions/booth_registration.js | 12 +++++------- .../website_event_create_meeting_room.js | 6 ++---- .../static/src/interactions/website_forum_share.js | 11 +++-------- .../static/src/interactions/website_forum_spam.js | 3 +-- addons/website_jitsi/static/src/js/chat_room.js | 4 +--- .../static/src/interactions/address_form.js | 6 ++---- 12 files changed, 26 insertions(+), 50 deletions(-) diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index e6a1f5871de63..3c80147b4a834 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -305,8 +305,9 @@ export class Interaction { this.refreshListeners(); } - renderAt(template, renderContext, locationEl, position) { + renderAt(template, renderContext, locationEl, position, callback) { const fragment = renderToFragment(template, renderContext); + callback?.(fragment); for (const el of [...fragment.children]) { this.insert(el, locationEl, position); } diff --git a/addons/website/static/src/interactions/cookies/cookies_approval.js b/addons/website/static/src/interactions/cookies/cookies_approval.js index 06a2b3c9bbd76..031e568f7e21b 100644 --- a/addons/website/static/src/interactions/cookies/cookies_approval.js +++ b/addons/website/static/src/interactions/cookies/cookies_approval.js @@ -1,6 +1,5 @@ import { registry } from "@web/core/registry"; import { MEDIAS_BREAKPOINTS, SIZES } from "@web/core/ui/ui_service"; -import { renderToElement } from "@web/core/utils/render"; import { Interaction } from "@web/public/interaction"; export class CookiesApproval extends Interaction { @@ -32,15 +31,13 @@ export class CookiesApproval extends Interaction { } addOptionalCookiesWarning() { - const optionalCookiesWarningEl = renderToElement("website.cookiesWarning", { + this.renderAt("website.cookiesWarning", { extraStyle: this.iframeEl.parentElement.classList.contains("media_iframe_video") ? `aspect-ratio: 16/9; max-width: ${MEDIAS_BREAKPOINTS[SIZES.SM].maxWidth}px;` : "", extraClasses: getComputedStyle(this.iframeEl.parentElement).position === "absolute" ? "" : "my-3", - }); - this.insert(optionalCookiesWarningEl, this.iframeEl, "afterend"); - this.services["public.interactions"].startInteractions(optionalCookiesWarningEl); + }, this.iframeEl, "afterend"); } onOptionalCookiesAccepted() { diff --git a/addons/website/static/src/interactions/cookies/cookies_bar.js b/addons/website/static/src/interactions/cookies/cookies_bar.js index 1a8457de88ea6..ab496f548d9c8 100644 --- a/addons/website/static/src/interactions/cookies/cookies_bar.js +++ b/addons/website/static/src/interactions/cookies/cookies_bar.js @@ -56,7 +56,6 @@ export class CookiesBar extends Popup { `).firstElementChild; this.insert(this.toggleEl, this.el, "beforebegin"); - this.services["public.interactions"].startInteractions(this.toggleEl); } } diff --git a/addons/website/static/src/snippets/s_countdown/countdown.js b/addons/website/static/src/snippets/s_countdown/countdown.js index ede8ae9600a50..b7d5d908946c7 100644 --- a/addons/website/static/src/snippets/s_countdown/countdown.js +++ b/addons/website/static/src/snippets/s_countdown/countdown.js @@ -3,7 +3,6 @@ import { registry } from "@web/core/registry"; import { _t } from "@web/core/l10n/translation"; import { isCSSColor } from "@web/core/utils/colors"; -import { renderToElement } from "@web/core/utils/render"; import { getCSSVariableValue, getHtmlStyle } from "@html_editor/utils/formatting"; class Countdown extends Interaction { @@ -94,10 +93,9 @@ class Countdown extends Interaction { } else { if (!this.el.querySelector(".s_countdown_end_redirect_message").length) { const container = this.el.querySelector("> .container, > .container-fluid, > .o_container_small"); - - this.insert(renderToElement("website.s_countdown.end_redirect_message", { + this.renderAt("website.s_countdown.end_redirect_message", { redirectUrl: redirectUrl, - }), container); + }, container); } } } else if (this.endAction === "message" || this.endAction === "message_no_countdown") { diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar.js b/addons/website/static/src/snippets/s_searchbar/search_bar.js index 2de2a48d7fbb8..7ae797238ba39 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar.js @@ -3,7 +3,6 @@ import { Interaction } from "@web/public/interaction"; import { rpc } from "@web/core/network/rpc"; import { KeepLast } from "@web/core/utils/concurrency"; import { getTemplate } from "@web/core/templates"; -import { renderToElement } from "@web/core/utils/render"; import { markup } from "@odoo/owl"; export class SearchBar extends Interaction { @@ -120,16 +119,14 @@ export class SearchBar extends Interaction { if (getTemplate(candidate)) { template = candidate; } - this.menuEl = renderToElement(template, { + this.menuEl = this.renderAt(template, { results: results, parts: res["parts"], hasMoreResults: results.length < res["results_count"], search: this.inputEl.value, fuzzySearch: res["fuzzy_search"], widget: this, - }); - this.insert(this.menuEl, this.el); - this.services["public.interactions"].startInteractions(this.menuEl); + }, this.el).children[0]; } this.hasDropdown = !!res; prevMenuEl?.remove(); diff --git a/addons/website/static/src/snippets/s_website_form/form.js b/addons/website/static/src/snippets/s_website_form/form.js index 18e55ac88ae8c..55adb92bda56d 100644 --- a/addons/website/static/src/snippets/s_website_form/form.js +++ b/addons/website/static/src/snippets/s_website_form/form.js @@ -5,7 +5,6 @@ import { Interaction } from "@web/public/interaction"; import { user } from "@web/core/user"; import { delay } from "@web/core/utils/concurrency"; import { _t } from "@web/core/l10n/translation"; -import { renderToElement } from "@web/core/utils/render"; import { post } from "@web/core/network/http_service"; import { localization } from "@web/core/l10n/localization"; import { @@ -582,9 +581,10 @@ export class Form extends Interaction { message = _t("An error has occured, the form has not been sent."); } - resultEl.replaceWith(renderToElement(`website.s_website_form_status_${status}`, { + this.renderAt(`website.s_website_form_status_${status}`, { message: message, - })); + }, resultEl, "afterend"); + resultEl.remove(); } /** @@ -752,9 +752,7 @@ export class Form extends Interaction { * displayed */ createFileBlock(fileDetails, filesZoneEl) { - const fileBlockEl = renderToElement("website.file_block", {fileName: fileDetails.name}); - fileBlockEl.fileDetails = fileDetails; - filesZoneEl.append(fileBlockEl); + this.renderAt("website.file_block", {fileName: fileDetails.name}, filesZoneEl, "beforeend", (el) => el.children[0].fileDetails = fileDetails); } /** * Creates the file upload button (= a button to replace the file input, diff --git a/addons/website_event_booth/static/src/interactions/booth_registration.js b/addons/website_event_booth/static/src/interactions/booth_registration.js index 7feade59b5efc..96f2bcf416ec2 100644 --- a/addons/website_event_booth/static/src/interactions/booth_registration.js +++ b/addons/website_event_booth/static/src/interactions/booth_registration.js @@ -6,8 +6,6 @@ import { rpc } from "@web/core/network/rpc"; import { post } from "@web/core/network/http_service"; import { redirect } from "@web/core/utils/urls"; -import { renderToElement, renderToFragment } from "@web/core/utils/render"; - export class BoothRegistration extends Interaction { static selector = ".o_wbooth_registration"; dynamicContent = { @@ -75,10 +73,11 @@ export class BoothRegistration extends Interaction { updateBoothsList() { const boothsElem = this.el.querySelector('.o_wbooth_booths'); - boothsElem.replaceChildren(renderToFragment('event_booth_checkbox_list', { + this.renderAt("event_booth_checkbox_list", { 'event_booth_ids': this.boothCache[this.activeBoothCategoryId], 'selected_booth_ids': this.isFirstRender ? this.selectedBoothIds : [], - })); + }, boothsElem, "afterend"); + boothsElem.remove(); this.isFirstRender = false; } @@ -188,13 +187,12 @@ export class BoothRegistration extends Interaction { if (jsonResponse.success) { this.el.querySelector('.o_wevent_booth_order_progress').remove(); const boothCategoryId = this.el.querySelector('input[name=booth_category_id]').value; - const boothRegistrationCompleteFormEl = renderToElement("event_booth_registration_complete", { + this.renderAt("event_booth_registration_complete", { booth_category_id: boothCategoryId, event_id: this.eventId, event_name: jsonResponse.event_name, contact: jsonResponse.contact, - }); - this.insert(boothRegistrationCompleteFormEl, formEl, "afterend"); + }, formEl, "afterend"); formEl.remove(); } else if (jsonResponse.redirect) { redirect(jsonResponse.redirect); diff --git a/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js b/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js index 1f4dee7eb926d..c56bd9165086c 100644 --- a/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js +++ b/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js @@ -1,7 +1,6 @@ import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; -import { renderToElement } from "@web/core/utils/render"; import { rpc } from "@web/core/network/rpc"; export class WebsiteEventCreateMeetingRoom extends Interaction { @@ -14,13 +13,12 @@ export class WebsiteEventCreateMeetingRoom extends Interaction { if (!this.createModalEl) { const langs = await this.waitFor(rpc("/event/active_langs")); if (langs) { - this.createModalEl = renderToElement("event_meet_create_room_modal", { + this.createModalEl = this.renderAt("event_meet_create_room_modal", { csrf_token: odoo.csrf_token, eventId: this.el.dataset.eventId, defaultLangCode: this.el.dataset.defaultLangCode, langs: langs, - }); - this.insert(this.createModalEl, this.el, "afterend"); + }, this.el, "afterend").children[0]; } } if (this.createModalEl) { diff --git a/addons/website_forum/static/src/interactions/website_forum_share.js b/addons/website_forum/static/src/interactions/website_forum_share.js index 6f7433a56f6c6..f9e1fb6c616ab 100644 --- a/addons/website_forum/static/src/interactions/website_forum_share.js +++ b/addons/website_forum/static/src/interactions/website_forum_share.js @@ -1,5 +1,4 @@ import { registry } from "@web/core/registry"; -import { renderToElement } from "@web/core/utils/render"; import { Interaction } from "@web/public/interaction"; class WebsiteForumShare extends Interaction { @@ -12,16 +11,12 @@ class WebsiteForumShare extends Interaction { if (socialData.targetType) { const questionEl = document.querySelector(".o_wforum_question"); - const modalEl = renderToElement("website.social_modal", { + this.renderAt("website.social_modal", { target_type: socialData.targetType, state: questionEl.dataset.state, + }, document.body, "beforeend", (el) => { + this.addListener(el, "hidden.bs.modal", () => el.remove()); }); - this.addListener(modalEl, "hidden.bs.modal", () => modalEl.remove()); - this.insert(modalEl, document.body); - - if (modalEl.querySelector(".s_share")) { - this.services["public.interactions"].startInteractions(modalEl.querySelector(".s_share")); - } const bsModal = window.Modal.getOrCreateInstance(document.querySelector("#oe_social_share_modal")); bsModal.show(); this.registerCleanup(() => bsModal.dispose()); diff --git a/addons/website_forum/static/src/interactions/website_forum_spam.js b/addons/website_forum/static/src/interactions/website_forum_spam.js index 631bab8857428..ba13054a972ce 100644 --- a/addons/website_forum/static/src/interactions/website_forum_spam.js +++ b/addons/website_forum/static/src/interactions/website_forum_spam.js @@ -1,7 +1,6 @@ import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; import { KeepLast } from "@web/core/utils/concurrency"; -import { renderToFragment } from "@web/core/utils/render"; import { Interaction } from "@web/public/interaction"; import { cloneContentEls } from "@website/js/utils"; @@ -52,7 +51,7 @@ class WebsiteForumSpam extends Interaction { post.content = childEl.textContent.substring(0, 250); }); // No need for cleanup, it's already done above. - postSpamEl.append(renderToFragment("website_forum.spam_search_name", { posts })); + this.renderAt("website_forum.spam_search_name", { posts }, postSpamEl); } async onMarkSpamClick() { diff --git a/addons/website_jitsi/static/src/js/chat_room.js b/addons/website_jitsi/static/src/js/chat_room.js index 5f12bf00ca8d8..acaa4d546b656 100644 --- a/addons/website_jitsi/static/src/js/chat_room.js +++ b/addons/website_jitsi/static/src/js/chat_room.js @@ -2,7 +2,6 @@ import { browser } from "@web/core/browser/browser"; import { rpc } from "@web/core/network/rpc"; import { registry } from "@web/core/registry"; import { utils as uiUtils } from "@web/core/ui/ui_service"; -import { renderToElement } from "@web/core/utils/render"; import { Interaction } from "@web/public/interaction"; /** @@ -95,8 +94,7 @@ class ChatRoom extends Interaction { await this.waitFor(this.joinJitsiRoom(parentNode)); } else { // create a modal and append the Jitsi iframe in it - const jitsiModalEl = renderToElement("chat_room_modal", {}); - this.insert(jitsiModalEl, document.body); + const jitsiModalEl = this.renderAt("chat_room_modal", {}, document.body).children[0]; const bsJitsiModal = window.Modal.getOrCreateInstance(jitsiModalEl) bsJitsiModal.show(); this.registerCleanup(() => bsJitsiModal.dispose()); diff --git a/addons/website_sale_autocomplete/static/src/interactions/address_form.js b/addons/website_sale_autocomplete/static/src/interactions/address_form.js index 5ae6e8d2a7ba3..7f89c40da4321 100644 --- a/addons/website_sale_autocomplete/static/src/interactions/address_form.js +++ b/addons/website_sale_autocomplete/static/src/interactions/address_form.js @@ -3,7 +3,6 @@ import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { KeepLast } from "@web/core/utils/concurrency"; -import { renderToElement } from "@web/core/utils/render"; class AddressForm extends Interaction { static selector = ".oe_cart .checkout_autoformat"; @@ -39,10 +38,9 @@ class AddressForm extends Interaction { session_id: this.sessionId || null, }).then((response) => { inputContainerEl.querySelector(".dropdown-menu")?.remove(); - inputContainerEl.appendChild(renderToElement("website_sale_autocomplete.AutocompleteDropDown", { + this.renderAt("website_sale_autocomplete.AutocompleteDropDown", { results: response.results, - })); - this.refreshListeners(); + }, inputContainerEl); if (response.session_id) { this.sessionId = response.session_id; } From c60869668b81e1e7813e39539281bb731572c85e Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 30 Dec 2024 13:46:01 +0100 Subject: [PATCH 104/150] booth registration: result is named data --- addons/web/static/src/public/interaction.js | 6 +++++- addons/website_event_booth/__manifest__.py | 2 +- .../static/src/interactions/booth_registration.js | 6 +++--- .../tests/tours/website_event_booth_exhibitor_steps.js | 2 +- .../static/src/interactions/website_forum_share.js | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index 3c80147b4a834..955dca355098e 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -308,7 +308,11 @@ export class Interaction { renderAt(template, renderContext, locationEl, position, callback) { const fragment = renderToFragment(template, renderContext); callback?.(fragment); - for (const el of [...fragment.children]) { + let els = [...fragment.children]; + if (["beforeend", "beforebegin"].includes(position)) { + els = els.reverse(); + } + for (const el of els) { this.insert(el, locationEl, position); } return fragment; diff --git a/addons/website_event_booth/__manifest__.py b/addons/website_event_booth/__manifest__.py index a7c3ed9a90c78..002bf28d095ca 100644 --- a/addons/website_event_booth/__manifest__.py +++ b/addons/website_event_booth/__manifest__.py @@ -26,7 +26,7 @@ 'web.assets_frontend': [ '/website_event_booth/static/src/interactions/*', '/website_event_booth/static/src/scss/website_event_booth.scss', - 'website_event_booth/static/src/xml/event_booth_registration_templates.xml', + '/website_event_booth/static/src/xml/event_booth_registration_templates.xml', ], }, 'license': 'LGPL-3', diff --git a/addons/website_event_booth/static/src/interactions/booth_registration.js b/addons/website_event_booth/static/src/interactions/booth_registration.js index 96f2bcf416ec2..200ccfea089f3 100644 --- a/addons/website_event_booth/static/src/interactions/booth_registration.js +++ b/addons/website_event_booth/static/src/interactions/booth_registration.js @@ -56,7 +56,7 @@ export class BoothRegistration extends Interaction { if (data && data.unavailable_booths.length) { const boothIdEls = this.el.querySelectorAll("input[name='event_booth_ids']"); for (const boothIdEl of boothIdEls) { - if (result.unavailable_booths.includes(parseInt(boothIdEl.value))) { + if (data.unavailable_booths.includes(parseInt(boothIdEl.value))) { boothIdEl.closest(".form-check").classList.add("text-danger"); } } @@ -73,11 +73,11 @@ export class BoothRegistration extends Interaction { updateBoothsList() { const boothsElem = this.el.querySelector('.o_wbooth_booths'); + boothsElem.replaceChildren(); this.renderAt("event_booth_checkbox_list", { 'event_booth_ids': this.boothCache[this.activeBoothCategoryId], 'selected_booth_ids': this.isFirstRender ? this.selectedBoothIds : [], - }, boothsElem, "afterend"); - boothsElem.remove(); + }, boothsElem); this.isFirstRender = false; } diff --git a/addons/website_event_booth_exhibitor/static/tests/tours/website_event_booth_exhibitor_steps.js b/addons/website_event_booth_exhibitor/static/tests/tours/website_event_booth_exhibitor_steps.js index d5424ae23d58c..e9956784339b0 100644 --- a/addons/website_event_booth_exhibitor/static/tests/tours/website_event_booth_exhibitor_steps.js +++ b/addons/website_event_booth_exhibitor/static/tests/tours/website_event_booth_exhibitor_steps.js @@ -2,7 +2,7 @@ class FinalSteps { _getSteps() { return [{ - trigger: 'h3:contains("Booth Registration completed!")', + trigger: 'h4:contains("Booth Registration completed!")', }]; } diff --git a/addons/website_forum/static/src/interactions/website_forum_share.js b/addons/website_forum/static/src/interactions/website_forum_share.js index f9e1fb6c616ab..5e6c0e120f07f 100644 --- a/addons/website_forum/static/src/interactions/website_forum_share.js +++ b/addons/website_forum/static/src/interactions/website_forum_share.js @@ -15,7 +15,7 @@ class WebsiteForumShare extends Interaction { target_type: socialData.targetType, state: questionEl.dataset.state, }, document.body, "beforeend", (el) => { - this.addListener(el, "hidden.bs.modal", () => el.remove()); + this.addListener(el.children[0], "hidden.bs.modal", () => el.children[0].remove()); }); const bsModal = window.Modal.getOrCreateInstance(document.querySelector("#oe_social_share_modal")); bsModal.show(); From da0210968625e0b87c37ff0ff7324a96f8cdfd23 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 31 Dec 2024 08:57:43 +0100 Subject: [PATCH 105/150] renderAt output and callback parameter as nodes array --- addons/web/static/src/public/interaction.js | 19 +++++++++++++++---- .../src/snippets/s_searchbar/search_bar.js | 2 +- .../src/snippets/s_website_form/form.js | 2 +- .../website_event_create_meeting_room.js | 2 +- .../src/interactions/website_forum_share.js | 4 ++-- .../website_jitsi/static/src/js/chat_room.js | 2 +- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/addons/web/static/src/public/interaction.js b/addons/web/static/src/public/interaction.js index 955dca355098e..0e4bfc80db860 100644 --- a/addons/web/static/src/public/interaction.js +++ b/addons/web/static/src/public/interaction.js @@ -305,17 +305,28 @@ export class Interaction { 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 + * @returns { HTMLElement[] } rendered elements + */ renderAt(template, renderContext, locationEl, position, callback) { const fragment = renderToFragment(template, renderContext); - callback?.(fragment); - let els = [...fragment.children]; + const els = [...fragment.children]; + callback?.(els); if (["beforeend", "beforebegin"].includes(position)) { - els = els.reverse(); + els.reverse(); } for (const el of els) { this.insert(el, locationEl, position); } - return fragment; + return [...fragment.children]; } /** diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar.js b/addons/website/static/src/snippets/s_searchbar/search_bar.js index 7ae797238ba39..dc462960951eb 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar.js @@ -126,7 +126,7 @@ export class SearchBar extends Interaction { search: this.inputEl.value, fuzzySearch: res["fuzzy_search"], widget: this, - }, this.el).children[0]; + }, this.el)[0]; } this.hasDropdown = !!res; prevMenuEl?.remove(); diff --git a/addons/website/static/src/snippets/s_website_form/form.js b/addons/website/static/src/snippets/s_website_form/form.js index 55adb92bda56d..06fe03391f78e 100644 --- a/addons/website/static/src/snippets/s_website_form/form.js +++ b/addons/website/static/src/snippets/s_website_form/form.js @@ -752,7 +752,7 @@ export class Form extends Interaction { * displayed */ createFileBlock(fileDetails, filesZoneEl) { - this.renderAt("website.file_block", {fileName: fileDetails.name}, filesZoneEl, "beforeend", (el) => el.children[0].fileDetails = fileDetails); + this.renderAt("website.file_block", {fileName: fileDetails.name}, filesZoneEl, "beforeend", (els) => els[0].fileDetails = fileDetails); } /** * Creates the file upload button (= a button to replace the file input, diff --git a/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js b/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js index c56bd9165086c..1202193932528 100644 --- a/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js +++ b/addons/website_event_meet/static/src/interactions/website_event_create_meeting_room.js @@ -18,7 +18,7 @@ export class WebsiteEventCreateMeetingRoom extends Interaction { eventId: this.el.dataset.eventId, defaultLangCode: this.el.dataset.defaultLangCode, langs: langs, - }, this.el, "afterend").children[0]; + }, this.el, "afterend")[0]; } } if (this.createModalEl) { diff --git a/addons/website_forum/static/src/interactions/website_forum_share.js b/addons/website_forum/static/src/interactions/website_forum_share.js index 5e6c0e120f07f..bf2d87f5d2b25 100644 --- a/addons/website_forum/static/src/interactions/website_forum_share.js +++ b/addons/website_forum/static/src/interactions/website_forum_share.js @@ -14,8 +14,8 @@ class WebsiteForumShare extends Interaction { this.renderAt("website.social_modal", { target_type: socialData.targetType, state: questionEl.dataset.state, - }, document.body, "beforeend", (el) => { - this.addListener(el.children[0], "hidden.bs.modal", () => el.children[0].remove()); + }, document.body, "beforeend", (els) => { + this.addListener(els[0], "hidden.bs.modal", () => els[0].remove()); }); const bsModal = window.Modal.getOrCreateInstance(document.querySelector("#oe_social_share_modal")); bsModal.show(); diff --git a/addons/website_jitsi/static/src/js/chat_room.js b/addons/website_jitsi/static/src/js/chat_room.js index acaa4d546b656..d84d66662954a 100644 --- a/addons/website_jitsi/static/src/js/chat_room.js +++ b/addons/website_jitsi/static/src/js/chat_room.js @@ -94,7 +94,7 @@ class ChatRoom extends Interaction { await this.waitFor(this.joinJitsiRoom(parentNode)); } else { // create a modal and append the Jitsi iframe in it - const jitsiModalEl = this.renderAt("chat_room_modal", {}, document.body).children[0]; + const jitsiModalEl = this.renderAt("chat_room_modal", {}, document.body)[0]; const bsJitsiModal = window.Modal.getOrCreateInstance(jitsiModalEl) bsJitsiModal.show(); this.registerCleanup(() => bsJitsiModal.dispose()); From 466718bbb1b8bca4d19243e8ea40a19340a2f343 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 12:35:02 +0100 Subject: [PATCH 106/150] Small fix --- addons/auth_signup/static/src/interactions/sign_up_form.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/auth_signup/static/src/interactions/sign_up_form.js b/addons/auth_signup/static/src/interactions/sign_up_form.js index 2b7ff5ba03328..5c74586c7feff 100644 --- a/addons/auth_signup/static/src/interactions/sign_up_form.js +++ b/addons/auth_signup/static/src/interactions/sign_up_form.js @@ -5,7 +5,7 @@ export class SignUpForm extends Interaction { static selector = ".oe_signup_form"; dynamicContent = { _root: { "t-on-submit": this.onSubmit }, - ".oe_login_buttons > button[type='submit']": { "t-att-disabled": this.submitElStatus }, + ".oe_login_buttons > button[type='submit']": { "t-att-disabled": () => this.submitElStatus }, }; setup() { @@ -17,7 +17,7 @@ export class SignUpForm extends Interaction { if (!this.submitElStatus) { this.submitElStatus = "disabled"; const refreshEl = document.createElement("i"); - refreshEl.classList.add("fa fa-refresh fa-spin"); + refreshEl.classList.add("fa", "fa-refresh", "fa-spin"); this.insert(refreshEl, submitEl, "beforebegin"); } } From c81c3a93155e8a8b8b191f145c5cfa39bd07949c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 13:34:38 +0100 Subject: [PATCH 107/150] carousel (folder) --- .../carousel_bootstrap_upgrade_fix.edit.js | 6 +- .../carousel_bootstrap_upgrade_fix.js | 12 +- .../carousel_section_slider.edit.js | 4 +- .../{ => carousel}/carousel_slider.edit.js | 15 ++- .../{ => carousel}/carousel_slider.js | 8 +- ...arousel_bootstrap_upgrade_fix.edit.test.js | 56 ++++++++++ .../carousel_bootstrap_upgrade_fix.test.js | 20 ++-- .../carousel_section_slider.edit.test.js | 68 ++++++++++++ .../carousel/carousel_slider.edit.test.js | 103 ++++++++++++++++++ .../carousel/carousel_slider.test.js | 59 ++++++++++ .../carousel_section_slider.edit.test.js | 64 ----------- .../interactions/carousel_slider.edit.test.js | 67 ------------ .../interactions/carousel_slider.test.js | 52 --------- 13 files changed, 317 insertions(+), 217 deletions(-) rename addons/website/static/src/interactions/{ => carousel}/carousel_bootstrap_upgrade_fix.edit.js (72%) rename addons/website/static/src/interactions/{ => carousel}/carousel_bootstrap_upgrade_fix.js (93%) rename addons/website/static/src/interactions/{ => carousel}/carousel_section_slider.edit.js (88%) rename addons/website/static/src/interactions/{ => carousel}/carousel_slider.edit.js (52%) rename addons/website/static/src/interactions/{ => carousel}/carousel_slider.js (89%) create mode 100644 addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js rename addons/website/static/tests/interactions/{ => carousel}/carousel_bootstrap_upgrade_fix.test.js (93%) create mode 100644 addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js create mode 100644 addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js create mode 100644 addons/website/static/tests/interactions/carousel/carousel_slider.test.js delete mode 100644 addons/website/static/tests/interactions/carousel_section_slider.edit.test.js delete mode 100644 addons/website/static/tests/interactions/carousel_slider.edit.test.js delete mode 100644 addons/website/static/tests/interactions/carousel_slider.test.js diff --git a/addons/website/static/src/interactions/carousel_bootstrap_upgrade_fix.edit.js b/addons/website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.js similarity index 72% rename from addons/website/static/src/interactions/carousel_bootstrap_upgrade_fix.edit.js rename to addons/website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.js index 2f09e9176c246..b8f636000afc2 100644 --- a/addons/website/static/src/interactions/carousel_bootstrap_upgrade_fix.edit.js +++ b/addons/website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.js @@ -1,14 +1,14 @@ +import { CarouselBootstrapUpgradeFix } from "@website/interactions/carousel/carousel_bootstrap_upgrade_fix"; import { registry } from "@web/core/registry"; -import { CarouselBootstrapUpgradeFix } from "@website/interactions/carousel_bootstrap_upgrade_fix"; const CarouselBootstrapUpgradeFixEdit = I => class extends I { // Suspend ride in edit mode. - carouselOptions = {ride: false, pause: true}; + carouselOptions = { ride: false, pause: true }; }; registry .category("public.interactions.edit") .add("website.carousel_bootstrap_upgrade_fix", { Interaction: CarouselBootstrapUpgradeFix, - mixin: CarouselBootstrapUpgradeFixEdit + mixin: CarouselBootstrapUpgradeFixEdit, }); diff --git a/addons/website/static/src/interactions/carousel_bootstrap_upgrade_fix.js b/addons/website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.js similarity index 93% rename from addons/website/static/src/interactions/carousel_bootstrap_upgrade_fix.js rename to addons/website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.js index 1f5701a1dc5a2..5cfdc9d0a5b2d 100644 --- a/addons/website/static/src/interactions/carousel_bootstrap_upgrade_fix.js +++ b/addons/website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.js @@ -1,7 +1,5 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; - -const CAROUSEL_SLIDING_CLASS = "o_carousel_sliding"; +import { registry } from "@web/core/registry"; /** * This class is used to fix carousel auto-slide behavior in Odoo 17.4 and up. @@ -23,11 +21,11 @@ export class CarouselBootstrapUpgradeFix extends Interaction { "[data-snippet='s_carousel_intro'] .carousel", ].join(", "); dynamicContent = { - "_root": { + _root: { "t-on-slide.bs.carousel": () => this.sliding = true, "t-on-slid.bs.carousel": () => this.sliding = false, "t-att-class": () => ({ - [CAROUSEL_SLIDING_CLASS]: this.sliding, + "o_carousel_sliding": this.sliding, }), }, }; @@ -60,9 +58,9 @@ export class CarouselBootstrapUpgradeFix extends Interaction { async willStart() { if (this.hasInterval || this.el.dataset.bsRide) { // Wait for carousel to finish sliding. - if (this.el.classList.contains(CAROUSEL_SLIDING_CLASS)) { + if (this.el.classList.contains("o_carousel_sliding")) { await new Promise(resolve => { - this.el.addEventListener("slid.bs.carousel", () => resolve(), {once: true}); + this.el.addEventListener("slid.bs.carousel", () => resolve(), { once: true }); }); } window.Carousel.getInstance(this.el)?.dispose(); diff --git a/addons/website/static/src/interactions/carousel_section_slider.edit.js b/addons/website/static/src/interactions/carousel/carousel_section_slider.edit.js similarity index 88% rename from addons/website/static/src/interactions/carousel_section_slider.edit.js rename to addons/website/static/src/interactions/carousel/carousel_section_slider.edit.js index 37ac7dda234bd..5cca62025f3d7 100644 --- a/addons/website/static/src/interactions/carousel_section_slider.edit.js +++ b/addons/website/static/src/interactions/carousel/carousel_section_slider.edit.js @@ -1,5 +1,5 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; export class CarouselSectionSliderEdit extends Interaction { static selector = "section > .carousel"; @@ -19,9 +19,7 @@ export class CarouselSectionSliderEdit extends Interaction { if (!editTranslations) { // Restore the carousel controls. const indicatorEls = this.el.querySelectorAll(".carousel-indicators > *"); - // this.options.wysiwyg.odooEditor.observerUnactive("restore_controls"); indicatorEls.forEach((indicatorEl, i) => indicatorEl.setAttribute("data-bs-slide-to", i)); - // this.options.wysiwyg.odooEditor.observerActive("restore_controls"); } } diff --git a/addons/website/static/src/interactions/carousel_slider.edit.js b/addons/website/static/src/interactions/carousel/carousel_slider.edit.js similarity index 52% rename from addons/website/static/src/interactions/carousel_slider.edit.js rename to addons/website/static/src/interactions/carousel/carousel_slider.edit.js index 4c0705479d5f1..9399ee364df9a 100644 --- a/addons/website/static/src/interactions/carousel_slider.edit.js +++ b/addons/website/static/src/interactions/carousel/carousel_slider.edit.js @@ -1,14 +1,13 @@ +import { CarouselSlider } from "@website/interactions/carousel/carousel_slider"; import { registry } from "@web/core/registry"; -import { CarouselSlider } from "@website/interactions/carousel_slider"; const CarouselSliderEdit = I => class extends I { - dynamicContent = Object.assign(this.dynamicContent, { - _root: { - "t-on-content_changed": this.onContentChanged, - }, - }); + dynamicContent = { + ...this.dynamicContent, + _root: { "t-on-content_changed": this.onContentChanged }, + }; // Pause carousel in edit mode. - carouselOptions = {ride: false, pause: true}; + carouselOptions = { ride: false, pause: true }; onContentChanged() { this.computeMaxHeight(); @@ -19,5 +18,5 @@ registry .category("public.interactions.edit") .add("website.carousel_slider", { Interaction: CarouselSlider, - mixin: CarouselSliderEdit + mixin: CarouselSliderEdit, }); diff --git a/addons/website/static/src/interactions/carousel_slider.js b/addons/website/static/src/interactions/carousel/carousel_slider.js similarity index 89% rename from addons/website/static/src/interactions/carousel_slider.js rename to addons/website/static/src/interactions/carousel/carousel_slider.js index 2f9dd23d10c0d..02a858d7fc5a3 100644 --- a/addons/website/static/src/interactions/carousel_slider.js +++ b/addons/website/static/src/interactions/carousel/carousel_slider.js @@ -1,5 +1,5 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; export class CarouselSlider extends Interaction { static selector = ".carousel"; @@ -12,7 +12,7 @@ export class CarouselSlider extends Interaction { }, ".carousel-item": { "t-att-style": () => ({ - "min-height": this.maxHeight, + "min-height": `${this.maxHeight}px`, }), }, }; @@ -24,6 +24,7 @@ export class CarouselSlider extends Interaction { start() { this.computeMaxHeight(); + this.updateContent(); const carouselBS = window.Carousel.getOrCreateInstance(this.el, this.carouselOptions); this.registerCleanup(() => carouselBS.dispose()); } @@ -33,8 +34,7 @@ export class CarouselSlider extends Interaction { for (const itemEl of this.el.querySelectorAll(".carousel-item")) { const isActive = itemEl.classList.contains("active"); itemEl.classList.add("active"); - const rect = itemEl.getBoundingClientRect(); - const height = rect.height; + const height = itemEl.getBoundingClientRect().height; if (height > this.maxHeight || this.maxHeight === undefined) { this.maxHeight = height; } diff --git a/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js new file mode 100644 index 0000000000000..1f9515995a9fc --- /dev/null +++ b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js @@ -0,0 +1,56 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +import { describe, expect, test } from "@odoo/hoot"; + +import { switchToEditMode } from "../../helpers"; + +setupInteractionWhiteList("website.carousel_bootstrap_upgrade_fix"); + +describe.current.tags("interaction_dev"); + +test("[EDIT] carousel_bootstrap_upgrade_fix prevents ride", async () => { + const { core, el } = await startInteractions(` + + `); + expect(core.interactions).toHaveLength(1); + await switchToEditMode(core); + const carouselEl = el.querySelector(".carousel"); + const carouselBS = window.Carousel.getInstance(carouselEl); + expect(carouselBS._config.ride).toBe(false); + expect(carouselBS._config.pause).toBe(true); + expect(carouselEl.dataset.bsRide).toBe("carousel"); +}); diff --git a/addons/website/static/tests/interactions/carousel_bootstrap_upgrade_fix.test.js b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js similarity index 93% rename from addons/website/static/tests/interactions/carousel_bootstrap_upgrade_fix.test.js rename to addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js index dc782b83c5285..b4f267848234a 100644 --- a/addons/website/static/tests/interactions/carousel_bootstrap_upgrade_fix.test.js +++ b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js @@ -1,15 +1,17 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { click } from "@odoo/hoot-dom"; -import { advanceTime } from "@odoo/hoot-mock"; import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; + setupInteractionWhiteList("website.carousel_bootstrap_upgrade_fix"); + describe.current.tags("interaction_dev"); -test("carousel bootstrap upgrade fix is tagged while sliding", async () => { +test("carousel_bootstrap_upgrade_fix is tagged while sliding", async () => { const { core, el } = await startInteractions(` `); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); + const carouselEl = el.querySelector(".carousel"); expect(carouselEl.dataset.bsRide).toBe("carousel"); expect(carouselEl.dataset.bsInterval).toBe("5000"); expect(carouselEl).not.toHaveClass("o_carousel_sliding"); - const nextEl = carouselEl.querySelector(".carousel-control-next"); - await click(nextEl); + + await click(carouselEl.querySelector(".carousel-control-next")); + expect(carouselEl).toHaveClass("o_carousel_sliding"); await advanceTime(750); expect(carouselEl).not.toHaveClass("o_carousel_sliding"); - core.stopInteractions(); - expect(core.interactions.length).toBe(0); }); diff --git a/addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js b/addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js new file mode 100644 index 0000000000000..bdf72e34f6069 --- /dev/null +++ b/addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js @@ -0,0 +1,68 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +import { describe, expect, test } from "@odoo/hoot"; + +import { switchToEditMode } from "../../helpers"; + +setupInteractionWhiteList("website.carousel_section_slider"); + +describe.current.tags("interaction_dev"); + +test("carousel_section_slider resets slide to attributes", async () => { + const { core, el } = await startInteractions(` +
+ +
+ `, { waitForStart: true, editMode: true }); + await switchToEditMode(core); + + expect(core.interactions).toHaveLength(1); + const controlEls = el.querySelectorAll(".carousel-control-prev, .carousel-control-next"); + const indicatorEls = el.querySelectorAll(".carousel-indicators > *"); + for (const controlEl of controlEls) { + expect(controlEl.dataset.bsSlide).toBe(undefined); + } + for (const indicatorEl of indicatorEls) { + expect(indicatorEl.dataset.bsSlideTo).toBe(undefined); + } + + core.stopInteractions(); + + expect(core.interactions).toHaveLength(0); + for (const controlEl of controlEls) { + expect(controlEl.dataset.bsSlide).not.toBe(undefined); + } + for (const indicatorEl of indicatorEls) { + expect(indicatorEl.dataset.bsSlideTo).not.toBe(undefined); + } +}); diff --git a/addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js b/addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js new file mode 100644 index 0000000000000..913f7da904738 --- /dev/null +++ b/addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js @@ -0,0 +1,103 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +import { describe, expect, test } from "@odoo/hoot"; +import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom"; + +import { switchToEditMode } from "../../helpers"; + +setupInteractionWhiteList("website.carousel_slider"); + +describe.current.tags("interaction_dev"); + +test("[EDIT] carousel_slider prevents ride", async () => { + const { core, el } = await startInteractions(` +
+ +
+ `); + await switchToEditMode(core); + + expect(core.interactions).toHaveLength(1); + const carouselEl = el.querySelector(".carousel"); + const carouselBS = window.Carousel.getInstance(carouselEl); + expect(carouselBS._config.ride).toBe(false); + expect(carouselBS._config.pause).toBe(true); + + core.stopInteractions(); + + expect(core.interactions).toHaveLength(0); + expect(carouselEl.dataset.bsRide).toBe("ride"); +}); + +test("[EDIT] carousel_slider updates min height on content_changed", async () => { + const { core, el } = await startInteractions(` + + `); + await switchToEditMode(core); + + expect(core.interactions).toHaveLength(1); + const carouselEl = el.querySelector(".carousel"); + const itemEl = carouselEl.querySelector(".carousel-item"); + const maxHeight = itemEl.style.minHeight; + itemEl.style.minHeight = ""; + expect(itemEl).not.toHaveStyle({ minHeight: maxHeight }); + await manuallyDispatchProgrammaticEvent(carouselEl, "content_changed"); + expect(itemEl).toHaveStyle({ minHeight: maxHeight }); +}); diff --git a/addons/website/static/tests/interactions/carousel/carousel_slider.test.js b/addons/website/static/tests/interactions/carousel/carousel_slider.test.js new file mode 100644 index 0000000000000..89dfd6fdc9d30 --- /dev/null +++ b/addons/website/static/tests/interactions/carousel/carousel_slider.test.js @@ -0,0 +1,59 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +import { describe, expect, test } from "@odoo/hoot"; + +setupInteractionWhiteList("website.carousel_slider"); + +describe.current.tags("interaction_dev"); + +test("carousel_slider updates min height of carousel items", async () => { + const { core, el } = await startInteractions(` +
+ +
+ `); + const itemEls = el.querySelectorAll(".carousel-item"); + const maxHeight = itemEls[0].style.minHeight; + + expect(core.interactions).toHaveLength(1); + for (const itemEl of itemEls) { + expect(itemEl).toHaveStyle({ minHeight: maxHeight }); + } + + core.stopInteractions(); + + expect(core.interactions).toHaveLength(0); + for (const itemEl of itemEls) { + expect(itemEl).not.toHaveStyle({ minHeight: maxHeight }); + } +}); diff --git a/addons/website/static/tests/interactions/carousel_section_slider.edit.test.js b/addons/website/static/tests/interactions/carousel_section_slider.edit.test.js deleted file mode 100644 index b847bf7bfbfe7..0000000000000 --- a/addons/website/static/tests/interactions/carousel_section_slider.edit.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { - startInteractions, - setupInteractionWhiteList, -} from "@web/../tests/public/helpers"; -import { switchToEditMode } from "../helpers"; - -setupInteractionWhiteList("website.carousel_section_slider"); -describe.current.tags("interaction_dev"); - -const carouselHtml = ` -
- -
-`; - -test("carousel section slider resets slide to attributes", async () => { - const { core, el } = await startInteractions(carouselHtml, { waitForStart: true, editMode: true }); - await switchToEditMode(core); - expect(core.interactions.length).toBe(1); - const controlEls = el.querySelectorAll(".carousel-control-prev, .carousel-control-next"); - const indicatorEls = el.querySelectorAll(".carousel-indicators > *"); - for (const controlEl of controlEls) { - expect(controlEl.dataset.bsSlide).toBe(undefined); - } - for (const indicatorEl of indicatorEls) { - expect(indicatorEl.dataset.bsSlideTo).toBe(undefined); - } - core.stopInteractions(); - expect(core.interactions.length).toBe(0); - for (const controlEl of controlEls) { - expect(controlEl.dataset.bsSlide).not.toBe(undefined); - } - for (const indicatorEl of indicatorEls) { - expect(indicatorEl.dataset.bsSlideTo).not.toBe(undefined); - } -}); diff --git a/addons/website/static/tests/interactions/carousel_slider.edit.test.js b/addons/website/static/tests/interactions/carousel_slider.edit.test.js deleted file mode 100644 index 13e029d55741c..0000000000000 --- a/addons/website/static/tests/interactions/carousel_slider.edit.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom"; -import { - startInteractions, - setupInteractionWhiteList, -} from "@web/../tests/public/helpers"; -import { switchToEditMode } from "../helpers"; - -setupInteractionWhiteList("website.carousel_slider"); -describe.current.tags("interaction_dev"); - -const carouselHtml = ` - -`; - -test("carousel slider prevents ride", async () => { - const { core, el } = await startInteractions(carouselHtml); - await switchToEditMode(core); - expect(core.interactions.length).toBe(1); - const carouselEl = el.querySelector(".carousel"); - const carouselBS = window.Carousel.getInstance(carouselEl); - expect(carouselBS._config.ride).toBe(false); - expect(carouselBS._config.pause).toBe(true); - core.stopInteractions(); - expect(core.interactions.length).toBe(0); - expect(carouselEl.dataset.bsRide).toBe("ride"); -}); - -test("carousel slider computes height upon content_changed", async () => { - const { core, el } = await startInteractions(carouselHtml); - await switchToEditMode(core); - expect(core.interactions.length).toBe(1); - const carouselEl = el.querySelector(".carousel"); - const itemEl = carouselEl.querySelector(".carousel-item"); - const maxHeight = itemEl.style.minHeight; - itemEl.style.minHeight = ""; - expect(itemEl.style.minHeight).toBe(""); - await manuallyDispatchProgrammaticEvent(carouselEl, "content_changed"); - expect(itemEl.style.minHeight).toBe(maxHeight); -}); diff --git a/addons/website/static/tests/interactions/carousel_slider.test.js b/addons/website/static/tests/interactions/carousel_slider.test.js deleted file mode 100644 index 85bfc500cbc08..0000000000000 --- a/addons/website/static/tests/interactions/carousel_slider.test.js +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { - startInteractions, - setupInteractionWhiteList, -} from "@web/../tests/public/helpers"; - -setupInteractionWhiteList("website.carousel_slider"); -describe.current.tags("interaction_dev"); - -test("carousel slider computes maximum height", async () => { - const { core, el } = await startInteractions(` - - `); - expect(core.interactions.length).toBe(1); - const itemEls = el.querySelectorAll(".carousel-item"); - const maxHeight = itemEls[0].style.minHeight; - for (const itemEl of itemEls) { - expect(itemEl.style.minHeight).toBe(maxHeight); - } - core.stopInteractions(); - expect(core.interactions.length).toBe(0); - for (const itemEl of itemEls) { - expect(itemEl.style.minHeight).toBe(""); - } -}); From 05e10e05dd37b8ce0513f2bf69876db29bf538aa Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 14:05:58 +0100 Subject: [PATCH 108/150] cookies (folder) --- .../interactions/cookies/cookies_approval.js | 22 +++++---- .../src/interactions/cookies/cookies_bar.js | 48 ++++++++----------- .../interactions/cookies/cookies_toggle.js | 39 ++++++++------- .../interactions/cookies/cookies_warning.js | 31 +++++------- .../{snippets => cookies}/cookies.test.js | 28 ++++++----- 5 files changed, 84 insertions(+), 84 deletions(-) rename addons/website/static/tests/interactions/{snippets => cookies}/cookies.test.js (90%) diff --git a/addons/website/static/src/interactions/cookies/cookies_approval.js b/addons/website/static/src/interactions/cookies/cookies_approval.js index 031e568f7e21b..739813f79006d 100644 --- a/addons/website/static/src/interactions/cookies/cookies_approval.js +++ b/addons/website/static/src/interactions/cookies/cookies_approval.js @@ -1,6 +1,7 @@ +import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; + import { MEDIAS_BREAKPOINTS, SIZES } from "@web/core/ui/ui_service"; -import { Interaction } from "@web/public/interaction"; export class CookiesApproval extends Interaction { static selector = "[data-need-cookies-approval]"; @@ -10,10 +11,8 @@ export class CookiesApproval extends Interaction { } start() { - if (this.iframeEl) { - if (!this.cookiesWarningEl) { - this.addOptionalCookiesWarning(); - } + if (this.iframeEl && !this.getCookiesWarningEl()) { + this.addOptionalCookiesWarning(); } this.addListener( document, @@ -23,7 +22,7 @@ export class CookiesApproval extends Interaction { ); } - get cookiesWarningEl() { + getCookiesWarningEl() { if (this.iframeEl.nextElementSibling?.classList.contains("o_no_optional_cookie")) { return this.iframeEl.nextElementSibling; } @@ -36,8 +35,11 @@ export class CookiesApproval extends Interaction { ? `aspect-ratio: 16/9; max-width: ${MEDIAS_BREAKPOINTS[SIZES.SM].maxWidth}px;` : "", extraClasses: getComputedStyle(this.iframeEl.parentElement).position === "absolute" - ? "" : "my-3", - }, this.iframeEl, "afterend"); + ? "" + : "my-3", + }); + this.insert(optionalCookiesWarningEl, this.iframeEl, "afterend"); + this.services["public.interactions"].startInteractions(optionalCookiesWarningEl); } onOptionalCookiesAccepted() { @@ -49,4 +51,6 @@ export class CookiesApproval extends Interaction { } } -registry.category("public.interactions").add("website.cookies_approval", CookiesApproval); +registry + .category("public.interactions") + .add("website.cookies_approval", CookiesApproval); diff --git a/addons/website/static/src/interactions/cookies/cookies_bar.js b/addons/website/static/src/interactions/cookies/cookies_bar.js index ab496f548d9c8..218f0b7fe8042 100644 --- a/addons/website/static/src/interactions/cookies/cookies_bar.js +++ b/addons/website/static/src/interactions/cookies/cookies_bar.js @@ -1,49 +1,41 @@ +import { Popup } from "@website/interactions/popup/popup"; +import { registry } from "@web/core/registry"; + import { cookie } from "@web/core/browser/cookie"; import { _t } from "@web/core/l10n/translation"; -import { registry } from "@web/core/registry"; import { isVisible } from "@web/core/utils/ui"; -import { setUtmsHtmlDataset } from "@website/utils/misc"; -import { Popup } from "@website/interactions/popup/popup"; import { cloneContentEls } from "@website/js/utils"; +import { setUtmsHtmlDataset } from "@website/utils/misc"; // Extending the Popup class with cookiebar functionality. // This allows for refusing optional cookies for now and can be // extended to picking which cookies categories are accepted. export class CookiesBar extends Popup { static selector = "#website_cookies_bar"; - dynamicContent = Object.assign(this.dynamicContent, { - "#cookies-consent-essential, #cookies-consent-all": { - "t-on-click": this.onAcceptClick, + dynamicSelectors = { + ...this.dynamicSelectors, + _cookiesbus: () => this.services.website_cookies.bus, + }; + dynamicContent = { + ...this.dynamicContent, + _cookiesbus: { + "t-on-cookiesBar.show": this.onShowCookiesBar, + "t-on-cookiesBar.toggle": this.onToggleCookieBar, }, + "#cookies-consent-essential, #cookies-consent-all": { "t-on-click": this.onAcceptClick }, // Override to avoid side effects on hide. - ".js_close_popup": { - "t-on-click": () => {}, - }, - }); + ".js_close_popup": { "t-on-click": () => { } }, + }; setup() { super.setup(); this.showToggle(); } - start() { - super.start(); - this.addListener( - this.services.website_cookies.bus, - "cookiesBar.show", - this.onShowCookiesBar - ); - this.addListener( - this.services.website_cookies.bus, - "cookiesBar.toggle", - this.toggleCookiesBar, - ); - } - showPopup() { super.showPopup(); if (this.toggleEl) { - this.toggleCookiesBar(); + this.onToggleCookieBar(); } } @@ -59,7 +51,7 @@ export class CookiesBar extends Popup { } } - toggleCookiesBar() { + onToggleCookieBar() { this.bsModal.toggle(); // As we're using Bootstrap's events, the Popup class prevents the modal // from being shown after hiding it: override that behavior. @@ -118,4 +110,6 @@ export class CookiesBar extends Popup { } } -registry.category("public.interactions").add("website.cookies_bar", CookiesBar); +registry + .category("public.interactions") + .add("website.cookies_bar", CookiesBar); diff --git a/addons/website/static/src/interactions/cookies/cookies_toggle.js b/addons/website/static/src/interactions/cookies/cookies_toggle.js index 5f4767587d1b1..9ec306670e2ec 100644 --- a/addons/website/static/src/interactions/cookies/cookies_toggle.js +++ b/addons/website/static/src/interactions/cookies/cookies_toggle.js @@ -1,34 +1,37 @@ -import { _t } from "@web/core/l10n/translation"; -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +import { _t } from "@web/core/l10n/translation"; import { onceAllImagesLoaded } from "@website/utils/images"; export class CookiesToggle extends Interaction { static selector = ".o_cookies_bar_toggle"; - + dynamicSelectors = { + ...this.dynamicSelectors, + _cookiesbus: () => this.services.website_cookies.bus, + }; dynamicContent = { - "_root": { "t-on-click": this.onClick }, - ".fa:t-att-class": () => ({ - "fa-eye": !this.modalShown, - "fa-eye-slash": this.modalShown, - }), - ".o_cookies_bar_toggle_label:t-out": this.toggleText, + _root: { "t-on-click": this.onClick }, + _cookiesbus: { "t-on-cookiesBar.discard": this.onClick }, + ".o_cookies_bar_toggle_label": { "t-out": this.toggleText }, + ".fa": { + "t-att-class": () => ({ + "fa-eye": !this.isModalShown(), + "fa-eye-slash": this.isModalShown(), + }), + }, }; setup() { this.cookiesModalEl = this.el.nextElementSibling.querySelector(".modal"); } - start() { - this.addListener(this.services.website_cookies.bus, "cookiesBar.discard", this.onClick); - } - - get modalShown() { + isModalShown() { return this.cookiesModalEl.classList.contains("show"); } toggleText() { - return this.modalShown ? _t("Hide the cookies bar") : _t("Show the cookies bar"); + return this.isModalShown() ? _t("Hide the cookies bar") : _t("Show the cookies bar"); } async onClick(ev) { @@ -38,7 +41,7 @@ export class CookiesToggle extends Interaction { // Changing the property cannot be done in "t-att-style" in // dynamicContent as it relies on async code. - if (!this.modalShown || !this.cookiesModalEl.classList.contains("s_popup_bottom")) { + if (!this.isModalShown() || !this.cookiesModalEl.classList.contains("s_popup_bottom")) { this.el.style.removeProperty("--cookies-bar-toggle-inset-block-end"); } else { // Lazy-loaded images don't have a height yet. We need to await them @@ -58,4 +61,6 @@ export class CookiesToggle extends Interaction { } } -registry.category("public.interactions").add("website.cookies_toggle", CookiesToggle); +registry + .category("public.interactions") + .add("website.cookies_toggle", CookiesToggle); diff --git a/addons/website/static/src/interactions/cookies/cookies_warning.js b/addons/website/static/src/interactions/cookies/cookies_warning.js index a6a54692d8ecf..f5df6eeb6a018 100644 --- a/addons/website/static/src/interactions/cookies/cookies_warning.js +++ b/addons/website/static/src/interactions/cookies/cookies_warning.js @@ -1,38 +1,31 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; export class CookiesWarning extends Interaction { static selector = ".o_no_optional_cookie"; - dynamicSelectors = { ...this.dynamicSelectors, - "_iframe": () => this.iframeEl, + _iframe: () => this.el.previousElementSibling, }; dynamicContent = { - "_root": { "t-on-click": this.showCookiesBar }, - "_iframe": { "t-att-class": () => ({ "d-none": !!this.el.parentElement }) }, + _root: { "t-on-click": () => this.services.website_cookies.bus.trigger("cookiesBar.show") }, + _iframe: { + "t-att-class": () => ({ + "d-none": !!this.el.parentElement, + }), + }, }; - setup() { - this.iframeEl = this.el.previousElementSibling; - } - start() { this.addListener( document, "optionalCookiesAccepted", - this.removeOptionalCookiesWarning, + () => this.el.remove(), { once: true } ); } - - showCookiesBar() { - this.services.website_cookies.bus.trigger("cookiesBar.show"); - } - - removeOptionalCookiesWarning() { - this.el.remove(); - } } -registry.category("public.interactions").add("website.cookies_warning", CookiesWarning); +registry + .category("public.interactions") + .add("website.cookies_warning", CookiesWarning); diff --git a/addons/website/static/tests/interactions/snippets/cookies.test.js b/addons/website/static/tests/interactions/cookies/cookies.test.js similarity index 90% rename from addons/website/static/tests/interactions/snippets/cookies.test.js rename to addons/website/static/tests/interactions/cookies/cookies.test.js index 9ab479f02048c..d76bffd66608e 100644 --- a/addons/website/static/tests/interactions/snippets/cookies.test.js +++ b/addons/website/static/tests/interactions/cookies/cookies.test.js @@ -1,10 +1,16 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + import { describe, expect, test } from "@odoo/hoot"; import { click, queryOne, waitFor } from "@odoo/hoot-dom"; import { advanceTime } from "@odoo/hoot-mock"; + import { cookie } from "@web/core/browser/cookie"; -import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; setupInteractionWhiteList(["website.cookies_bar", "website.cookies_approval", "website.cookies_warning"]); + describe.current.tags("interaction_dev"); const cookiesBarTemplate = ` @@ -34,6 +40,12 @@ const cookiesBarTemplate = ` `; +const cookiesApprovalTemplate = ` +
+ +
+`; + test("consent for optional cookies not given if click on #cookies-consent-essential", async () => { const { core } = await startInteractions(cookiesBarTemplate); expect(core.interactions).toHaveLength(1); @@ -71,11 +83,7 @@ test("consent for optional cookies given if click on #cookies-consent-all", asyn }) test("show warning instead of iframe if no consent", async () => { - const { core } = await startInteractions(` -
- -
- `); + const { core } = await startInteractions(cookiesApprovalTemplate); expect(core.interactions).toHaveLength(2); const iframeEl = queryOne("iframe"); expect(iframeEl).toHaveClass("d-none"); @@ -87,9 +95,7 @@ test("show warning instead of iframe if no consent", async () => { test("show cookies bar after clicking on warning", async () => { const { core } = await startInteractions(`
-
- -
+ ${cookiesApprovalTemplate} ${cookiesBarTemplate}
`); @@ -111,9 +117,7 @@ test("show cookies bar after clicking on warning", async () => { test("remove warning, show and update iframe src after accepting cookies", async () => { const { core } = await startInteractions(`
-
- -
+ ${cookiesApprovalTemplate} ${cookiesBarTemplate}
`); From 68478607c21c020729aa720d3ecc7120bda77527 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 14:32:02 +0100 Subject: [PATCH 109/150] dropdown (folder) --- .../dropdown/hoverable_dropdown.edit.js | 14 ++-- .../dropdown/hoverable_dropdown.js | 23 +++--- .../dropdown/mega_menu_dropdown.js | 54 +++++-------- .../dropdown/hoverable_dropdown.edit.test.js | 77 +++++++++++++++++++ .../dropdown/hoverable_dropdown.test.js | 38 ++++----- .../dropdown/mega_menu_dropdown.test.js | 11 +-- 6 files changed, 142 insertions(+), 75 deletions(-) create mode 100644 addons/website/static/tests/interactions/dropdown/hoverable_dropdown.edit.test.js diff --git a/addons/website/static/src/interactions/dropdown/hoverable_dropdown.edit.js b/addons/website/static/src/interactions/dropdown/hoverable_dropdown.edit.js index 6ea7079fe50f3..62692498e2d94 100644 --- a/addons/website/static/src/interactions/dropdown/hoverable_dropdown.edit.js +++ b/addons/website/static/src/interactions/dropdown/hoverable_dropdown.edit.js @@ -4,24 +4,22 @@ import { registry } from "@web/core/registry"; const HoverableDropdownEdit = I => class extends I { /** * @param {Event} ev + * @param {HTMLElement} targetEl */ - onMouseEnter(ev) { + onMouseEnter(ev, targetEl) { if (this.el.querySelector(".dropdown-toggle.show")) { return; } else { - super.onMouseEnter(ev); + super.onMouseEnter(ev, targetEl); } } - - /** - * @param {Event} ev - */ - onMouseLeave(ev) { } + + onMouseLeave() { } }; registry .category("public.interactions.edit") .add("website.hoverable_dropdown", { Interaction: HoverableDropdown, - mixin: HoverableDropdownEdit + mixin: HoverableDropdownEdit, }); diff --git a/addons/website/static/src/interactions/dropdown/hoverable_dropdown.js b/addons/website/static/src/interactions/dropdown/hoverable_dropdown.js index e3d13234db949..8c4fd85041bbe 100644 --- a/addons/website/static/src/interactions/dropdown/hoverable_dropdown.js +++ b/addons/website/static/src/interactions/dropdown/hoverable_dropdown.js @@ -7,8 +7,8 @@ export class HoverableDropdown extends Interaction { static selector = "header.o_hoverable_dropdown"; dynamicContent = { ".dropdown": { - "t-on-mouseenter": this.onMouseEnter, - "t-on-mouseleave": this.onMouseLeave, + "t-on-mouseenter.withTarget": this.onMouseEnter, + "t-on-mouseleave.withTarget": this.onMouseLeave, }, ".dropdown-menu": { "t-att-style": () => ({ @@ -22,6 +22,7 @@ export class HoverableDropdown extends Interaction { }; setup() { + this.isSmall = undefined; this.dropdownMenuEls = this.el.querySelectorAll(".dropdown-menu"); } @@ -30,15 +31,15 @@ export class HoverableDropdown extends Interaction { } /** - * @param {Event} ev + * @param {Event} dropdownEl * @param {boolean} show */ - updateDropdownVisibility(ev, show) { - const dropdownToggleEl = ev.currentTarget.querySelector(".dropdown-toggle"); + updateDropdownVisibility(dropdownEl, show) { + const dropdownToggleEl = dropdownEl.querySelector(".dropdown-toggle"); if ( this.isSmall || !dropdownToggleEl - || ev.currentTarget.closest(".o_extra_menu_items") + || dropdownEl.closest(".o_extra_menu_items") ) { return; } @@ -48,14 +49,15 @@ export class HoverableDropdown extends Interaction { /** * @param {Event} ev + * @param {HTMLElement} targetEl */ - onMouseEnter(ev) { + onMouseEnter(ev, targetEl) { const focusedEl = this.el.ownerDocument.querySelector(":focus") || window.frameElement?.ownerDocument.querySelector(":focus"); // The user must click on the dropdown if he is on mobile (no way to // hover) or if the dropdown is the (or in the) extra menu ('+'). - this.updateDropdownVisibility(ev, true); + this.updateDropdownVisibility(targetEl, true); // Keep the focus on the previously focused element if any, otherwise do // not focus the dropdown on hover. @@ -71,9 +73,10 @@ export class HoverableDropdown extends Interaction { /** * @param {Event} ev + * @param {HTMLElement} targetEl */ - onMouseLeave(ev) { - this.updateDropdownVisibility(ev, false); + onMouseLeave(ev, targelEl) { + this.updateDropdownVisibility(targelEl, false); } onResize() { diff --git a/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js b/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js index c2775c595a0da..f963a1dd32713 100644 --- a/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js +++ b/addons/website/static/src/interactions/dropdown/mega_menu_dropdown.js @@ -1,13 +1,13 @@ import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; -class MegaMenuDropdown extends Interaction { +export class MegaMenuDropdown extends Interaction { static selector = "header#top"; dynamicContent = { ".o_mega_menu_toggle": { - "t-on-mouseenter": this.onHoverMegaMenu, - "t-on-mousedown": this.onTriggerMegaMenu, - "t-on-keyup": this.onTriggerMegaMenu, + "t-on-mouseenter.withTarget": this.onHoverMegaMenu, + "t-on-mousedown.withTarget": this.onTriggerMegaMenu, + "t-on-keyup.withTarget": this.onTriggerMegaMenu, }, _root: { "t-on-mousedown": this.onTriggerExtraMenu, // delegated to ".o_extra_menu_items" @@ -18,9 +18,7 @@ class MegaMenuDropdown extends Interaction { setup() { this.mobileMegaMenuToggleEls = []; this.desktopMegaMenuToggleEls = []; - const megaMenuToggleEls = this.el.querySelectorAll( - ".o_mega_menu_toggle", - ); + const megaMenuToggleEls = this.el.querySelectorAll(".o_mega_menu_toggle"); for (const megaMenuToggleEl of megaMenuToggleEls) { if (megaMenuToggleEl.closest(".o_header_mobile")) { this.mobileMegaMenuToggleEls.push(megaMenuToggleEl); @@ -35,16 +33,13 @@ class MegaMenuDropdown extends Interaction { * a mega menu (i.e. it is in the other navbar), brings the corresponding * mega menu into it. * - * @param {Element} megaMenuToggleEl + * @param {HTMLElement} megaMenuToggleEl */ moveMegaMenu(megaMenuToggleEl) { - const hasMegaMenu = - !!megaMenuToggleEl.parentElement.querySelector(".o_mega_menu"); + const hasMegaMenu = !!megaMenuToggleEl.parentElement.querySelector(".o_mega_menu"); if (hasMegaMenu) { return; } - // TODO Editor behavior - // this.options.wysiwyg?.odooEditor.observerUnactive("moveMegaMenu"); const isMobileNavbar = !!megaMenuToggleEl.closest(".o_header_mobile"); const currentNavbarToggleEls = isMobileNavbar ? this.mobileMegaMenuToggleEls @@ -52,51 +47,46 @@ class MegaMenuDropdown extends Interaction { const otherNavbarToggleEls = isMobileNavbar ? this.desktopMegaMenuToggleEls : this.mobileMegaMenuToggleEls; - const megaMenuToggleIndex = - currentNavbarToggleEls.indexOf(megaMenuToggleEl); - const previousMegaMenuToggleEl = - otherNavbarToggleEls[megaMenuToggleIndex]; - const megaMenuEl = - previousMegaMenuToggleEl.parentElement.querySelector( - ".o_mega_menu", - ); + + const megaMenuToggleIndex = currentNavbarToggleEls.indexOf(megaMenuToggleEl); + const previousMegaMenuToggleEl = otherNavbarToggleEls[megaMenuToggleIndex]; + const megaMenuEl = previousMegaMenuToggleEl.parentElement.querySelector(".o_mega_menu"); + // Hiding the dropdown where the mega menu comes from before moving it, // so everything is in a consistent state. Dropdown.getOrCreateInstance(previousMegaMenuToggleEl).hide(); megaMenuToggleEl.insertAdjacentElement("afterend", megaMenuEl); - // TODO Editor behavior - // this.options.wysiwyg?.odooEditor.observerActive("moveMegaMenu"); } /** * @param {Event} ev + * @param {HTMLElement} targetEl */ - onTriggerMegaMenu(ev) { - const megaMenuToggleEl = ev.currentTarget; + onTriggerMegaMenu(ev, targetEl) { // Hoverable menus are clicked in mobile view if ( this.el.classList.contains("o_hoverable_dropdown") - && !megaMenuToggleEl.closest(".o_header_mobile") + && !targetEl.closest(".o_header_mobile") && ev.type !== "keyup" ) { return; } - this.moveMegaMenu(megaMenuToggleEl); + this.moveMegaMenu(targetEl); } /** * @param {Event} ev + * @param {HTMLElement} targetEl */ - onHoverMegaMenu(ev) { - const megaMenuToggleEl = ev.currentTarget; + onHoverMegaMenu(ev, targetEl) { // Hoverable menus are clicked in mobile view if ( !this.el.classList.contains("o_hoverable_dropdown") - || megaMenuToggleEl.closest(".o_header_mobile") + || targetEl.closest(".o_header_mobile") ) { return; } - this.moveMegaMenu(megaMenuToggleEl); + this.moveMegaMenu(targetEl); } /** @@ -112,9 +102,7 @@ class MegaMenuDropdown extends Interaction { const megaMenuToggleEls = ev.target .closest(".o_extra_menu_items") .querySelectorAll(".o_mega_menu_toggle"); - megaMenuToggleEls.forEach((megaMenuToggleEl) => - this.moveMegaMenu(megaMenuToggleEl), - ); + megaMenuToggleEls.forEach(el => this.moveMegaMenu(el)); } } diff --git a/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.edit.test.js b/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.edit.test.js new file mode 100644 index 0000000000000..ceca8493c2662 --- /dev/null +++ b/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.edit.test.js @@ -0,0 +1,77 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + +import { describe, expect, test } from "@odoo/hoot"; +import { hover, leave } from "@odoo/hoot-dom"; + +import { switchToEditMode } from "../../helpers"; + +setupInteractionWhiteList("website.hoverable_dropdown"); + +describe.current.tags("interaction_dev"); + +const dropdownTemplate = ` + +`; + +test.tags("desktop")("[EDIT] onMouseLeave doesn't work in edit mode", async () => { + const { core } = await startInteractions(` +
+ +
+ `, { waitForStart: true, editMode: true }); + await switchToEditMode(core); + expect(".dropdown-toggle").not.toHaveClass("show"); + expect(".dropdown-menu > a").not.toBeVisible(); + await hover(".dropdown"); + expect(".dropdown-toggle").toHaveClass("show"); + expect(".dropdown-menu > a").toBeVisible(); + await leave(".dropdown"); + expect(".dropdown-toggle").toHaveClass("show"); + expect(".dropdown-menu > a").toBeVisible(); +}); + +test.tags("desktop")("[EDIT] onMouseEnter doesn't work in edit mode if another dropdown is opened", async () => { + const { core, el } = await startInteractions(` +
+ + +
+ `, { waitForStart: true, editMode: true }); + await switchToEditMode(core); + expect(".dropdown-toggle").not.toHaveClass("show"); + expect(".dropdown-menu > a").not.toBeVisible(); + await hover("#D1.dropdown"); + expect("#D1 > .dropdown-toggle").toHaveClass("show"); + expect("#D1 > .dropdown-menu > a").toBeVisible(); + expect("#D2 > .dropdown-toggle").not.toHaveClass("show"); + expect("#D2 > .dropdown-menu > a").not.toBeVisible(); + await hover("#D2.dropdown"); + expect("#D1 > .dropdown-toggle").toHaveClass("show"); + expect("#D1 > .dropdown-menu > a").toBeVisible(); + expect("#D2 > .dropdown-toggle").not.toHaveClass("show"); + expect("#D2 > .dropdown-menu > a").not.toBeVisible(); +}); diff --git a/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.test.js b/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.test.js index eac7c07683d9c..64c77c36fad86 100644 --- a/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.test.js +++ b/addons/website/static/tests/interactions/dropdown/hoverable_dropdown.test.js @@ -1,36 +1,36 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { hover, leave } from "@odoo/hoot-dom"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { hover, leave } from "@odoo/hoot-dom"; + setupInteractionWhiteList("website.hoverable_dropdown"); + describe.current.tags("interaction_dev"); -const getTemplate = function (options = {}) { - return ` -
- +
+`; + test("hoverable_dropdown is started when there is an element header.o_hoverable_dropdown", async () => { - const { core } = await startInteractions(getTemplate()); - expect(core.interactions.length).toBe(1); + const { core } = await startInteractions(dropdownTemplate); + expect(core.interactions).toHaveLength(1); }); test.tags("desktop")("[hover] show / hide content", async () => { - await startInteractions(getTemplate()); + await startInteractions(dropdownTemplate); expect(".dropdown-toggle").not.toHaveClass("show"); expect(".dropdown-menu > a").not.toBeVisible(); await hover(".dropdown"); diff --git a/addons/website/static/tests/interactions/dropdown/mega_menu_dropdown.test.js b/addons/website/static/tests/interactions/dropdown/mega_menu_dropdown.test.js index 45bfb2cdd6940..520501a1cb4ff 100644 --- a/addons/website/static/tests/interactions/dropdown/mega_menu_dropdown.test.js +++ b/addons/website/static/tests/interactions/dropdown/mega_menu_dropdown.test.js @@ -1,12 +1,13 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { hover, pointerDown } from "@odoo/hoot-dom"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { hover, pointerDown } from "@odoo/hoot-dom"; + setupInteractionWhiteList("website.mega_menu_dropdown"); + describe.current.tags("interaction_dev"); const getTemplate = function (options = {}) { @@ -28,11 +29,11 @@ const getTemplate = function (options = {}) { ` -} +}; test("mega_menu_dropdown is started when there is an element header#top", async () => { const { core } = await startInteractions(getTemplate()); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); test.tags("desktop")("[mousedown] moves content from desktop to mobile", async () => { From 6367106bba76e42428ba1128b408324aa18ccf4e Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 14:46:45 +0100 Subject: [PATCH 110/150] header (folder) --- .../src/interactions/header/base_header.js | 20 ++++++-------- .../header/base_header_special.js | 6 ++--- .../src/interactions/header/header_top.js | 26 +++++-------------- .../interactions/header/base_header.test.js | 7 ++--- .../header/base_header_special.test.js | 7 ++--- .../header/header_disappears.test.js | 7 ++--- .../header/header_fade_out.test.js | 7 ++--- .../interactions/header/header_fixed.test.js | 7 ++--- .../header/header_standard.test.js | 7 ++--- .../interactions/header/header_top.test.js | 11 +++----- 10 files changed, 44 insertions(+), 61 deletions(-) diff --git a/addons/website/static/src/interactions/header/base_header.js b/addons/website/static/src/interactions/header/base_header.js index 67968c8c7ece2..adc920ed9b6c7 100644 --- a/addons/website/static/src/interactions/header/base_header.js +++ b/addons/website/static/src/interactions/header/base_header.js @@ -1,10 +1,7 @@ import { Interaction } from "@web/public/interaction"; -import { compensateScrollbar } from "@web/core/utils/scrolling"; -import { SIZES, utils as uiUtils } from "@web/core/ui/ui_service"; -const isSmall = function () { - return uiUtils.getSize() < SIZES.LG -} +import { SIZES, utils as uiUtils } from "@web/core/ui/ui_service"; +import { compensateScrollbar } from "@web/core/utils/scrolling"; export class BaseHeader extends Interaction { dynamicContent = { @@ -38,7 +35,7 @@ export class BaseHeader extends Interaction { "t-on-show.bs.collapse": this.disableScroll, "t-on-hide.bs.collapse": this.enableScroll, }, - } + }; //-------------------------------------------------------------- // Life Cycle @@ -58,6 +55,7 @@ export class BaseHeader extends Interaction { this.isScrolled = false; this.forcedScroll = 0; + this.isSmall = uiUtils.getSize() < SIZES.LG; this.isOverlay = !!this.el.closest(".o_header_overlay, .o_header_overlay_theme"); this.mainEl = this.el.parentElement.querySelector("main"); @@ -79,7 +77,7 @@ export class BaseHeader extends Interaction { //-------------------------------------------------------------- disableScroll() { - if (isSmall()) { + if (this.isSmall) { this.bodyNoScroll = true; } } @@ -89,11 +87,9 @@ export class BaseHeader extends Interaction { } onResize() { + this.isSmall = uiUtils.getSize() < SIZES.LG; this.adjustScrollbar(); - if ( - document.body.classList.contains('overflow-hidden') - && !isSmall() - ) { + if (document.body.classList.contains('overflow-hidden') && !this.isSmall) { const offCanvasEls = this.el.querySelectorAll(".offcanvas.show"); for (const offCanvasEl of offCanvasEls) { Offcanvas.getOrCreateInstance(offCanvasEl).hide(); @@ -185,7 +181,7 @@ export class BaseHeader extends Interaction { //-------------------------------------------------------------- getHeaderHeight() { - if (isSmall()) { + if (this.isSmall) { // Ensure we don't consider the hiddenOnScroll element on mobile return this.el.getBoundingClientRect().height; } diff --git a/addons/website/static/src/interactions/header/base_header_special.js b/addons/website/static/src/interactions/header/base_header_special.js index 9318214bd36bb..81e495cd55cf1 100644 --- a/addons/website/static/src/interactions/header/base_header_special.js +++ b/addons/website/static/src/interactions/header/base_header_special.js @@ -1,12 +1,12 @@ -import { registry } from "@web/core/registry"; import { BaseHeader } from "@website/interactions/header/base_header"; +import { registry } from "@web/core/registry"; export class BaseHeaderSpecial extends BaseHeader { dynamicSelectors = { ...this.dynamicSelectors, _dropdown: () => this.hideEl?.querySelector(".dropdown-toggle"), _searchbar: () => this.searchbarEl, - } + }; dynamicContent = { ...this.dynamicContent, _dropdown: { @@ -15,7 +15,7 @@ export class BaseHeaderSpecial extends BaseHeader { _searchbar: { "t-on-input": this.onSearchbarInput, }, - } + }; setup() { super.setup(); diff --git a/addons/website/static/src/interactions/header/header_top.js b/addons/website/static/src/interactions/header/header_top.js index b7ed2d2f226ed..d1a5dac5ec593 100644 --- a/addons/website/static/src/interactions/header/header_top.js +++ b/addons/website/static/src/interactions/header/header_top.js @@ -1,36 +1,22 @@ import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; -// TODO -class HeaderTop extends Interaction { +export class HeaderTop extends Interaction { static selector = "header#top"; dynamicContent = { "#top_menu_collapse, #top_menu_collapse_mobile": { + "t-on-show.bs.offcanvas": () => this.showCollapse = true, + "t-on-hidden.bs.offcanvas": () => this.showCollapse &&= this.mobileNavbarEl.classList.matches(".show, .showing"), "t-att-class": () => ({ - "o_top_menu_collapse_shown": this.shownCollapse, + "o_top_menu_collapse_shown": this.showCollapse, }), - "t-on-show.bs.offcanvas": this.onCollapseShow, - "t-on-hidden.bs.offcanvas": this.onCollapseHidden, }, - } + }; setup() { + this.showCollapse = false; this.mobileNavbarEl = this.el.querySelector("#top_menu_collapse_mobile"); } - - onCollapseShow() { - // this.options.wysiwyg?.odooEditor.observerUnactive("addCollapseClass"); - this.shownCollapse = true; - // this.options.wysiwyg?.odooEditor.observerActive("addCollapseClass"); - } - - onCollapseHidden() { - // this.options.wysiwyg?.odooEditor.observerUnactive("removeCollapseClass"); - if (!this.mobileNavbarEl.matches(".show, .showing")) { - this.shownCollapse = false; - } - // this.options.wysiwyg?.odooEditor.observerActive("removeCollapseClass"); - } } registry diff --git a/addons/website/static/tests/interactions/header/base_header.test.js b/addons/website/static/tests/interactions/header/base_header.test.js index ec5aed3999051..2e3c8b5b15398 100644 --- a/addons/website/static/tests/interactions/header/base_header.test.js +++ b/addons/website/static/tests/interactions/header/base_header.test.js @@ -1,18 +1,19 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; + import { getTemplateWithoutHideOnScroll, } from "./helpers"; setupInteractionWhiteList("website.header_standard"); + describe.current.tags("interaction_dev"); test("header_standard is started when there is an element header.o_header_standard", async () => { const { core } = await startInteractions(getTemplateWithoutHideOnScroll("o_header_standard")); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); diff --git a/addons/website/static/tests/interactions/header/base_header_special.test.js b/addons/website/static/tests/interactions/header/base_header_special.test.js index 66226c15cf4d0..91ad5448377cf 100644 --- a/addons/website/static/tests/interactions/header/base_header_special.test.js +++ b/addons/website/static/tests/interactions/header/base_header_special.test.js @@ -1,18 +1,19 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; + import { getTemplateWithoutHideOnScroll, } from "./helpers"; setupInteractionWhiteList("website.header_disappears"); + describe.current.tags("interaction_dev"); test("header_disappears is started when there is an element header.o_header_disappears", async () => { const { core } = await startInteractions(getTemplateWithoutHideOnScroll("o_header_disappears")); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); diff --git a/addons/website/static/tests/interactions/header/header_disappears.test.js b/addons/website/static/tests/interactions/header/header_disappears.test.js index d3e663fafc3ab..f5bbdf5fe6d19 100644 --- a/addons/website/static/tests/interactions/header/header_disappears.test.js +++ b/addons/website/static/tests/interactions/header/header_disappears.test.js @@ -1,10 +1,10 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; + import { setupTest, checkHeader, @@ -14,11 +14,12 @@ import { } from "./helpers"; setupInteractionWhiteList("website.header_disappears"); + describe.current.tags("interaction_dev"); test("header_disappears is started when there is an element header.o_header_disappears", async () => { const { core } = await startInteractions(getTemplateWithoutHideOnScroll("o_header_disappears")); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); const behaviorWithout = [{ diff --git a/addons/website/static/tests/interactions/header/header_fade_out.test.js b/addons/website/static/tests/interactions/header/header_fade_out.test.js index 2c101825fa01e..d5aa83f28c049 100644 --- a/addons/website/static/tests/interactions/header/header_fade_out.test.js +++ b/addons/website/static/tests/interactions/header/header_fade_out.test.js @@ -1,10 +1,10 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; + import { setupTest, checkHeader, @@ -14,11 +14,12 @@ import { } from "./helpers"; setupInteractionWhiteList("website.header_fade_out"); + describe.current.tags("interaction_dev"); test("header_fade_out is started when there is an element header.o_header_fade_out", async () => { const { core } = await startInteractions(getTemplateWithoutHideOnScroll("o_header_fade_out")); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); const behaviorWithout = [{ diff --git a/addons/website/static/tests/interactions/header/header_fixed.test.js b/addons/website/static/tests/interactions/header/header_fixed.test.js index 5206c43696343..09cd6409ea714 100644 --- a/addons/website/static/tests/interactions/header/header_fixed.test.js +++ b/addons/website/static/tests/interactions/header/header_fixed.test.js @@ -1,10 +1,10 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; + import { setupTest, checkHeader, @@ -14,11 +14,12 @@ import { } from "./helpers"; setupInteractionWhiteList("website.header_fixed"); + describe.current.tags("interaction_dev"); test("header_fixed is started when there is an element header.o_header_fixed", async () => { const { core } = await startInteractions(getTemplateWithoutHideOnScroll("o_header_fixed")); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); const behaviorWithout = [{ diff --git a/addons/website/static/tests/interactions/header/header_standard.test.js b/addons/website/static/tests/interactions/header/header_standard.test.js index 8e8b5a1034442..d29fc7d6058e0 100644 --- a/addons/website/static/tests/interactions/header/header_standard.test.js +++ b/addons/website/static/tests/interactions/header/header_standard.test.js @@ -1,10 +1,10 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; + import { setupTest, checkHeader, @@ -14,11 +14,12 @@ import { } from "./helpers"; setupInteractionWhiteList("website.header_standard"); + describe.current.tags("interaction_dev"); test("header_standard is started when there is an element header.o_header_standard", async () => { const { core } = await startInteractions(getTemplateWithoutHideOnScroll("o_header_standard")); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); const behaviorWithout = [{ diff --git a/addons/website/static/tests/interactions/header/header_top.test.js b/addons/website/static/tests/interactions/header/header_top.test.js index 79c171620a7e4..23227438152d0 100644 --- a/addons/website/static/tests/interactions/header/header_top.test.js +++ b/addons/website/static/tests/interactions/header/header_top.test.js @@ -8,14 +8,9 @@ import { setupInteractionWhiteList("website.header_top"); describe.current.tags("interaction_dev"); -const getTemplate = function (options = {}) { - return ` -
-
- ` -} +const headerTemplate = `
`; test("header_top is started when there is an element header#top", async () => { - const { core } = await startInteractions(getTemplate()); - expect(core.interactions.length).toBe(1); + const { core } = await startInteractions(headerTemplate); + expect(core.interactions).toHaveLength(1); }); From 64e552bb85b9de08745db330b68c4124dc24e318 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 16:05:55 +0100 Subject: [PATCH 111/150] popup (folder) --- .../src/interactions/cookies/cookies_bar.js | 4 +-- .../popup/no_backdrop_popup.edit.js | 4 +-- .../interactions/popup/no_backdrop_popup.js | 7 +++-- .../static/src/interactions/popup/popup.js | 25 +++++++--------- .../interactions/popup/shared_popup.edit.js | 7 +++-- .../src/interactions/popup/shared_popup.js | 11 ++++--- .../{snippets => popup}/popup.test.js | 30 +++++++++++-------- 7 files changed, 48 insertions(+), 40 deletions(-) rename addons/website/static/tests/interactions/{snippets => popup}/popup.test.js (94%) diff --git a/addons/website/static/src/interactions/cookies/cookies_bar.js b/addons/website/static/src/interactions/cookies/cookies_bar.js index 218f0b7fe8042..ad0c5bb84f9fe 100644 --- a/addons/website/static/src/interactions/cookies/cookies_bar.js +++ b/addons/website/static/src/interactions/cookies/cookies_bar.js @@ -55,7 +55,7 @@ export class CookiesBar extends Popup { this.bsModal.toggle(); // As we're using Bootstrap's events, the Popup class prevents the modal // from being shown after hiding it: override that behavior. - this._popupAlreadyShown = false; + this.popupAlreadyShown = false; cookie.delete(this.el.id); } @@ -94,7 +94,7 @@ export class CookiesBar extends Popup { */ onShowCookiesBar() { const currCookie = cookie.get(this.el.id); - if (currCookie && JSON.parse(currCookie).optional || !this._popupAlreadyShown) { + if (currCookie && JSON.parse(currCookie).optional || !this.popupAlreadyShown) { return; } this.bsModal.show(); diff --git a/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js b/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js index 92f6eccd2866a..c0a2305f8af0b 100644 --- a/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js +++ b/addons/website/static/src/interactions/popup/no_backdrop_popup.edit.js @@ -1,5 +1,5 @@ -import { registry } from "@web/core/registry"; import { NoBackdropPopup } from "./no_backdrop_popup"; +import { registry } from "@web/core/registry"; export const NoBackdropPopupEdit = (I) => class extends I { start() { @@ -16,5 +16,5 @@ registry .category("public.interactions.edit") .add("website.no_backdrop_popup", { Interaction: NoBackdropPopup, - mixin: NoBackdropPopupEdit + mixin: NoBackdropPopupEdit, }); diff --git a/addons/website/static/src/interactions/popup/no_backdrop_popup.js b/addons/website/static/src/interactions/popup/no_backdrop_popup.js index 554f479661bcf..14de91d16b2f8 100644 --- a/addons/website/static/src/interactions/popup/no_backdrop_popup.js +++ b/addons/website/static/src/interactions/popup/no_backdrop_popup.js @@ -1,6 +1,7 @@ +import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; + import { isScrollableY } from "@web/core/utils/scrolling"; -import { Interaction } from "@web/public/interaction"; export class NoBackdropPopup extends Interaction { static selector = ".s_popup_no_backdrop"; @@ -65,4 +66,6 @@ export class NoBackdropPopup extends Interaction { } } -registry.category("public.interactions").add("website.no_backdrop_popup", NoBackdropPopup); +registry + .category("public.interactions") + .add("website.no_backdrop_popup", NoBackdropPopup); diff --git a/addons/website/static/src/interactions/popup/popup.js b/addons/website/static/src/interactions/popup/popup.js index c9058e73d1943..41e9ceabd5f20 100644 --- a/addons/website/static/src/interactions/popup/popup.js +++ b/addons/website/static/src/interactions/popup/popup.js @@ -1,9 +1,10 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { browser } from "@web/core/browser/browser"; import { cookie } from "@web/core/browser/cookie"; -import { registry } from "@web/core/registry"; import { utils as uiUtils, SIZES } from "@web/core/ui/ui_service"; import { getTabableElements } from "@web/core/utils/ui"; -import { Interaction } from "@web/public/interaction"; export class Popup extends Interaction { static selector = ".s_popup:not(#website_cookies_bar)"; @@ -29,9 +30,7 @@ export class Popup extends Interaction { this.modalEl = this.el.querySelector(".modal"); /** @type {import("bootstrap").Modal} */ this.bsModal = window.Modal.getOrCreateInstance(this.modalEl); - this.registerCleanup(() => { - this.bsModal.dispose(); - }); + this.registerCleanup(() => { this.bsModal.dispose() }); this.modalShownOnClickEl = this.el.querySelector(".modal[data-display='onClick']"); if (this.modalShownOnClickEl) { @@ -43,7 +42,7 @@ export class Popup extends Interaction { return; } - this._popupAlreadyShown = !!cookie.get(this.el.id); + this.popupAlreadyShown = !!cookie.get(this.el.id); } start() { @@ -61,7 +60,7 @@ export class Popup extends Interaction { : el.classList.contains("o_snippet_desktop_invisible"); return (visibilitySelectors && el.matches(visibilitySelectors)) || deviceInvisible; }); - if (!this._popupAlreadyShown && !emptyPopup) { + if (!this.popupAlreadyShown && !emptyPopup) { this.bindPopup(); } } @@ -93,7 +92,7 @@ export class Popup extends Interaction { } showPopup() { - if (this._popupAlreadyShown || !this.canShowPopup()) { + if (this.popupAlreadyShown || !this.canShowPopup()) { return; } this.bsModal.show(); @@ -156,9 +155,7 @@ export class Popup extends Interaction { } // The focus should stay free for no backdrop popups. if (this.el.querySelector(".s_popup_no_backdrop")) { - this.addListener(this.el, "hide.bs.modal", () => { - previouslyFocusedEl.focus(); - }, { once: true }); + this.addListener(this.el, "hide.bs.modal", () => previouslyFocusedEl.focus(), { once: true }); return; } const onKeydown = (ev) => { @@ -203,11 +200,9 @@ export class Popup extends Interaction { onHideModal() { const nbDays = this.modalEl.dataset.consentsDuration; cookie.set(this.el.id, this.cookieValue, nbDays * 24 * 60 * 60, "required"); - this._popupAlreadyShown = !this.modalShownOnClickEl; + this.popupAlreadyShown = !this.modalShownOnClickEl; - this.el.querySelectorAll(".media_iframe_video iframe").forEach((iframeEl) => { - iframeEl.src = ""; - }); + this.el.querySelectorAll(".media_iframe_video iframe").forEach(iframeEl => iframeEl.src = ""); } onShowModal() { diff --git a/addons/website/static/src/interactions/popup/shared_popup.edit.js b/addons/website/static/src/interactions/popup/shared_popup.edit.js index 579418ef84cf7..a5453492cd6f6 100644 --- a/addons/website/static/src/interactions/popup/shared_popup.edit.js +++ b/addons/website/static/src/interactions/popup/shared_popup.edit.js @@ -1,5 +1,5 @@ -import { registry } from "@web/core/registry"; import { SharedPopup } from "./shared_popup"; +import { registry } from "@web/core/registry"; export const SharedPopupEdit = (I) => class extends I { setup() { @@ -9,4 +9,7 @@ export const SharedPopupEdit = (I) => class extends I { registry .category("public.interactions.edit") - .add("website.shared_popup", { Interaction: SharedPopup, mixin: SharedPopupEdit }); + .add("website.shared_popup", { + Interaction: SharedPopup, + mixin: SharedPopupEdit, + }); diff --git a/addons/website/static/src/interactions/popup/shared_popup.js b/addons/website/static/src/interactions/popup/shared_popup.js index 7114377154c06..be1083ed619ce 100644 --- a/addons/website/static/src/interactions/popup/shared_popup.js +++ b/addons/website/static/src/interactions/popup/shared_popup.js @@ -1,6 +1,7 @@ +import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; + import { getScrollingElement } from "@web/core/utils/scrolling"; -import { Interaction } from "@web/public/interaction"; export class SharedPopup extends Interaction { static selector = ".s_popup"; @@ -16,13 +17,15 @@ export class SharedPopup extends Interaction { // `contenteditable=true` attribute in edit mode. It will result in a // ugly white bar. // tl;dr: this is keeping those 2 elements visibility synchronized. - "_root": { + _root: { "t-on-show.bs.modal": () => this.popupShown = true, "t-on-shown.bs.modal": () => this.popupShown = true, "t-on-hidden.bs.modal": this.onModalHidden, - "t-att-class": () => ({ "d-none": !this.popupShown }), + "t-att-class": () => ({ + "d-none": !this.popupShown, + }), }, - } + }; setup() { this.popupShown = false; diff --git a/addons/website/static/tests/interactions/snippets/popup.test.js b/addons/website/static/tests/interactions/popup/popup.test.js similarity index 94% rename from addons/website/static/tests/interactions/snippets/popup.test.js rename to addons/website/static/tests/interactions/popup/popup.test.js index 1c1fc90671827..a1f8f99276b2e 100644 --- a/addons/website/static/tests/interactions/snippets/popup.test.js +++ b/addons/website/static/tests/interactions/popup/popup.test.js @@ -1,3 +1,8 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + import { beforeEach, describe, expect, test } from "@odoo/hoot"; import { animationFrame, @@ -10,20 +15,20 @@ import { tick, } from "@odoo/hoot-dom"; import { advanceTime } from "@odoo/hoot-mock"; + import { browser } from "@web/core/browser/browser"; import { cookie } from "@web/core/browser/cookie"; import { defineStyle } from "@web/../tests/web_test_helpers"; -import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; setupInteractionWhiteList("website.popup"); + describe.current.tags("interaction_dev"); /** - * Remove the CSS transitions because Bootstrap transitions don't work well with - * Hoot. + * Remove the CSS transitions because Bootstrap transitions don't work with Hoot. */ function removeTransitions() { - defineStyle(/* css */ ` + defineStyle(` * { transition: none !important; } @@ -69,7 +74,7 @@ function getPopupTemplate(options = {}) {
×
Primary button - ${focusableElements ? '' : "" } + ${focusableElements ? '' : ""}
@@ -78,6 +83,8 @@ function getPopupTemplate(options = {}) { `; } +const modal = "#sPopup .modal"; + test("popup interaction does not activate without .s_popup", async () => { const { core } = await startInteractions(``); expect(core.interactions).toHaveLength(0); @@ -85,10 +92,10 @@ test("popup interaction does not activate without .s_popup", async () => { describe("close popup", () => { beforeEach(removeTransitions); + test("close popup with close button and check cookies", async () => { const { core } = await startInteractions(getPopupTemplate()); expect(core.interactions).toHaveLength(1); - const modal = "#sPopup .modal"; expect(cookie.get("sPopup")).not.toBe("true"); await tick(); await animationFrame(); @@ -102,7 +109,6 @@ describe("close popup", () => { test("close popup by pressing escape", async () => { const { core } = await startInteractions(getPopupTemplate()); expect(core.interactions).toHaveLength(1); - const modal = "#sPopup .modal"; await tick(); await animationFrame(); expect(modal).toBeVisible(); @@ -116,7 +122,6 @@ describe("close popup", () => { test("click on primary button closes popup", async () => { const { core } = await startInteractions(getPopupTemplate()); expect(core.interactions).toHaveLength(1); - const modal = "#sPopup .modal"; await tick(); await animationFrame(); expect(modal).toBeVisible(); @@ -128,7 +133,6 @@ describe("close popup", () => { test("click on primary button which is a form submit doesn't close popup", async () => { const { core } = await startInteractions(getPopupTemplate({ extraPrimaryBtnClasses: "o_website_form_send" })); expect(core.interactions).toHaveLength(1); - const modal = "#sPopup .modal"; await tick(); await animationFrame(); expect(modal).toBeVisible(); @@ -142,7 +146,6 @@ describe("show popup", () => { test("popup shows after 5000ms", async () => { const { core } = await startInteractions(getPopupTemplate({ showAfter: 5000 })); expect(core.interactions).toHaveLength(1); - const modal = "#sPopup .modal"; expect(modal).not.toBeVisible(); await advanceTime(4500); expect(modal).not.toBeVisible(); @@ -163,7 +166,7 @@ describe("show popup", () => { expect(modal).toBeVisible(); }); - test.tags`desktop`("show popup when mouse leaves document", async () => { + test.tags("desktop")("show popup when mouse leaves document", async () => { const { core, el } = await startInteractions(getPopupTemplate({ display: "mouseExit" })); expect(core.interactions).toHaveLength(1); const modalEl = el.querySelector("#sPopup .modal"); @@ -176,10 +179,11 @@ describe("show popup", () => { describe("trap focus", () => { beforeEach(removeTransitions); + test("focus is trapped when popup opens", async () => { const { core, el } = await startInteractions(` Link - ${getPopupTemplate({modalId: "modal", focusableElements: true})} + ${getPopupTemplate({ modalId: "modal", focusableElements: true })} `); expect(core.interactions).toHaveLength(1); await pointerDown(el.ownerDocument.body); @@ -199,7 +203,7 @@ describe("trap focus", () => { test("reset focus on the previous active element when popup is closed", async () => { const { core, el } = await startInteractions(` Link - ${getPopupTemplate({modalId: "modal" })} + ${getPopupTemplate({ modalId: "modal" })} `); expect(core.interactions).toHaveLength(1); await pointerDown(el.ownerDocument.body); From 55a758098441c423ab05890a2e8803a49cdc30ad Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 16:08:42 +0100 Subject: [PATCH 112/150] video (folder) --- .../static/src/interactions/video/background_video.js | 9 ++++----- .../website/static/src/interactions/video/media_video.js | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/addons/website/static/src/interactions/video/background_video.js b/addons/website/static/src/interactions/video/background_video.js index ffdafbc0bff53..f4cd68f8705b1 100644 --- a/addons/website/static/src/interactions/video/background_video.js +++ b/addons/website/static/src/interactions/video/background_video.js @@ -3,16 +3,15 @@ import { registry } from "@web/core/registry"; import { uniqueId } from "@web/core/utils/functions"; import { renderToElement } from "@web/core/utils/render"; - import { setupAutoplay, triggerAutoplay } from "@website/utils/videos"; -class BackgroundVideo extends Interaction { +export class BackgroundVideo extends Interaction { static selector = ".o_background_video"; dynamicSelectors = { ...this.dynamicSelectors, _dropdown: () => this.el.closest(".dropdown-menu").parentElement, _modal: () => this.el.closest("modal"), - } + }; dynamicContent = { _document: { "t-on-optionalCookiesAccepted": () => this.iframeEl.src = this.videoSrc, @@ -32,7 +31,7 @@ class BackgroundVideo extends Interaction { "d-none": this.hideVideoContainer, }), }, - } + }; setup() { this.hideVideoContainer = false; @@ -94,7 +93,7 @@ class BackgroundVideo extends Interaction { // an horizontal scrollbar may appear. this.adjustIframe(); }); - this.insert(this.bgVideoContainer, this.el, "afterbegin") + this.insert(this.bgVideoContainer, this.el, "afterbegin"); oldContainer.remove(); this.adjustIframe(); diff --git a/addons/website/static/src/interactions/video/media_video.js b/addons/website/static/src/interactions/video/media_video.js index 002ca29e3e400..857b8e42ed926 100644 --- a/addons/website/static/src/interactions/video/media_video.js +++ b/addons/website/static/src/interactions/video/media_video.js @@ -3,10 +3,9 @@ import { registry } from "@web/core/registry"; import { _t } from "@web/core/l10n/translation"; import { escape } from "@web/core/utils/strings"; - import { setupAutoplay, triggerAutoplay } from "@website/utils/videos"; -class MediaVideo extends Interaction { +export class MediaVideo extends Interaction { static selector = ".media_iframe_video"; setup() { @@ -76,7 +75,7 @@ class MediaVideo extends Interaction { return; } - const iframeEl = document.createElement("iframe") + const iframeEl = document.createElement("iframe"); iframeEl.frameborder = "0"; iframeEl.allowFullscreen = "allowfullscreen"; iframeEl.ariaLabel = _t("Media video"); From b447908015ee576f494631083f38fd18c16a1725 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 16:45:04 +0100 Subject: [PATCH 113/150] last interactions (1/8) --- .../static/src/interactions/_example.js | 61 ------------------- .../static/src/interactions/anchor_slide.js | 28 +++------ ...nimate_overflow.js => animate_overflow.js} | 14 ++--- .../static/src/interactions/animation.js | 13 ++-- .../src/interactions/bottom_fixed_element.js | 29 ++++----- .../tests/interactions/anchor_slide.test.js | 30 ++++----- ...rflow.test.js => animate_overflow.test.js} | 16 ++--- .../tests/interactions/animation.test.js | 36 ++++++----- .../interactions/bottom_fixed_element.test.js | 36 ++++++----- 9 files changed, 101 insertions(+), 162 deletions(-) rename addons/website/static/src/interactions/{website_animate_overflow.js => animate_overflow.js} (82%) rename addons/website/static/tests/interactions/{website_animate_overflow.test.js => animate_overflow.test.js} (79%) diff --git a/addons/website/static/src/interactions/_example.js b/addons/website/static/src/interactions/_example.js index 6d6ff218280de..09719852aac7d 100644 --- a/addons/website/static/src/interactions/_example.js +++ b/addons/website/static/src/interactions/_example.js @@ -1,68 +1,7 @@ // import { registry } from "@web/core/registry"; -import { Interaction } from "@web/public/interaction"; import { Component, xml, useState } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; -/** - * This is just a few examples. This should be removed in the future, before - * merging this to master - */ - -// ----------------------------------------------------------------------------- -// Example of interaction -// ----------------------------------------------------------------------------- -export class TogglableBackgroundSection extends Interaction { - static selector = "section"; - dynamicContent = { - _root: { - "t-att-style": () => ({ - "background-color": this.bgColor - }), - "t-att-data-bg-color": () => this.bgColor, - }, - h2: { - "t-on-click": this.toggleBackground, - "t-out": () => this.bgColor, - }, - }; - - setup() { - this.bgColor = this.el.dataset.bgColor || "white"; - } - - toggleBackground() { - this.bgColor = this.bgColor === "white" ? "red" : "white"; - this.services.notification.add(`Example of a service: ${this.bgColor}`); - } -} - -/* -registry - .category("public.interactions") - .add("website.toggle_background", TogglableBackgroundSection); -*/ - -// ----------------------------------------------------------------------------- -// Example of interaction -// ----------------------------------------------------------------------------- -export class FunNotificationThing extends Interaction { - static selector = "#wrapwrap"; - dynamicContent = { - "b,strong:t-on-click": this.onClick, - }; - - onClick(ev) { - const text = ev.target.innerText; - this.services.notification.add(`Look at this => ${text}`); - } -} - -/* -registry - .category("public.interactions") - .add("website.fun_notification", FunNotificationThing); -*/ - // ----------------------------------------------------------------------------- // Example of mounted component // ----------------------------------------------------------------------------- diff --git a/addons/website/static/src/interactions/anchor_slide.js b/addons/website/static/src/interactions/anchor_slide.js index 20c058e07db90..e5860ab8dc5e4 100644 --- a/addons/website/static/src/interactions/anchor_slide.js +++ b/addons/website/static/src/interactions/anchor_slide.js @@ -1,5 +1,6 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { scrollTo } from "@web_editor/js/common/scrolling"; export class AnchorSlide extends Interaction { @@ -21,20 +22,14 @@ export class AnchorSlide extends Interaction { extraOffset: this.computeExtraOffset(), }); } - /** - * To be overridden. - */ + computeExtraOffset() { return 0; } - /** - */ - animateClick(ev) { + + animateClick() { const ensureSlash = (path) => (path.endsWith("/") ? path : path + "/"); - if ( - ensureSlash(this.el.pathname) !== - ensureSlash(window.location.pathname) - ) { + if (ensureSlash(this.el.pathname) !== ensureSlash(window.location.pathname)) { return; } // Avoid flicker at destination in case of ending "/" difference. @@ -65,9 +60,7 @@ export class AnchorSlide extends Interaction { this.addListener( offcanvasEl, "hidden.bs.offcanvas", - () => { - this.manageScroll(hash, anchorEl, scrollValue); - }, + () => this.manageScroll(hash, anchorEl, scrollValue), // the listener must be automatically removed when invoked { once: true }, ); @@ -76,8 +69,8 @@ export class AnchorSlide extends Interaction { this.manageScroll(hash, anchorEl, scrollValue); } } + /** - * * @param {string} hash * @param {HTMLElement} anchorEl the element to scroll to. * @param {string} [scrollValue='true'] scroll value @@ -90,10 +83,7 @@ export class AnchorSlide extends Interaction { // parameter, the "scrollTo" function handles the scroll to the top // or to the bottom of the document even if the header or the // footer is removed from the DOM. - scrollTo(hash, { - duration: 500, - extraOffset: this.computeExtraOffset(), - }); + this.scrollTo(hash); } else { this.scrollTo(anchorEl, scrollValue); } diff --git a/addons/website/static/src/interactions/website_animate_overflow.js b/addons/website/static/src/interactions/animate_overflow.js similarity index 82% rename from addons/website/static/src/interactions/website_animate_overflow.js rename to addons/website/static/src/interactions/animate_overflow.js index 88da078071c5f..904975deb1b46 100644 --- a/addons/website/static/src/interactions/website_animate_overflow.js +++ b/addons/website/static/src/interactions/animate_overflow.js @@ -2,19 +2,19 @@ import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; import { getScrollingElement } from "@web/core/utils/scrolling"; -export class WebsiteAnimateOverflow extends Interaction { +export class AnimateOverflow extends Interaction { static selector = "#wrapwrap"; dynamicSelectors = { ...this.dynamicSelectors, - "_scrollingElement": () => this.scrollingElement, + _scrollingElement: () => this.scrollingElement, }; dynamicContent = { - "_scrollingElement": { + _scrollingElement: { "t-att-class": () => ({ "o_wanim_overflow_xy_hidden": this.forceOverflowXYHidden || this.hasAnimationInProgress, }), }, - "_root": { + _root: { "t-on-updatecontent.noupdate": (ev) => { if (ev.target.classList.contains("o_animate")) { this.updateContent(); @@ -43,10 +43,10 @@ export class WebsiteAnimateOverflow extends Interaction { registry .category("public.interactions") - .add("website.website_animate_overflow", WebsiteAnimateOverflow); + .add("website.animate_overflow", AnimateOverflow); registry .category("public.interactions.edit") - .add("website.website_animate_overflow", { - Interaction: WebsiteAnimateOverflow, + .add("website.animate_overflow", { + Interaction: AnimateOverflow, }); diff --git a/addons/website/static/src/interactions/animation.js b/addons/website/static/src/interactions/animation.js index 97e9899fcca7b..46eade616b29a 100644 --- a/addons/website/static/src/interactions/animation.js +++ b/addons/website/static/src/interactions/animation.js @@ -1,5 +1,6 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { getScrollingElement, isScrollableY } from "@web/core/utils/scrolling"; import { isVisible } from "@web/core/utils/ui"; @@ -7,13 +8,11 @@ export class Animation extends Interaction { static selector = ".o_animate"; dynamicSelectors = { ...this.dynamicSelectors, - _windowUnlessDropdown: () => this.windowUnlessDropdown, _scrollingTarget: () => this.scrollingTarget, + _windowUnlessDropdown: () => this.windowUnlessDropdown, }; dynamicContent = { - _window: { - "t-on-resize": this.scrollWebsiteAnimate, - }, + _window: { "t-on-resize": this.scrollWebsiteAnimate }, _windowUnlessDropdown: { "t-on-shown.bs.modal": this.scrollWebsiteAnimate, "t-on-slid.bs.carousel": this.scrollWebsiteAnimate, @@ -201,7 +200,9 @@ export class Animation extends Interaction { } } -registry.category("public.interactions").add("website.animation", Animation); +registry + .category("public.interactions") + .add("website.animation", Animation); registry .category("public.interactions.edit") diff --git a/addons/website/static/src/interactions/bottom_fixed_element.js b/addons/website/static/src/interactions/bottom_fixed_element.js index 6475dad4769d1..b955e9814be5e 100644 --- a/addons/website/static/src/interactions/bottom_fixed_element.js +++ b/addons/website/static/src/interactions/bottom_fixed_element.js @@ -3,15 +3,11 @@ import { registry } from "@web/core/registry"; import { touching, isVisible } from "@web/core/utils/ui"; -class BottomFixedElement extends Interaction { +export class BottomFixedElement extends Interaction { static selector = "#wrapwrap"; dynamicContent = { - _document: { - "t-on-scroll": this.hideBottomFixedElements, - }, - _window: { - "t-on-resize": this.hideBottomFixedElements, - }, + _document: { "t-on-scroll": this.hideBottomFixedElements }, + _window: { "t-on-resize": this.hideBottomFixedElements }, } destroy() { @@ -22,7 +18,7 @@ class BottomFixedElement extends Interaction { // Note: check in the whole DOM instead of #wrapwrap as unfortunately // some things are still put outside of the #wrapwrap (like the livechat // button which is the main reason of this code). - const bottomFixedEls = this.el.querySelectorAll(".o_bottom_fixed_element"); + const bottomFixedEls = document.querySelectorAll(".o_bottom_fixed_element"); if (!bottomFixedEls.length) { return; } @@ -36,7 +32,7 @@ class BottomFixedElement extends Interaction { // reappear. if (this.el.querySelector(".s_popup_no_backdrop.show")) { for (const bottomFixedEl of bottomFixedEls) { - bottomFixedEl.classList.add("o_bottom_fixed_element_hidden") + bottomFixedEl.classList.add("o_bottom_fixed_element_hidden"); } return; } @@ -47,7 +43,7 @@ class BottomFixedElement extends Interaction { const buttonEls = [...this.el.querySelectorAll('a, .btn')].filter(isVisible); for (const bottomFixedEl of bottomFixedEls) { const bcr = bottomFixedEl.getBoundingClientRect(); - const hiddenButtonEl = touching(buttonEls, { + const touchingButtonEl = touching(buttonEls, { top: bcr.top, right: bcr.right, bottom: bcr.bottom, @@ -56,13 +52,12 @@ class BottomFixedElement extends Interaction { height: bcr.height, x: bcr.x, y: bcr.y, - }) - if (!!hiddenButtonEl.length) { + }); + if (!!touchingButtonEl.length) { if (bottomFixedEl.classList.contains("o_bottom_fixed_element_move_up")) { - bottomFixedEl.style.marginBottom = window.innerHeight - hiddenButtonEl.getBoundingClientRect().top + 5 + 'px'; + bottomFixedEl.style.marginBottom = window.innerHeight - touchingButtonEl.getBoundingClientRect().top + 5 + 'px'; } else { bottomFixedEl.classList.add('o_bottom_fixed_element_hidden'); - } } } @@ -83,3 +78,9 @@ class BottomFixedElement extends Interaction { registry .category("public.interactions") .add("website.bottom_fixed_element", BottomFixedElement); + +registry + .category("public.interactions.edit") + .add("website.bottom_fixed_element", { + Interaction: BottomFixedElement, + }); diff --git a/addons/website/static/tests/interactions/anchor_slide.test.js b/addons/website/static/tests/interactions/anchor_slide.test.js index de218742cdd48..bc68c989213c2 100644 --- a/addons/website/static/tests/interactions/anchor_slide.test.js +++ b/addons/website/static/tests/interactions/anchor_slide.test.js @@ -1,25 +1,27 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { animationFrame, click } from "@odoo/hoot-dom"; -import { advanceTime } from "@odoo/hoot-mock"; import { isElementVerticallyInViewportOf, startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; + setupInteractionWhiteList("website.anchor_slide"); + describe.current.tags("interaction_dev"); -test("anchor slide does nothing if there is no href", async () => { +test("anchor_slide does nothing if there is no href", async () => { const { core } = await startInteractions(` -
- -
+ `); - expect(core.interactions.length).toBe(0); + expect(core.interactions).toHaveLength(0); }); -test("anchor slide scrolls to targetted location", async () => { +test("anchor_slide scrolls to targetted location", async () => { const { core, el } = await startInteractions(`
Click here @@ -29,7 +31,7 @@ test("anchor slide scrolls to targetted location", async () => { `); const scrollEl = el.querySelector("#wrapwrap"); const targetEl = scrollEl.querySelector("div#target"); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); click("a[href]"); // Intentionally not awaited expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); @@ -38,7 +40,7 @@ test("anchor slide scrolls to targetted location", async () => { expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(true); }); -test("without anchor slide instantly reach the targetted location", async () => { +test("without anchor_slide, instantly reach the targetted location", async () => { const { core, el } = await startInteractions(`
Click here @@ -48,16 +50,16 @@ test("without anchor slide instantly reach the targetted location", async () => `); const scrollEl = el.querySelector("#wrapwrap"); const targetEl = scrollEl.querySelector("div#target"); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); core.stopInteractions(); - expect(core.interactions.length).toBe(0); + expect(core.interactions).toHaveLength(0); expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(false); click("a[href]"); // Intentionally not awaited await animationFrame(); expect(isElementVerticallyInViewportOf(targetEl, scrollEl)).toBe(true); }); -test("anchor slide scrolls to targetted location - with non-ASCII7 characters", async () => { +test("anchor_slide scrolls to targetted location - with non-ASCII7 characters", async () => { const { core, el } = await startInteractions(`
Click here diff --git a/addons/website/static/tests/interactions/website_animate_overflow.test.js b/addons/website/static/tests/interactions/animate_overflow.test.js similarity index 79% rename from addons/website/static/tests/interactions/website_animate_overflow.test.js rename to addons/website/static/tests/interactions/animate_overflow.test.js index 66dfd8a5b0258..ff88d3596d6cd 100644 --- a/addons/website/static/tests/interactions/website_animate_overflow.test.js +++ b/addons/website/static/tests/interactions/animate_overflow.test.js @@ -1,20 +1,22 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom"; import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; -setupInteractionWhiteList("website.website_animate_overflow"); +import { describe, expect, test } from "@odoo/hoot"; +import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom"; + +setupInteractionWhiteList("website.animate_overflow"); + describe.current.tags("interaction_dev"); -test("website animate overflow adds class during animations", async () => { +test("animate_overflow adds class during animations", async () => { const { core, el } = await startInteractions(`
`); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); const htmlEl = el.closest("html"); expect(htmlEl).not.toHaveClass("o_wanim_overflow_xy_hidden"); const animatedEl = el.querySelector(".o_animate"); @@ -26,13 +28,13 @@ test("website animate overflow adds class during animations", async () => { expect(htmlEl).not.toHaveClass("o_wanim_overflow_xy_hidden"); }); -test("website animate overflow always adds class if there are transforms", async () => { +test("animate_overflow always adds class if there are transforms", async () => { const { core, el } = await startInteractions(`
`); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); const htmlEl = el.closest("html"); expect(htmlEl).toHaveClass("o_wanim_overflow_xy_hidden"); }); diff --git a/addons/website/static/tests/interactions/animation.test.js b/addons/website/static/tests/interactions/animation.test.js index e2350111d29d7..7e8b747564faa 100644 --- a/addons/website/static/tests/interactions/animation.test.js +++ b/addons/website/static/tests/interactions/animation.test.js @@ -1,14 +1,16 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { animationFrame, click, scroll } from "@odoo/hoot-dom"; import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { animationFrame, click, scroll } from "@odoo/hoot-dom"; + setupInteractionWhiteList("website.animation"); + describe.current.tags("interaction_dev"); -test("on appearance animation starts once visible", async () => { +test("onAppearance animation starts once visible", async () => { const { core, el } = await startInteractions(`
Get there @@ -17,7 +19,7 @@ test("on appearance animation starts once visible", async () => {
Tall stuff
`); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); // Scroll top must be obtained on wrapwrap in test. core.interactions[0].interaction.scrollingElement = el.querySelector("#wrapwrap"); const animEl = el.querySelector(".o_animate"); @@ -35,33 +37,38 @@ test("on scroll animation changes based on scroll", async () => { const { core, el } = await startInteractions(`
Tall stuff
-
Animated
+
+ Animated +
Tall stuff
`); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); const wrapEl = el.querySelector("#wrapwrap"); // Scroll top must be obtained on wrapwrap in test. core.interactions[0].interaction.scrollingElement = wrapEl; const animEl = el.querySelector(".o_animate"); expect(animEl.style.visibility).toBe("visible"); + const delay0 = animEl.style.animationDelay; expect(delay0).toBe(""); + await scroll(wrapEl, { y: 2000 }); await animationFrame(); const delay1 = animEl.style.animationDelay - expect(delay1).not.toBe(""); expect(delay1).not.toBe(delay0); + await scroll(wrapEl, { y: 2100 }); await animationFrame(); const delay2 = animEl.style.animationDelay - expect(delay2).not.toBe(""); + expect(delay2).not.toBe(delay0); expect(delay2).not.toBe(delay1); }); -test("on appearance animation in modal starts once visible", async () => { +test("onAppearance animation in modal starts once visible", async () => { const { core, el } = await startInteractions(`
`); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); const modalEl = el.querySelector(".modal"); - const animEl = el.querySelector(".o_animate"); - expect(animEl).not.toHaveClass("o_animating"); + expect(".o_animate").not.toHaveClass("o_animating"); window.Modal.getOrCreateInstance(modalEl).show(); await animationFrame(); - expect(animEl).toHaveClass("o_animating"); + expect(".o_animate").toHaveClass("o_animating"); }); diff --git a/addons/website/static/tests/interactions/bottom_fixed_element.test.js b/addons/website/static/tests/interactions/bottom_fixed_element.test.js index 05bfdcf335c0b..c8f779872e31d 100644 --- a/addons/website/static/tests/interactions/bottom_fixed_element.test.js +++ b/addons/website/static/tests/interactions/bottom_fixed_element.test.js @@ -1,13 +1,13 @@ -import { describe, expect, test } from "@odoo/hoot"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; import { scroll } from "@odoo/hoot-dom"; setupInteractionWhiteList("website.bottom_fixed_element"); + describe.current.tags("interaction_dev"); const scrollTo = async function (el, scrollTarget, bottomFixedElement) { @@ -32,30 +32,28 @@ const scrollToBottom = async function (el, bottomFixedElement) { } const getTemplate = function (options = {}) { - const withButtonCenter = options.withButtonCenter || false; const withButtonLeft = options.withButtonLeft || false; + const withButtonCenter = options.withButtonCenter || false; - const emptyDiv = `
` - const buttonEl = `` + const emptyDiv = `
`; + const buttonEl = ``; return ` -
-
-
-
-
-
-
- ${withButtonLeft ? buttonEl : emptyDiv} - ${withButtonCenter ? buttonEl : emptyDiv} - ${emptyDiv} -
- ` +
+
+
+
+
+ ${withButtonLeft ? buttonEl : emptyDiv} + ${withButtonCenter ? buttonEl : emptyDiv} + ${emptyDiv} +
+ `; } test("bottom_fixed_element is started when there is an element #wrapwrap", async () => { const { core } = await startInteractions(getTemplate()); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); test("show button fixed element when over no button (0 button present)", async () => { @@ -88,7 +86,7 @@ test("hide button fixed element when over one button (1 button present)", async expect(bottomFixedElement).toHaveClass("o_bottom_fixed_element_hidden"); }); -test("hide button fixed element when over one button (2 button present)", async () => { +test("hide button fixed element when over one button (2 buttons presents)", async () => { const { el } = await startInteractions(getTemplate({ withButtonCenter: true, withButtonLeft: true })); el.style.overflowY = "scroll"; const bottomFixedElement = el.querySelector(".o_bottom_fixed_element"); From ac1a32edd501f2807c9561838d329016e031a60f Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Fri, 27 Dec 2024 17:10:24 +0100 Subject: [PATCH 114/150] last interactions (2/8) --- .../static/src/snippets/s_chart/chart.edit.js | 16 +++ .../static/src/snippets/s_chart/chart.js | 56 +++------- .../src/snippets/s_countdown/countdown.js | 31 +++--- .../s_dynamic_snippet/dynamic_snippet.edit.js | 13 ++- .../s_dynamic_snippet/dynamic_snippet.js | 93 +++++++++------- .../dynamic_snippet_carousel.js | 8 +- .../tests/interactions/snippets/chart.test.js | 44 ++++---- .../interactions/snippets/countdown.test.js | 60 +++++----- .../snippets/dynamic_snippet.test.js | 93 ++++++++-------- .../snippets/dynamic_snippet_carousel.test.js | 103 +++++++----------- .../tour_utils/lifecycle_dep_interaction.js | 4 +- 11 files changed, 250 insertions(+), 271 deletions(-) create mode 100644 addons/website/static/src/snippets/s_chart/chart.edit.js diff --git a/addons/website/static/src/snippets/s_chart/chart.edit.js b/addons/website/static/src/snippets/s_chart/chart.edit.js new file mode 100644 index 0000000000000..5a7372b16b6a8 --- /dev/null +++ b/addons/website/static/src/snippets/s_chart/chart.edit.js @@ -0,0 +1,16 @@ +import { Chart } from "./chart"; +import { registry } from "@web/core/registry"; + +const ChartEdit = I => class extends I { + setup() { + super.setup(); + this.noAnimation = true; + } +}; + +registry + .category("public.interactions.edit") + .add("website.chart", { + Interaction: Chart, + mixin: ChartEdit, + }); diff --git a/addons/website/static/src/snippets/s_chart/chart.js b/addons/website/static/src/snippets/s_chart/chart.js index 362d75c054048..00afa7bca1611 100644 --- a/addons/website/static/src/snippets/s_chart/chart.js +++ b/addons/website/static/src/snippets/s_chart/chart.js @@ -4,12 +4,12 @@ import { registry } from "@web/core/registry"; import { loadBundle } from "@web/core/assets"; import weUtils from "@web_editor/js/common/utils"; -class Chart extends Interaction { - +export class Chart extends Interaction { static selector = ".s_chart"; setup() { this.chart = null; + this.noAnimation = false; this.style = window.getComputedStyle(document.documentElement); } @@ -20,13 +20,8 @@ class Chart extends Interaction { start() { const data = JSON.parse(this.el.dataset.data); data.datasets.forEach(el => { - if (Array.isArray(el.backgroundColor)) { - el.backgroundColor = el.backgroundColor.map(el => this.convertToCSSColor(el)); - el.borderColor = el.borderColor.map(el => this.convertToCSSColor(el)); - } else { - el.backgroundColor = this.convertToCSSColor(el.backgroundColor); - el.borderColor = this.convertToCSSColor(el.borderColor); - } + el.backgroundColor = this.convertToCSS(el.backgroundColor); + el.borderColor = this.convertToCSS(el.borderColor); el.borderWidth = this.el.dataset.borderWidth; }); @@ -89,51 +84,34 @@ class Chart extends Interaction { label: (tooltipItem) => { const label = tooltipItem.label; const secondLabel = tooltipItem.dataset.label; - let final = label; - if (label) { - if (secondLabel) { - final = label + " - " + secondLabel; - } - } else if (secondLabel) { - final = secondLabel; - } + const final = label && secondLabel ? label + " - " + secondLabel : label || secondLabel; return final + ":" + tooltipItem.formattedValue; }, }; } - // Disable animation in edit mode - if (this.editableMode) { - chartData.options.animation = { - duration: 0, - }; + if (this.noAnimation) { + chartData.options.animation = { duration: 0 }; } - const canvas = this.el.querySelector("canvas"); - window.Chart.Tooltip.positioners.custom = (elements, eventPosition) => eventPosition; - this.chart = new window.Chart(canvas, chartData); + const canvasEls = this.el.querySelector("canvas"); + window.Chart.Tooltip.positioners.custom = (_, eventPosition) => eventPosition; + this.chart = new window.Chart(canvasEls, chartData); this.registerCleanup(() => { this.chart.destroy(); this.el.querySelectorAll(".chartjs-size-monitor").forEach(el => el.remove()); }); } - /** - * @param {string} color - * @returns {string} - */ + convertToCSS(paramColor) { + return Array.isArray(paramColor) ? paramColor.map(color => this.convertToCSSColor(color)) : this.convertToCSSColor(paramColor); + } + convertToCSSColor(color) { - if (!color) { - return "transparent"; - } - return weUtils.getCSSVariableValue(color, this.style) || color; + return color ? weUtils.getCSSVariableValue(color, this.style) || color : "transparent"; } } -registry.category("public.interactions").add("website.chart", Chart); - registry - .category("public.interactions.edit") - .add("website.chart", { - Interaction: Chart, - }); + .category("public.interactions") + .add("website.chart", Chart); diff --git a/addons/website/static/src/snippets/s_countdown/countdown.js b/addons/website/static/src/snippets/s_countdown/countdown.js index b7d5d908946c7..efa6b0242ceae 100644 --- a/addons/website/static/src/snippets/s_countdown/countdown.js +++ b/addons/website/static/src/snippets/s_countdown/countdown.js @@ -1,26 +1,24 @@ import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; +import { getCSSVariableValue, getHtmlStyle } from "@html_editor/utils/formatting"; import { _t } from "@web/core/l10n/translation"; import { isCSSColor } from "@web/core/utils/colors"; -import { getCSSVariableValue, getHtmlStyle } from "@html_editor/utils/formatting"; -class Countdown extends Interaction { +export class Countdown extends Interaction { static selector = ".s_countdown"; dynamicContent = { - ".s_countdown_canvas_wrapper": { + ".s_countdown_canvas_wrapper": { "t-att-class": () => ({ "d-flex": true, "justify-content-center": true, - }) + }), }, - } + }; setup() { // Remove SVG previews (used to simulated canvas) - this.el.querySelectorAll("svg").forEach(el => { - el.parentNode.remove(); - }) + this.el.querySelectorAll("svg").forEach(el => el.parentNode.remove()); this.wrapperEl = this.el.querySelector(".s_countdown_canvas_wrapper"); this.hereBeforeTimerEnds = false; @@ -46,9 +44,9 @@ class Countdown extends Interaction { this.progressBarStyle = this.el.dataset.progressBarStyle; this.progressBarWeight = this.el.dataset.progressBarWeight; - this.textColor = this.ensureCSSColor(this.el.dataset.textColor); this.layoutBackgroundColor = this.ensureCSSColor(this.el.dataset.layoutBackgroundColor); this.progressBarColor = this.ensureCSSColor(this.el.dataset.progressBarColor); + this.textColor = this.ensureCSSColor(this.el.dataset.textColor); this.onlyOneUnit = this.display === "d"; this.width = this.size; @@ -63,9 +61,7 @@ class Countdown extends Interaction { } destroy() { - this.el.querySelector(".s_countdown_end_message")?.classList.add("d-none"); - this.el.querySelector(".s_countdown_canvas_wrapper")?.classList.remove("d-none"); - + this.el.querySelector(".s_countdown_canvas_wrapper").classList.remove("d-none"); clearInterval(this.setInterval); } @@ -89,7 +85,7 @@ class Countdown extends Interaction { if (this.endAction === "redirect") { const redirectUrl = this.el.dataset.redirectUrl || "/"; if (this.hereBeforeTimerEnds) { - setTimeout(() => window.location = redirectUrl, 500); + this.waitForTimeout(() => window.location = redirectUrl, 500); } else { if (!this.el.querySelector(".s_countdown_end_redirect_message").length) { const container = this.el.querySelector("> .container, > .container-fluid, > .o_container_small"); @@ -99,8 +95,9 @@ class Countdown extends Interaction { } } } else if (this.endAction === "message" || this.endAction === "message_no_countdown") { - this.el.querySelector(".s_countdown_end_message").removeClass("d-none"); + this.el.querySelector(".s_countdown_end_message").classList.remove("d-none"); } + this.registerCleanup(() => this.el.querySelector(".s_countdown_end_message").classList.add("d-none")); } getDelta() { @@ -203,7 +200,7 @@ class Countdown extends Interaction { const hideCountdown = this.isFinished && !this.editableMode && this.el.classList.contains("hide-countdown"); if (this.layout === "text") { - const canvasEls = this.el.querySelectorAll(".s_countdown_canvas_flex") + const canvasEls = this.el.querySelectorAll(".s_countdown_canvas_flex"); for (const canvasEl of canvasEls) { canvasEl.classList.add("d-none"); } @@ -406,4 +403,6 @@ registry registry .category("public.interactions.edit") - .add("website.countdown", { Interaction: Countdown }); + .add("website.countdown", { + Interaction: Countdown, + }); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.edit.js b/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.edit.js index f2531e3c851b6..9b61531452aba 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.edit.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.edit.js @@ -1,16 +1,17 @@ -import { registry } from "@web/core/registry"; import { DynamicSnippet } from "./dynamic_snippet"; +import { registry } from "@web/core/registry"; const DynamicSnippetEdit = I => class extends I { - /** - * @override - */ - callToAction(ev) {} + setup() { + super.setup(); + this.isEditMode = true; + } + callToAction() { } }; registry .category("public.interactions.edit") .add("website.dynamic_snippet", { Interaction: DynamicSnippet, - mixin: DynamicSnippetEdit + mixin: DynamicSnippetEdit, }); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js b/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js index eae71bd091d2b..f6c4e85ef363f 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/dynamic_snippet.js @@ -1,9 +1,10 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { rpc } from "@web/core/network/rpc"; +import { listenSizeChange, utils as uiUtils } from "@web/core/ui/ui_service"; import { uniqueId } from "@web/core/utils/functions"; import { renderToFragment } from "@web/core/utils/render"; -import { listenSizeChange, utils as uiUtils } from "@web/core/ui/ui_service"; import { markup } from "@odoo/owl"; @@ -16,6 +17,11 @@ export class DynamicSnippet extends Interaction { "[data-url]": { "t-on-click": this.callToAction, }, + _root: { + "t-att-class": () => ({ + "o_dynamic_snippet_empty": !this.isVisible, + }), + }, }; setup() { @@ -30,76 +36,76 @@ export class DynamicSnippet extends Interaction { this.renderedContentNode = document.createDocumentFragment(); this.uniqueId = uniqueId("s_dynamic_snippet_"); this.templateKey = "website.s_dynamic_snippet.grid"; + this.isVisible = true; + this.isEditMode = false; } + async willStart() { return this.fetchData(); } + start() { this.registerCleanup(listenSizeChange(this.render.bind(this))); - // TODO Editor behavior. - // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive(); this.render(); - // TODO Editor behavior. - // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerActive(); } + destroy() { - // TODO Editor behavior. - // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive(); this.toggleVisibility(false); // Clear content. const templateAreaEl = this.el.querySelector(".dynamic_snippet_template"); // Nested interactions are stopped implicitly. templateAreaEl.replaceChildren(); - // TODO Editor behavior. - // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerActive(); } + /** - * Method to be overridden in child components if additional configuration elements - * are required in order to fetch data. + * To be overridden + * Check if additional configuration elements are required in order to fetch data. */ isConfigComplete() { return this.el.dataset.filterId !== undefined && this.el.dataset.templateKey !== undefined; } + /** - * Method to be overridden in child components in order to provide a search - * domain if needed. + * To be overridden + * Provide a search domain if needed. */ getSearchDomain() { return []; } + /** - * Method to be overridden in child components in order to add custom parameters if needed. + * To be overridden + * Add custom parameters if needed. */ getRpcParameters() { return {}; } - /** - * Fetches the data. - */ + async fetchData() { if (this.isConfigComplete()) { const nodeData = this.el.dataset; - const filterFragments = await rpc( + const filterFragments = await this.waitFor(rpc( "/website/snippet/filters", Object.assign({ - "filter_id": parseInt(nodeData.filterId), - "template_key": nodeData.templateKey, - "limit": parseInt(nodeData.numberOfRecords), - "search_domain": this.getSearchDomain(), - "with_sample": this.editableMode, - }, + "filter_id": parseInt(nodeData.filterId), + "template_key": nodeData.templateKey, + "limit": parseInt(nodeData.numberOfRecords), + "search_domain": this.getSearchDomain(), + "with_sample": this.isEditMode, + }, this.getRpcParameters(), JSON.parse(this.el.dataset?.customTemplateData || "{}") ) - ); + )); this.data = filterFragments.map(markup); } else { this.data = []; } } + /** - * Method to be overridden in child components in order to prepare content - * before rendering. + * To be overridden + * Prepare the content before rendering. */ prepareContent() { this.renderedContentNode = renderToFragment( @@ -107,9 +113,10 @@ export class DynamicSnippet extends Interaction { this.getQWebRenderOptions() ); } + /** - * Method to be overridden in child components in order to prepare QWeb - * options. + * To be overridden + * Prepare QWeb options. */ getQWebRenderOptions() { const dataset = this.el.dataset; @@ -129,12 +136,13 @@ export class DynamicSnippet extends Interaction { columnClasses: dataset.columnClasses || "", }; } + render() { - if (this.data.length > 0 || this.editableMode) { - this.el.classList.remove("o_dynamic_snippet_empty"); + if (this.data.length > 0 || this.isEditMode) { + this.isVisible = true; this.prepareContent(); } else { - this.el.classList.add("o_dynamic_snippet_empty"); + this.isVisible = false; this.renderedContentNode = document.createDocumentFragment(); } this.renderContent(); @@ -143,6 +151,7 @@ export class DynamicSnippet extends Interaction { // this.services["public.interactions"].startInteractions(childEl); // } } + renderContent() { const templateAreaEl = this.el.querySelector(".dynamic_snippet_template"); this.services["public.interactions"].stopInteractions(templateAreaEl); @@ -170,30 +179,28 @@ export class DynamicSnippet extends Interaction { delete carouselEl.dataset.bsInterval; } window.Carousel.getInstance(carouselEl)?.dispose(); - if (!this.editableMode) { + if (!this.isEditMode) { window.Carousel.getOrCreateInstance(carouselEl); } }); }, 0); } + /** - * - * @param visible + * @param {Boolean} visible */ toggleVisibility(visible) { - this.el.classList.toggle("o_dynamic_snippet_empty", !visible); + this.isVisible = visible; } + /** + * To be overriden * Returns the main URL of the module related to the active filter. */ getMainPageUrl() { return ""; } - //------------------------------------- ------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - /** * Navigates to the call to action url. */ @@ -202,4 +209,6 @@ export class DynamicSnippet extends Interaction { } } -registry.category("public.interactions").add("website.dynamic_snippet", DynamicSnippet); +registry + .category("public.interactions") + .add("website.dynamic_snippet", DynamicSnippet); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js index 3f6f8fa7fc088..86ec7c0863180 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js @@ -1,7 +1,7 @@ -import { registry } from "@web/core/registry"; -import { utils as uiUtils } from "@web/core/ui/ui_service"; import { DynamicSnippet } from "@website/snippets/s_dynamic_snippet/dynamic_snippet"; +import { registry } from "@web/core/registry"; +import { utils as uiUtils } from "@web/core/ui/ui_service"; export class DynamicSnippetCarousel extends DynamicSnippet { static selector = ".s_dynamic_snippet_carousel"; @@ -29,4 +29,6 @@ registry registry .category("public.interactions.edit") - .add("website.dynamic_snippet_carousel", { Interaction: DynamicSnippetCarousel }); + .add("website.dynamic_snippet_carousel", { + Interaction: DynamicSnippetCarousel, + }); diff --git a/addons/website/static/tests/interactions/snippets/chart.test.js b/addons/website/static/tests/interactions/snippets/chart.test.js index 067eec7c473a5..2599cc3e3fd37 100644 --- a/addons/website/static/tests/interactions/snippets/chart.test.js +++ b/addons/website/static/tests/interactions/snippets/chart.test.js @@ -1,37 +1,35 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { advanceTime } from "@odoo/hoot-mock"; -import { escape } from "@web/core/utils/strings"; - import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { advanceTime } from "@odoo/hoot-mock"; + +import { escape } from "@web/core/utils/strings"; + setupInteractionWhiteList("website.chart"); + describe.current.tags("interaction_dev"); -const getTemplate = function (options = {}) { - return ` -
+test("chart is started when there is an element .s_chart", async () => { + const { core, el } = await startInteractions(` +

A Chart Title

- ` -} - -test("chart is started when there is an element .s_chart", async () => { - const { core, el } = await startInteractions(getTemplate()); + `); expect(core.interactions.length).toBe(1); await advanceTime(0); const canvas = el.querySelector('canvas'); diff --git a/addons/website/static/tests/interactions/snippets/countdown.test.js b/addons/website/static/tests/interactions/snippets/countdown.test.js index 13674ff3e0cf5..975ca1174ab88 100644 --- a/addons/website/static/tests/interactions/snippets/countdown.test.js +++ b/addons/website/static/tests/interactions/snippets/countdown.test.js @@ -1,39 +1,43 @@ -import { describe, expect, test } from "@odoo/hoot"; +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; -import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { describe, expect, test } from "@odoo/hoot"; import { advanceTime } from "@odoo/hoot-mock"; setupInteractionWhiteList("website.countdown"); + describe.current.tags("interaction_dev"); const getTemplate = function (options = {}) { return ` -
-
-
-
+
+
+
+
+
-
-
-
+ +
` -} +}; const getCommonLength = function (data1, data2, data3) { const length1 = data1.length; @@ -44,7 +48,7 @@ const getCommonLength = function (data1, data2, data3) { } else { return 0; } -} +}; const wasDataChanged = function (data1, data2, l) { for (let i = 0; i < l; i++) { @@ -57,7 +61,7 @@ const wasDataChanged = function (data1, data2, l) { test("countdown is started when there is an element .s_countdown", async () => { const { core } = await startInteractions(getTemplate()); - expect(core.interactions.length).toBe(1); + expect(core.interactions).toHaveLength(1); }); /** diff --git a/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js b/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js index 3ad73ac3dd65f..68eda794bd5c7 100644 --- a/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js +++ b/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js @@ -1,23 +1,28 @@ -import { describe, expect, test } from "@odoo/hoot"; -import { onRpc } from "@web/../tests/web_test_helpers"; -import { registry } from "@web/core/registry"; -import { Interaction } from "@web/public/interaction"; import { startInteractions, setupInteractionWhiteList, } from "@web/../tests/public/helpers"; -class TestItem extends Interaction { - static selector = ".s_test_item"; +import { describe, expect, test } from "@odoo/hoot"; + +import { onRpc } from "@web/../tests/web_test_helpers"; + +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +class TestDynamicItem extends Interaction { + static selector = ".s_test_dynamic_item"; dynamicContent = { - "_root": { - "t-att-data-started": (el) => `*${el.dataset.testParam}*`, - }, + _root: { "t-att-data-started": (el) => `*${el.dataset.testParam}*` }, }; } -registry.category("public.interactions").add("website.test_dynamic_item", TestItem); + +registry + .category("public.interactions") + .add("website.test_dynamic_item", TestDynamicItem); setupInteractionWhiteList(["website.dynamic_snippet", "website.test_dynamic_item"]); + describe.current.tags("interaction_dev"); test("dynamic snippet loads items and displays them through template", async () => { @@ -25,53 +30,41 @@ test("dynamic snippet loads items and displays them through template", async () for await (const chunk of args.body) { const json = JSON.parse(new TextDecoder().decode(chunk)); expect(json.params.filter_id).toBe(1); - expect(json.params.template_key).toBe( - "website.dynamic_filter_template_test_item", - ); + expect(json.params.template_key).toBe("website.dynamic_filter_template_test_item"); expect(json.params.limit).toBe(16); expect(json.params.search_domain).toEqual([]); } return [ - ` -
- Some test record -
- `, - ` -
- Another test record -
- `, + `
Some test record
`, + `
Another test record
`, ]; }); const { core, el } = await startInteractions(` -
-
-
-
-
-
-
- Your Dynamic Snippet will be displayed here... This message is displayed because you did not provide both a filter and a template to use. -
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Your Dynamic Snippet will be displayed here... This message is displayed because you did not provide both a filter and a template to use. +
+
+
+
+
+
+
+
+
`); - expect(core.interactions.length).toBe(3); - const contentEl = el.querySelector(".dynamic_snippet_template"); - const itemEls = contentEl.querySelectorAll(".s_test_item"); + expect(core.interactions).toHaveLength(3); + const itemEls = el.querySelectorAll(".dynamic_snippet_template .s_test_dynamic_item"); expect(itemEls[0].dataset.testParam).toBe("test"); expect(itemEls[1].dataset.testParam).toBe("test2"); // Make sure element interactions are started. @@ -79,5 +72,5 @@ test("dynamic snippet loads items and displays them through template", async () expect(itemEls[1].dataset.started).toBe("*test2*"); core.stopInteractions(); // Make sure element interactions are stopped. - expect(core.interactions.length).toBe(0); + expect(core.interactions).toHaveLength(0); }); diff --git a/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js b/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js index e994f9521f457..647f2b92e4c45 100644 --- a/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js +++ b/addons/website/static/tests/interactions/snippets/dynamic_snippet_carousel.test.js @@ -1,27 +1,34 @@ +import { + startInteractions, + setupInteractionWhiteList, +} from "@web/../tests/public/helpers"; + import { describe, expect, test } from "@odoo/hoot"; import { animationFrame, click } from "@odoo/hoot-dom"; import { advanceTime } from "@odoo/hoot-mock"; -import { - onRpc, -} from "@web/../tests/web_test_helpers"; -import { registry } from "@web/core/registry"; + +import { onRpc } from "@web/../tests/web_test_helpers"; + import { Interaction } from "@web/public/interaction"; -import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { registry } from "@web/core/registry"; -class TestItem extends Interaction { - static selector = ".s_test_item"; +class TestDynamicCarouselItem extends Interaction { + static selector = ".s_test_dynamic_carousel_item"; dynamicContent = { "_root": { "t-att-data-started": (el) => `*${el.dataset.testParam}*`, }, }; } -registry.category("public.interactions").add("website.test_dynamic_carousel_item", TestItem); +registry + .category("public.interactions") + .add("website.test_dynamic_carousel_item", TestDynamicCarouselItem); setupInteractionWhiteList(["website.dynamic_snippet_carousel", "website.test_dynamic_carousel_item"]); + describe.current.tags("interaction_dev"); -const Template = ` +const testTemplate = `
- `, ` -
- Another test record -
- `, ` -
- Yet another test record -
- `, ` -
- Last test record of first page -
- `, ` -
- Test record in second page -
- `]; + return [``, + ``, + ``, + ``, + ``, + ]; }); - const { core, el } = await startInteractions(Template); - expect(core.interactions.length).toBe(6); + const { core, el } = await startInteractions(testTemplate); + expect(core.interactions).toHaveLength(6); const carouselEl = el.querySelector(".carousel"); // Neutralize carousel automatic sliding. carouselEl.dataset.bsRide = "false"; - const itemEls = carouselEl.querySelectorAll(".s_test_item"); - expect(itemEls[0].dataset.testParam).toBe("test"); + const itemEls = carouselEl.querySelectorAll(".s_test_dynamic_carousel_item"); + expect(itemEls[0].dataset.testParam).toBe("test1"); expect(itemEls[1].dataset.testParam).toBe("test2"); expect(itemEls[2].dataset.testParam).toBe("test3"); expect(itemEls[3].dataset.testParam).toBe("test4"); @@ -98,14 +90,14 @@ test.tags("desktop")("dynamic snippet carousel loads items and displays them thr expect(itemEls[3].closest(".carousel-item")).not.toHaveClass("active"); expect(itemEls[4].closest(".carousel-item")).toHaveClass("active"); // Make sure element interactions are started. - expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[0].dataset.started).toBe("*test1*"); expect(itemEls[1].dataset.started).toBe("*test2*"); expect(itemEls[2].dataset.started).toBe("*test3*"); expect(itemEls[3].dataset.started).toBe("*test4*"); expect(itemEls[4].dataset.started).toBe("*test5*"); core.stopInteractions(); // Make sure element interactions are stopped. - expect(core.interactions.length).toBe(0); + expect(core.interactions).toHaveLength(0); }); test.tags("mobile")("dynamic snippet carousel loads items and displays them through template (mobile)", async () => { @@ -117,35 +109,20 @@ test.tags("mobile")("dynamic snippet carousel loads items and displays them thro expect(json.params.limit).toBe(16); expect(json.params.search_domain).toEqual([]); } - return [` -
- Some test record -
- `, ` -
- Another test record -
- `, ` -
- Yet another test record -
- `, ` -
- Last test record of first page -
- `, ` -
- Test record in second page -
- `]; + return [``, + ``, + ``, + ``, + ``, + ]; }); - const { core, el } = await startInteractions(Template); - expect(core.interactions.length).toBe(6); + const { core, el } = await startInteractions(testTemplate); + expect(core.interactions).toHaveLength(6); const carouselEl = el.querySelector(".carousel"); // Neutralize carousel automatic sliding. carouselEl.dataset.bsRide = "false"; - const itemEls = carouselEl.querySelectorAll(".s_test_item"); - expect(itemEls[0].dataset.testParam).toBe("test"); + const itemEls = carouselEl.querySelectorAll(".s_test_dynamic_carousel_item"); + expect(itemEls[0].dataset.testParam).toBe("test1"); expect(itemEls[1].dataset.testParam).toBe("test2"); expect(itemEls[2].dataset.testParam).toBe("test3"); expect(itemEls[3].dataset.testParam).toBe("test4"); @@ -160,12 +137,12 @@ test.tags("mobile")("dynamic snippet carousel loads items and displays them thro expect(itemEls[0].closest(".carousel-item")).not.toHaveClass("active"); expect(itemEls[1].closest(".carousel-item")).toHaveClass("active"); // Make sure element interactions are started. - expect(itemEls[0].dataset.started).toBe("*test*"); + expect(itemEls[0].dataset.started).toBe("*test1*"); expect(itemEls[1].dataset.started).toBe("*test2*"); expect(itemEls[2].dataset.started).toBe("*test3*"); expect(itemEls[3].dataset.started).toBe("*test4*"); expect(itemEls[4].dataset.started).toBe("*test5*"); core.stopInteractions(); // Make sure element interactions are stopped. - expect(core.interactions.length).toBe(0); + expect(core.interactions).toHaveLength(0); }); diff --git a/addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js b/addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js index c560a701d4c12..9b4d5e1630342 100644 --- a/addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js +++ b/addons/website/static/tests/tour_utils/lifecycle_dep_interaction.js @@ -24,7 +24,9 @@ odoo.loader.bus.addEventListener("module-started", (e) => { static selector = ".s_countdown"; dynamicContent = { "_root": { - "t-att-class": () => ({ "interaction_started": true }), + "t-att-class": () => ({ + "interaction_started": true, + }), }, }; From e290abf2aab87b010d94a5077cd0b94838558fd1 Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Sat, 28 Dec 2024 20:03:12 +0100 Subject: [PATCH 115/150] last interactions (3/8) --- .../src/interactions/footer_slideout.js | 8 +- .../snippets/s_embed_code/embed_code.edit.js | 22 ++ .../src/snippets/s_embed_code/embed_code.js | 29 +- .../snippets/s_facebook_page/facebook_page.js | 16 +- .../s_faq_horizontal/faq_horizontal.js | 31 +- .../interactions/footer_slideout.test.js | 63 ++-- .../interactions/snippets/embed_code.test.js | 26 +- .../snippets/faq_horizontal.test.js | 276 +++++++++--------- 8 files changed, 236 insertions(+), 235 deletions(-) create mode 100644 addons/website/static/src/snippets/s_embed_code/embed_code.edit.js diff --git a/addons/website/static/src/interactions/footer_slideout.js b/addons/website/static/src/interactions/footer_slideout.js index 9b02fdd88bd27..589c6f6f0bbcc 100644 --- a/addons/website/static/src/interactions/footer_slideout.js +++ b/addons/website/static/src/interactions/footer_slideout.js @@ -1,12 +1,11 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; export class FooterSlideout extends Interaction { static selector = "#wrapwrap"; static selectorHas = ".o_footer_slideout"; - dynamicContent = { - "_root": { + _root: { "t-att-class": () => ({ "o_footer_effect_enable": this.slideoutEffect, }), @@ -14,8 +13,7 @@ export class FooterSlideout extends Interaction { }; setup() { - const mainEl = this.el.querySelector(":scope > main"); - this.slideoutEffect = mainEl.offsetHeight >= window.innerHeight; + this.slideoutEffect = this.el.querySelector(":scope > main").offsetHeight >= window.innerHeight; } start() { diff --git a/addons/website/static/src/snippets/s_embed_code/embed_code.edit.js b/addons/website/static/src/snippets/s_embed_code/embed_code.edit.js new file mode 100644 index 0000000000000..606815eeecfdb --- /dev/null +++ b/addons/website/static/src/snippets/s_embed_code/embed_code.edit.js @@ -0,0 +1,22 @@ +import { EmbedCode } from "./embed_code"; +import { registry } from "@web/core/registry"; + +const EmbedCodeEdit = I => class extends I { + start() { + if (this.embedCodeEl.offsetHeight === 0) { + // Shows a placeholder message in edit mode to be able to select + // the snippet if it's visually empty. + const placeholderEl = document.createElement("div"); + placeholderEl.classList.add("s_embed_code_placeholder", "alert", "alert-info", "pt16", "pb16"); + placeholderEl.textContent = _t("Your Embed Code snippet doesn't have anything to display. Click on Edit to modify it."); + this.embedCodeEl.appendChild(placeholderEl); + } + } +}; + +registry + .category("public.interactions.edit") + .add("website.embed_code", { + Interaction: EmbedCode, + mixin: EmbedCodeEdit, + }); diff --git a/addons/website/static/src/snippets/s_embed_code/embed_code.js b/addons/website/static/src/snippets/s_embed_code/embed_code.js index abbc0b33a27d0..091822cae2594 100644 --- a/addons/website/static/src/snippets/s_embed_code/embed_code.js +++ b/addons/website/static/src/snippets/s_embed_code/embed_code.js @@ -1,41 +1,26 @@ -import { registry } from "@web/core/registry"; import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + import { _t } from "@web/core/l10n/translation"; import { cloneContentEls } from "@website/js/utils"; export class EmbedCode extends Interaction { static selector = ".s_embed_code"; - // TODO Support edit mode. - disabledInEditableMode = false; setup() { this.embedCodeEl = this.el.querySelector(".s_embed_code_embedded"); } - start() { - // TODO Support edit mode. - if (this.editableMode && this.embedCodeEl.offsetHeight === 0) { - // Shows a placeholder message in edit mode to be able to select - // the snippet if it's visually empty. - const placeholderEl = document.createElement("div"); - placeholderEl.classList - .add("s_embed_code_placeholder", "alert", "alert-info", "pt16", "pb16"); - placeholderEl.textContent = _t("Your Embed Code snippet doesn't have anything to display. Click on Edit to modify it."); - this.embedCodeEl.appendChild(placeholderEl); - } - } - destroy() { // Just before entering edit mode, reinitialize the snippet's content, // without +
+
+
+
+
+ +
+
+ +
+ +
+
+

Communication history

+
+
+
+
+ + + +
+ + +
+
+ +
+ +
+ `); + expect(core.interactions).toHaveLength(1); + const modalEl = el.querySelector("#pay_with"); + expect(modalEl).not.toBe(undefined); + await advanceTime(400); + expect(modalEl).not.toHaveStyle({ "display": "none" }); +}); diff --git a/addons/portal/static/src/interactions/portal_sidebar.js b/addons/portal/static/src/interactions/portal_sidebar.js index 05e747cb97db9..b8978acd60372 100644 --- a/addons/portal/static/src/interactions/portal_sidebar.js +++ b/addons/portal/static/src/interactions/portal_sidebar.js @@ -26,11 +26,11 @@ export class PortalSidebar extends Interaction { const today = DateTime.now().startOf("day"); const diff = dateTime.diff(today).as("days"); if (diff === 0) { - this.el.innerText = _t('Due today'); + timeagoEl.innerText = _t('Due today'); } else if (diff > 0) { - this.el.innerText = _t('Due in %s days', Math.abs(diff).toFixed()); + timeagoEl.innerText = _t('Due in %s days', Math.abs(diff).toFixed()); } else { - this.el.innerText = _t('%s days overdue', Math.abs(diff).toFixed()); + timeagoEl.innerText = _t('%s days overdue', Math.abs(diff).toFixed()); } } } From c34b0b8ef70a244d319efc2352663a6f59cbaa8c Mon Sep 17 00:00:00 2001 From: Romaric MOYEUVRE Date: Mon, 6 Jan 2025 12:39:58 +0100 Subject: [PATCH 150/150] [...]PortalSidebar -> [...]Sidebar --- .../{account_portal_sidebar.js => account_sidebar.js} | 6 +++--- .../src/interactions/{portal_sidebar.js => sidebar.js} | 2 +- .../{purchase_portal_sidebar.js => purchase_sidebar.js} | 6 +++--- .../{sale_portal_sidebar.js => sale_sidebar.js} | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) rename addons/account/static/src/interactions/{account_portal_sidebar.js => account_sidebar.js} (90%) rename addons/portal/static/src/interactions/{portal_sidebar.js => sidebar.js} (97%) rename addons/purchase/static/src/interactions/{purchase_portal_sidebar.js => purchase_sidebar.js} (94%) rename addons/sale/static/src/interactions/{sale_portal_sidebar.js => sale_sidebar.js} (95%) diff --git a/addons/account/static/src/interactions/account_portal_sidebar.js b/addons/account/static/src/interactions/account_sidebar.js similarity index 90% rename from addons/account/static/src/interactions/account_portal_sidebar.js rename to addons/account/static/src/interactions/account_sidebar.js index 48da6c49550a0..38d3005f5f8c5 100644 --- a/addons/account/static/src/interactions/account_portal_sidebar.js +++ b/addons/account/static/src/interactions/account_sidebar.js @@ -1,9 +1,9 @@ -import { PortalSidebar } from "@portal/interactions/portal_sidebar"; +import { Sidebar } from "@portal/interactions/portal_sidebar"; import { registry } from "@web/core/registry"; import { scrollTo } from "@web/core/utils/scrolling"; -export class AccountPortalSidebar extends PortalSidebar { +export class AccountSidebar extends Sidebar { static selector = ".o_portal_invoice_sidebar"; dynamicContent = { _window: { "t-on-resize": this.updateIframeSize }, @@ -55,4 +55,4 @@ export class AccountPortalSidebar extends PortalSidebar { registry .category("public.interactions") - .add("account.account_portal_sidebar", AccountPortalSidebar); + .add("account.account_sidebar", AccountSidebar); diff --git a/addons/portal/static/src/interactions/portal_sidebar.js b/addons/portal/static/src/interactions/sidebar.js similarity index 97% rename from addons/portal/static/src/interactions/portal_sidebar.js rename to addons/portal/static/src/interactions/sidebar.js index b8978acd60372..761c16249ea9a 100644 --- a/addons/portal/static/src/interactions/portal_sidebar.js +++ b/addons/portal/static/src/interactions/sidebar.js @@ -5,7 +5,7 @@ import { deserializeDateTime } from "@web/core/l10n/dates"; const { DateTime } = luxon; -export class PortalSidebar extends Interaction { +export class Sidebar extends Interaction { setup() { this.printContent = undefined; diff --git a/addons/purchase/static/src/interactions/purchase_portal_sidebar.js b/addons/purchase/static/src/interactions/purchase_sidebar.js similarity index 94% rename from addons/purchase/static/src/interactions/purchase_portal_sidebar.js rename to addons/purchase/static/src/interactions/purchase_sidebar.js index 46e1cb656034f..758cd090e6b13 100644 --- a/addons/purchase/static/src/interactions/purchase_portal_sidebar.js +++ b/addons/purchase/static/src/interactions/purchase_sidebar.js @@ -1,9 +1,9 @@ -import { PortalSidebar } from "@portal/interactions/portal_sidebar"; +import { Sidebar } from "@portal/interactions/portal_sidebar"; import { registry } from "@web/core/registry"; import { uniqueId } from "@web/core/utils/functions"; -export class PurchasePortalSidebar extends PortalSidebar { +export class PurchaseSidebar extends Sidebar { static selector = ".o_portal_purchase_sidebar"; setup() { @@ -111,4 +111,4 @@ export class PurchasePortalSidebar extends PortalSidebar { registry .category("public.interactions") - .add("purchase.purchase_portal_sidebar", PurchasePortalSidebar); + .add("purchase.purchase_sidebar", PurchaseSidebar); diff --git a/addons/sale/static/src/interactions/sale_portal_sidebar.js b/addons/sale/static/src/interactions/sale_sidebar.js similarity index 95% rename from addons/sale/static/src/interactions/sale_portal_sidebar.js rename to addons/sale/static/src/interactions/sale_sidebar.js index e29d75e3a2c3e..c9e73720eb17f 100644 --- a/addons/sale/static/src/interactions/sale_portal_sidebar.js +++ b/addons/sale/static/src/interactions/sale_sidebar.js @@ -1,9 +1,9 @@ -import { PortalSidebar } from "@portal/interactions/portal_sidebar"; +import { Sidebar } from "@portal/interactions/portal_sidebar"; import { registry } from "@web/core/registry"; import { uniqueId } from "@web/core/utils/functions"; -export class SalePortalSidebar extends PortalSidebar { +export class SaleSidebar extends Sidebar { static selector = ".o_portal_sale_sidebar"; setup() { @@ -108,4 +108,4 @@ export class SalePortalSidebar extends PortalSidebar { registry .category("public.interactions") - .add("sale.sale_portal_sidebar", SalePortalSidebar); + .add("sale.sale_sidebar", SaleSidebar);