1135 lines
35 KiB
JavaScript
1135 lines
35 KiB
JavaScript
/** @odoo-module alias=@web/../tests/helpers/utils default=false */
|
|
|
|
import { getTemplate } from "@web/core/templates";
|
|
import { browser } from "@web/core/browser/browser";
|
|
import { isMacOS } from "@web/core/browser/feature_detection";
|
|
import { download } from "@web/core/network/download";
|
|
import { getPopoverForTarget } from "@web/core/popover/popover";
|
|
import { Deferred } from "@web/core/utils/concurrency";
|
|
import { patch } from "@web/core/utils/patch";
|
|
import { isVisible } from "@web/core/utils/ui";
|
|
import { _t } from "@web/core/l10n/translation";
|
|
import { registerCleanup } from "./cleanup";
|
|
|
|
import {
|
|
App,
|
|
onError,
|
|
onMounted,
|
|
onPatched,
|
|
onRendered,
|
|
onWillDestroy,
|
|
onWillPatch,
|
|
onWillRender,
|
|
onWillStart,
|
|
onWillUnmount,
|
|
onWillUpdateProps,
|
|
useComponent,
|
|
} from "@odoo/owl";
|
|
|
|
/**
|
|
* @typedef {keyof HTMLElementEventMap | keyof WindowEventMap} EventType
|
|
*
|
|
* @typedef {Side | `${Side}-${Side}` | { x?: number, y?: number }} Position
|
|
*
|
|
* @typedef {"bottom" | "left" | "right" | "top"} Side
|
|
*
|
|
* @typedef TriggerEventOptions
|
|
* @property {boolean} [skipVisibilityCheck=false]
|
|
* @property {boolean} [sync=false]
|
|
*/
|
|
|
|
/**
|
|
* Patch the native Date object
|
|
*
|
|
* Note that it will be automatically unpatched at the end of the test
|
|
*
|
|
* @param {number} [year]
|
|
* @param {number} [month]
|
|
* @param {number} [day]
|
|
* @param {number} [hours]
|
|
* @param {number} [minutes]
|
|
* @param {number} [seconds]
|
|
* @param {number} [ms=0]
|
|
*/
|
|
export function patchDate(year, month, day, hours, minutes, seconds, ms = 0) {
|
|
var RealDate = window.Date;
|
|
var actualDate = new RealDate();
|
|
|
|
// By default, RealDate uses the browser offset, so we must replace it with the offset fixed in luxon.
|
|
var fakeDate = new RealDate(year, month, day, hours, minutes, seconds, ms);
|
|
if (!(luxon.Settings.defaultZone instanceof luxon.FixedOffsetZone)) {
|
|
throw new Error("luxon.Settings.defaultZone must be a FixedOffsetZone");
|
|
}
|
|
const browserOffset = -fakeDate.getTimezoneOffset();
|
|
const patchedOffset = luxon.Settings.defaultZone.offset();
|
|
const offsetDiff = patchedOffset - browserOffset;
|
|
const correctedMinutes = fakeDate.getMinutes() - offsetDiff;
|
|
fakeDate.setMinutes(correctedMinutes);
|
|
|
|
var timeInterval = actualDate.getTime() - fakeDate.getTime();
|
|
|
|
// eslint-disable-next-line no-global-assign
|
|
window.Date = (function (NativeDate) {
|
|
function Date(Y, M, D, h, m, s, ms) {
|
|
var length = arguments.length;
|
|
let date;
|
|
if (arguments.length > 0) {
|
|
date =
|
|
length == 1 && String(Y) === Y // isString(Y)
|
|
? // We explicitly pass it through parse:
|
|
new NativeDate(Date.parse(Y))
|
|
: // We have to manually make calls depending on argument
|
|
// length here
|
|
length >= 7
|
|
? new NativeDate(Y, M, D, h, m, s, ms)
|
|
: length >= 6
|
|
? new NativeDate(Y, M, D, h, m, s)
|
|
: length >= 5
|
|
? new NativeDate(Y, M, D, h, m)
|
|
: length >= 4
|
|
? new NativeDate(Y, M, D, h)
|
|
: length >= 3
|
|
? new NativeDate(Y, M, D)
|
|
: length >= 2
|
|
? new NativeDate(Y, M)
|
|
: length >= 1
|
|
? new NativeDate(Y)
|
|
: new NativeDate();
|
|
// Prevent mixups with unfixed Date object
|
|
date.constructor = Date;
|
|
return date;
|
|
} else {
|
|
date = new NativeDate();
|
|
var time = date.getTime();
|
|
time -= timeInterval;
|
|
date.setTime(time);
|
|
return date;
|
|
}
|
|
}
|
|
|
|
// Copy any custom methods a 3rd party library may have added
|
|
for (var key in NativeDate) {
|
|
Date[key] = NativeDate[key];
|
|
}
|
|
|
|
// Copy "native" methods explicitly; they may be non-enumerable
|
|
// exception: 'now' uses fake date as reference
|
|
Date.now = function () {
|
|
var date = new NativeDate();
|
|
var time = date.getTime();
|
|
time -= timeInterval;
|
|
return time;
|
|
};
|
|
Date.UTC = NativeDate.UTC;
|
|
Date.prototype = NativeDate.prototype;
|
|
Date.prototype.constructor = Date;
|
|
|
|
// Upgrade Date.parse to handle simplified ISO 8601 strings
|
|
Date.parse = NativeDate.parse;
|
|
return Date;
|
|
})(Date);
|
|
|
|
registerCleanup(() => {
|
|
window.Date = RealDate;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Applies a fixed time zone to luxon based on an offset to the UTC time zone.
|
|
*
|
|
* @param {number} offset the number of minutes ahead or behind the UTC time zone
|
|
* +120 => UTC+2
|
|
* -120 => UTC-2
|
|
*/
|
|
export function patchTimeZone(offset) {
|
|
patchWithCleanup(luxon.Settings, { defaultZone: luxon.FixedOffsetZone.instance(offset) });
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Object} obj object to patch
|
|
* @param {Object} patchValue the actual patch description
|
|
*/
|
|
export function patchWithCleanup(obj, patchValue) {
|
|
const unpatch = patch(obj, patchValue);
|
|
registerCleanup(() => {
|
|
unpatch();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @returns {Element}
|
|
*/
|
|
export function getFixture() {
|
|
if (!window.QUnit) {
|
|
return document;
|
|
}
|
|
if (QUnit.config.debug) {
|
|
return document.body;
|
|
} else {
|
|
return document.getElementById("qunit-fixture");
|
|
}
|
|
}
|
|
|
|
export async function nextTick() {
|
|
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
|
await new Promise((resolve) => setTimeout(resolve));
|
|
}
|
|
|
|
export function makeDeferred() {
|
|
return new Deferred();
|
|
}
|
|
|
|
export function findElement(el, selector) {
|
|
let target = el;
|
|
if (selector) {
|
|
const els = el.querySelectorAll(selector);
|
|
if (els.length === 0) {
|
|
throw new Error(`No element found (selector: ${selector})`);
|
|
}
|
|
if (els.length > 1) {
|
|
throw new Error(`Found ${els.length} elements, instead of 1 (selector: ${selector})`);
|
|
}
|
|
target = els[0];
|
|
}
|
|
return target;
|
|
}
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Event init attributes mappers
|
|
//-----------------------------------------------------------------------------
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapBubblingEvent = (args) => ({ ...args, bubbles: true });
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapNonBubblingEvent = (args) => ({ ...args, bubbles: false });
|
|
|
|
/** @param {EventInit} [args={}] */
|
|
const mapBubblingPointerEvent = (args = {}) => ({
|
|
clientX: args.pageX,
|
|
clientY: args.pageY,
|
|
...args,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
view: window,
|
|
});
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapNonBubblingPointerEvent = (args) => ({
|
|
...mapBubblingPointerEvent(args),
|
|
bubbles: false,
|
|
cancelable: false,
|
|
});
|
|
|
|
/** @param {EventInit} [args={}] */
|
|
const mapCancelableTouchEvent = (args = {}) => ({
|
|
...args,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
rotation: 0.0,
|
|
touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined,
|
|
view: window,
|
|
zoom: 1.0,
|
|
});
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapNonCancelableTouchEvent = (args) => ({
|
|
...mapCancelableTouchEvent(args),
|
|
cancelable: false,
|
|
});
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapKeyboardEvent = (args) => ({
|
|
...args,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
|
|
/**
|
|
* @template {typeof Event} T
|
|
* @param {EventType} eventType
|
|
* @returns {[T, (attrs: EventInit) => EventInit]}
|
|
*/
|
|
const getEventConstructor = (eventType) => {
|
|
switch (eventType) {
|
|
// Mouse events
|
|
case "auxclick":
|
|
case "click":
|
|
case "contextmenu":
|
|
case "dblclick":
|
|
case "mousedown":
|
|
case "mouseup":
|
|
case "mousemove":
|
|
case "mouseover":
|
|
case "mouseout": {
|
|
return [MouseEvent, mapBubblingPointerEvent];
|
|
}
|
|
case "mouseenter":
|
|
case "mouseleave": {
|
|
return [MouseEvent, mapNonBubblingPointerEvent];
|
|
}
|
|
// Pointer events
|
|
case "pointerdown":
|
|
case "pointerup":
|
|
case "pointermove":
|
|
case "pointerover":
|
|
case "pointerout": {
|
|
return [PointerEvent, mapBubblingPointerEvent];
|
|
}
|
|
case "pointerenter":
|
|
case "pointerleave": {
|
|
return [PointerEvent, mapNonBubblingPointerEvent];
|
|
}
|
|
// Focus events
|
|
case "focusin": {
|
|
return [FocusEvent, mapBubblingEvent];
|
|
}
|
|
case "focus":
|
|
case "blur": {
|
|
return [FocusEvent, mapNonBubblingEvent];
|
|
}
|
|
// Clipboard events
|
|
case "cut":
|
|
case "copy":
|
|
case "paste": {
|
|
return [ClipboardEvent, mapBubblingEvent];
|
|
}
|
|
// Keyboard events
|
|
case "keydown":
|
|
case "keypress":
|
|
case "keyup": {
|
|
return [KeyboardEvent, mapKeyboardEvent];
|
|
}
|
|
// Drag events
|
|
case "drag":
|
|
case "dragend":
|
|
case "dragenter":
|
|
case "dragstart":
|
|
case "dragleave":
|
|
case "dragover":
|
|
case "drop": {
|
|
return [DragEvent, mapBubblingEvent];
|
|
}
|
|
// Input events
|
|
case "input": {
|
|
return [InputEvent, mapBubblingEvent];
|
|
}
|
|
// Composition events
|
|
case "compositionstart":
|
|
case "compositionend": {
|
|
return [CompositionEvent, mapBubblingEvent];
|
|
}
|
|
// UI events
|
|
case "scroll": {
|
|
return [UIEvent, mapNonBubblingEvent];
|
|
}
|
|
// Touch events
|
|
case "touchstart":
|
|
case "touchend":
|
|
case "touchmove": {
|
|
return [TouchEvent, mapCancelableTouchEvent];
|
|
}
|
|
case "touchcancel": {
|
|
return [TouchEvent, mapNonCancelableTouchEvent];
|
|
}
|
|
// Default: base Event constructor
|
|
default: {
|
|
return [Event, mapBubblingEvent];
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @template {EventType} T
|
|
* @param {Element} el
|
|
* @param {string | null | undefined | false} selector
|
|
* @param {T} eventType
|
|
* @param {EventInit} [eventInit]
|
|
* @param {TriggerEventOptions} [options={}]
|
|
* @returns {GlobalEventHandlersEventMap[T] | Promise<GlobalEventHandlersEventMap[T]>}
|
|
*/
|
|
export function triggerEvent(el, selector, eventType, eventInit, options = {}) {
|
|
const errors = [];
|
|
const target = findElement(el, selector);
|
|
|
|
// Error handling
|
|
if (typeof eventType !== "string") {
|
|
errors.push("event type must be a string");
|
|
}
|
|
if (!target) {
|
|
errors.push("cannot find target");
|
|
} else if (!options.skipVisibilityCheck && !isVisible(target)) {
|
|
errors.push("target is not visible");
|
|
}
|
|
if (errors.length) {
|
|
throw new Error(
|
|
`Cannot trigger event${eventType ? ` "${eventType}"` : ""}${
|
|
selector ? ` (with selector "${selector}")` : ""
|
|
}: ${errors.join(" and ")}`
|
|
);
|
|
}
|
|
|
|
// Actual dispatch
|
|
const [Constructor, processParams] = getEventConstructor(eventType);
|
|
const event = new Constructor(eventType, processParams(eventInit));
|
|
target.dispatchEvent(event);
|
|
|
|
if (window.QUnit && QUnit.config.debug) {
|
|
const group = `%c[${event.type.toUpperCase()}]`;
|
|
console.groupCollapsed(group, "color: #b52c9b");
|
|
console.log(target, event);
|
|
console.groupEnd(group, "color: #b52c9b");
|
|
}
|
|
|
|
if (options.sync) {
|
|
return event;
|
|
} else {
|
|
return nextTick().then(() => event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Element} el
|
|
* @param {string | null | undefined | false} selector
|
|
* @param {(EventType | [EventType, EventInit])[]} [eventDefs]
|
|
* @param {TriggerEventOptions} [options={}]
|
|
*/
|
|
export function triggerEvents(el, selector, eventDefs, options = {}) {
|
|
const events = [...eventDefs].map((eventDef) => {
|
|
const [eventType, eventInit] = Array.isArray(eventDef) ? eventDef : [eventDef, {}];
|
|
return triggerEvent(el, selector, eventType, eventInit, options);
|
|
});
|
|
if (options.sync) {
|
|
return events;
|
|
} else {
|
|
return nextTick().then(() => events);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggers a scroll event on the given target
|
|
*
|
|
* If the target cannot be scrolled or an axis has reached
|
|
* the end of the scrollable area, the event can be transmitted
|
|
* to its nearest parent until it can be triggered
|
|
*
|
|
* @param {Element} target target of the scroll event
|
|
* @param {Object} coordinates
|
|
* @param {number} coordinates.left coordinates to scroll horizontally
|
|
* @param {number} coordinates.top coordinates to scroll vertically
|
|
* @param {boolean} canPropagate states if the scroll can propagate to a scrollable parent
|
|
*/
|
|
export async function triggerScroll(
|
|
target,
|
|
coordinates = { left: null, top: null },
|
|
canPropagate = true
|
|
) {
|
|
const isScrollable =
|
|
(target.scrollHeight > target.clientHeight && target.clientHeight > 0) ||
|
|
(target.scrollWidth > target.clientWidth && target.clientWidth > 0);
|
|
if (!isScrollable && !canPropagate) {
|
|
return;
|
|
}
|
|
if (isScrollable) {
|
|
const canScrollFrom = {
|
|
left:
|
|
coordinates.left > target.scrollLeft
|
|
? target.scrollLeft + target.clientWidth < target.scrollWidth
|
|
: target.scrollLeft > 0,
|
|
top:
|
|
coordinates.top > target.scrollTop
|
|
? target.scrollTop + target.clientHeight < target.scrollHeight
|
|
: target.scrollTop > 0,
|
|
};
|
|
const scrollCoordinates = {};
|
|
Object.entries(coordinates).forEach(([key, value]) => {
|
|
if (value !== null && canScrollFrom[key]) {
|
|
scrollCoordinates[key] = value;
|
|
delete coordinates[key];
|
|
}
|
|
});
|
|
target.scrollTo(scrollCoordinates);
|
|
await triggerEvent(target, null, "scroll");
|
|
if (!canPropagate || !Object.entries(coordinates).length) {
|
|
return;
|
|
}
|
|
}
|
|
target.parentElement
|
|
? triggerScroll(target.parentElement, coordinates)
|
|
: triggerEvent(window, null, "scroll");
|
|
await nextTick();
|
|
}
|
|
|
|
export function click(
|
|
el,
|
|
selector,
|
|
{ mouseEventInit = {}, skipDisabledCheck = false, skipVisibilityCheck = false } = {}
|
|
) {
|
|
if (!skipDisabledCheck && el.disabled) {
|
|
throw new Error("Can't click on a disabled button");
|
|
}
|
|
return triggerEvents(
|
|
el,
|
|
selector,
|
|
[
|
|
"pointerdown",
|
|
"mousedown",
|
|
"focus",
|
|
"focusin",
|
|
"pointerup",
|
|
"mouseup",
|
|
["click", mouseEventInit],
|
|
],
|
|
{ skipVisibilityCheck }
|
|
);
|
|
}
|
|
|
|
export function clickCreate(htmlElement) {
|
|
if (
|
|
htmlElement.querySelectorAll(
|
|
".o_control_panel_main_buttons .o_form_button_create"
|
|
).length
|
|
) {
|
|
return click(
|
|
htmlElement,
|
|
".o_control_panel_main_buttons .o_form_button_create"
|
|
);
|
|
} else if (
|
|
htmlElement.querySelectorAll(
|
|
".o_control_panel_main_buttons .o_list_button_create"
|
|
).length
|
|
) {
|
|
return click(
|
|
htmlElement,
|
|
".o_control_panel_main_buttons .o_list_button_create"
|
|
);
|
|
} else {
|
|
throw new Error("No edit button found to be clicked.");
|
|
}
|
|
}
|
|
|
|
export function clickEdit(htmlElement) {
|
|
if (htmlElement.querySelectorAll(".o_list_button_edit").length) {
|
|
return click(htmlElement, ".o_list_button_edit");
|
|
} else {
|
|
throw new Error("No edit button found to be clicked.");
|
|
}
|
|
}
|
|
|
|
export async function clickSave(htmlElement) {
|
|
if (htmlElement.querySelectorAll(".o_form_status_indicator").length) {
|
|
await mouseEnter(htmlElement, ".o_form_status_indicator");
|
|
}
|
|
if (htmlElement.querySelectorAll(".o_form_button_save").length) {
|
|
return click(htmlElement, ".o_form_button_save");
|
|
}
|
|
const listSaveButtons = htmlElement.querySelectorAll(".o_list_button_save");
|
|
if (listSaveButtons.length) {
|
|
return listSaveButtons.length >= 2 ? click(listSaveButtons[1]) : click(listSaveButtons[0]);
|
|
} else {
|
|
throw new Error("No save button found to be clicked.");
|
|
}
|
|
}
|
|
|
|
export async function clickDiscard(htmlElement) {
|
|
if (htmlElement.querySelectorAll(".o_form_status_indicator").length) {
|
|
await mouseEnter(htmlElement, ".o_form_status_indicator");
|
|
}
|
|
if (htmlElement.querySelectorAll(".o_form_button_cancel").length) {
|
|
return click(htmlElement, ".o_form_button_cancel");
|
|
} else if ($(htmlElement).find(".o_list_button_discard:visible").length) {
|
|
return click($(htmlElement).find(".o_list_button_discard:visible").get(0));
|
|
} else {
|
|
throw new Error("No discard button found to be clicked.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger pointerenter and mouseenter events on the given target. If no
|
|
* coordinates are given, the event is located by default
|
|
* in the middle of the target to simplify the test process
|
|
*
|
|
* @param {Element} el
|
|
* @param {string} selector
|
|
* @param {Object} coordinates position of the mouseenter event
|
|
*/
|
|
export async function mouseEnter(el, selector, coordinates) {
|
|
const target = el.querySelector(selector) || el;
|
|
const atPos = coordinates || {
|
|
clientX: target.getBoundingClientRect().left + target.getBoundingClientRect().width / 2,
|
|
clientY: target.getBoundingClientRect().top + target.getBoundingClientRect().height / 2,
|
|
};
|
|
return triggerEvents(target, null, ["pointerenter", "mouseenter"], atPos);
|
|
}
|
|
|
|
/**
|
|
* Trigger pointerleave and mouseleave events on the given target.
|
|
*
|
|
* @param {Element} el
|
|
* @param {string} selector
|
|
*/
|
|
export async function mouseLeave(el, selector) {
|
|
const target = el.querySelector(selector) || el;
|
|
return triggerEvents(target, null, ["pointerleave", "mouseleave"]);
|
|
}
|
|
|
|
export async function editInput(el, selector, value) {
|
|
const input = findElement(el, selector);
|
|
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) {
|
|
throw new Error("Only 'input' and 'textarea' elements can be edited with 'editInput'.");
|
|
}
|
|
if (
|
|
!["text", "textarea", "email", "search", "color", "number", "file", "tel"].includes(
|
|
input.type
|
|
)
|
|
) {
|
|
throw new Error(`Type "${input.type}" not supported by 'editInput'.`);
|
|
}
|
|
|
|
const eventOpts = {};
|
|
if (input.type === "file") {
|
|
const files = Array.isArray(value) ? value : [value];
|
|
const dataTransfer = new DataTransfer();
|
|
for (const file of files) {
|
|
if (!(file instanceof File)) {
|
|
throw new Error(`File input value should be one or several File objects.`);
|
|
}
|
|
dataTransfer.items.add(file);
|
|
}
|
|
input.files = dataTransfer.files;
|
|
eventOpts.skipVisibilityCheck = true;
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
|
|
await triggerEvents(input, null, ["input", "change"], eventOpts);
|
|
|
|
if (input.type === "file") {
|
|
// Need to wait for the file to be loaded by the input
|
|
await nextTick();
|
|
await nextTick();
|
|
}
|
|
}
|
|
|
|
export function editSelect(el, selector, value) {
|
|
const select = findElement(el, selector);
|
|
if (select.tagName !== "SELECT") {
|
|
throw new Error("Only select tag can be edited with selectInput.");
|
|
}
|
|
select.value = value;
|
|
return triggerEvent(select, null, "change");
|
|
}
|
|
|
|
export async function editSelectMenu(el, selector, value) {
|
|
const dropdown = el.querySelector(selector);
|
|
await click(dropdown.querySelector(".dropdown-toggle"));
|
|
for (const item of Array.from(el.querySelectorAll(".o_select_menu_menu .dropdown-item"))) {
|
|
if (item.textContent === value) {
|
|
return click(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggers an hotkey properly disregarding the operating system.
|
|
*
|
|
* @param {string} hotkey
|
|
* @param {boolean} addOverlayModParts
|
|
* @param {KeyboardEventInit} eventAttrs
|
|
*/
|
|
export async function triggerHotkey(hotkey, addOverlayModParts = false, eventAttrs = {}) {
|
|
eventAttrs.key = hotkey.split("+").pop();
|
|
|
|
if (/shift/i.test(hotkey)) {
|
|
eventAttrs.shiftKey = true;
|
|
}
|
|
|
|
if (/control/i.test(hotkey)) {
|
|
if (isMacOS()) {
|
|
eventAttrs.metaKey = true;
|
|
} else {
|
|
eventAttrs.ctrlKey = true;
|
|
}
|
|
}
|
|
|
|
if (/alt/i.test(hotkey) || addOverlayModParts) {
|
|
if (isMacOS()) {
|
|
eventAttrs.ctrlKey = true;
|
|
} else {
|
|
eventAttrs.altKey = true;
|
|
}
|
|
}
|
|
|
|
if (!("bubbles" in eventAttrs)) {
|
|
eventAttrs.bubbles = true;
|
|
}
|
|
|
|
const [keydownEvent, keyupEvent] = await triggerEvents(
|
|
document.activeElement,
|
|
null,
|
|
[
|
|
["keydown", eventAttrs],
|
|
["keyup", eventAttrs],
|
|
],
|
|
{ skipVisibilityCheck: true }
|
|
);
|
|
|
|
return { keydownEvent, keyupEvent };
|
|
}
|
|
|
|
export function mockDownload(cb) {
|
|
patchWithCleanup(download, { _download: cb });
|
|
}
|
|
|
|
export const hushConsole = Object.create(null);
|
|
for (const propName of Object.keys(window.console)) {
|
|
hushConsole[propName] = () => {};
|
|
}
|
|
|
|
export function mockSendBeacon(mock) {
|
|
patchWithCleanup(navigator, {
|
|
sendBeacon: (url, blob) => {
|
|
return mock(url, blob) !== false;
|
|
},
|
|
});
|
|
}
|
|
|
|
export function mockTimeout() {
|
|
const timeouts = new Map();
|
|
let currentTime = 0;
|
|
let id = 1;
|
|
patchWithCleanup(browser, {
|
|
setTimeout(fn, delay = 0) {
|
|
timeouts.set(id, { fn, scheduledFor: delay + currentTime, id });
|
|
return id++;
|
|
},
|
|
clearTimeout(id) {
|
|
timeouts.delete(id);
|
|
},
|
|
});
|
|
return {
|
|
execRegisteredTimeouts() {
|
|
for (const { fn } of timeouts.values()) {
|
|
fn();
|
|
}
|
|
timeouts.clear();
|
|
},
|
|
async advanceTime(duration) {
|
|
// wait here so all microtasktick scheduled in this frame can be
|
|
// executed and possibly register their own timeout
|
|
await nextTick();
|
|
currentTime += duration;
|
|
for (const { fn, scheduledFor, id } of timeouts.values()) {
|
|
if (scheduledFor <= currentTime) {
|
|
fn();
|
|
timeouts.delete(id);
|
|
}
|
|
}
|
|
// wait here to make sure owl can update the UI
|
|
await nextTick();
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mockAnimationFrame() {
|
|
const callbacks = new Map();
|
|
let currentTime = 0;
|
|
let id = 1;
|
|
patchWithCleanup(browser, {
|
|
requestAnimationFrame(fn) {
|
|
callbacks.set(id, { fn, scheduledFor: 16 + currentTime, id });
|
|
return id++;
|
|
},
|
|
cancelAnimationFrame(id) {
|
|
callbacks.delete(id);
|
|
},
|
|
performance: { now: () => currentTime },
|
|
});
|
|
return {
|
|
execRegisteredAnimationFrames() {
|
|
for (const { fn } of callbacks.values()) {
|
|
fn(currentTime);
|
|
}
|
|
callbacks.clear();
|
|
},
|
|
async advanceFrame(count = 1) {
|
|
// wait here so all microtasktick scheduled in this frame can be
|
|
// executed and possibly register their own timeout
|
|
await nextTick();
|
|
currentTime += 16 * count;
|
|
for (const { fn, scheduledFor, id } of callbacks.values()) {
|
|
if (scheduledFor <= currentTime) {
|
|
fn(currentTime);
|
|
callbacks.delete(id);
|
|
}
|
|
}
|
|
// wait here to make sure owl can update the UI
|
|
await nextTick();
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function mount(Comp, target, config = {}) {
|
|
let { props, env } = config;
|
|
env = env || {};
|
|
const configuration = {
|
|
env,
|
|
getTemplate,
|
|
test: true,
|
|
props,
|
|
};
|
|
if (env.services && "localization" in env.services) {
|
|
configuration.translateFn = _t;
|
|
}
|
|
const app = new App(Comp, configuration);
|
|
registerCleanup(() => app.destroy());
|
|
return app.mount(target);
|
|
}
|
|
|
|
export function destroy(comp) {
|
|
comp.__owl__.app.destroy();
|
|
}
|
|
|
|
export function findChildren(comp, predicate = (e) => e) {
|
|
const queue = [];
|
|
[].unshift.apply(queue, Object.values(comp.__owl__.children));
|
|
|
|
while (queue.length > 0) {
|
|
const curNode = queue.pop();
|
|
if (predicate(curNode)) {
|
|
return curNode;
|
|
}
|
|
[].unshift.apply(queue, Object.values(curNode.component.__owl__.children));
|
|
}
|
|
}
|
|
|
|
// partial replacement of t-ref on component
|
|
export function useChild() {
|
|
const node = useComponent().__owl__;
|
|
const setChild = () => {
|
|
const componentNode = Object.values(node.children)[0];
|
|
node.component.child = componentNode.component;
|
|
};
|
|
onMounted(setChild);
|
|
onPatched(setChild);
|
|
}
|
|
|
|
export function useLogLifeCycle(logFn, name = "") {
|
|
const component = useComponent();
|
|
let loggedName = `${component.constructor.name}`;
|
|
if (name) {
|
|
loggedName = `${component.constructor.name} ${name}`;
|
|
}
|
|
onError(() => {
|
|
logFn(`onError ${loggedName}`);
|
|
});
|
|
onMounted(() => {
|
|
logFn(`onMounted ${loggedName}`);
|
|
});
|
|
onPatched(() => {
|
|
logFn(`onPatched ${loggedName}`);
|
|
});
|
|
onRendered(() => {
|
|
logFn(`onRendered ${loggedName}`);
|
|
});
|
|
onWillDestroy(() => {
|
|
logFn(`onWillDestroy ${loggedName}`);
|
|
});
|
|
onWillPatch(() => {
|
|
logFn(`onWillPatch ${loggedName}`);
|
|
});
|
|
onWillRender(() => {
|
|
logFn(`onWillRender ${loggedName}`);
|
|
});
|
|
onWillStart(() => {
|
|
logFn(`onWillStart ${loggedName}`);
|
|
});
|
|
onWillUnmount(() => {
|
|
logFn(`onWillUnmount ${loggedName}`);
|
|
});
|
|
onWillUpdateProps(() => {
|
|
logFn(`onWillUpdateProps ${loggedName}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the list of nodes containing n2 (included) that do not contain n1.
|
|
*
|
|
* @param {Node} n1
|
|
* @param {Node} n2
|
|
* @returns {Node[]}
|
|
*/
|
|
function getDifferentParents(n1, n2) {
|
|
const parents = [n2];
|
|
while (parents[0].parentNode) {
|
|
const parent = parents[0].parentNode;
|
|
if (parent.contains(n1)) {
|
|
break;
|
|
}
|
|
parents.unshift(parent);
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
/**
|
|
* Helper performing a drag and drop sequence.
|
|
*
|
|
* - 'from' is used to determine the element on which the drag will start;
|
|
* - 'target' will determine the element on which the first one will be dropped.
|
|
*
|
|
* The first element will be dragged by its center, and will be dropped on the
|
|
* bottom-right inner pixel of the target element. This behavior covers both
|
|
* cases of appending the first element to the end of a list (toSelector =
|
|
* target list) or moving it at the position of another element, effectively
|
|
* placing the first element before the second (toSelector = other element).
|
|
*
|
|
* A position can be given to drop the first element above, below, or on the
|
|
* side of the second (default is inside, as specified above).
|
|
*
|
|
* Note that only the last event is awaited, since all the others are
|
|
* considered to be synchronous.
|
|
*
|
|
* @param {Element | string} from
|
|
* @param {Element | string} to
|
|
* @param {Position} [position]
|
|
*/
|
|
export async function dragAndDrop(from, to, position) {
|
|
const { drop } = await drag(from);
|
|
await drop(to, position);
|
|
}
|
|
|
|
/**
|
|
* Helper performing a drag.
|
|
*
|
|
* - the 'from' selector is used to determine the element on which the drag will
|
|
* start;
|
|
* - the 'target' selector will determine the element on which the dragged element will be
|
|
* moved.
|
|
*
|
|
* Returns a drop function
|
|
*
|
|
* @param {Element | string} from
|
|
*/
|
|
export async function drag(from, pointerType = "mouse") {
|
|
const assertIsDragging = (fn, endDrag) => {
|
|
return {
|
|
async [fn.name](...args) {
|
|
if (dragEndReason) {
|
|
throw new Error(
|
|
`Cannot execute drag helper '${fn.name}': drag sequence has been ended by '${dragEndReason}'.`
|
|
);
|
|
}
|
|
await fn(...args);
|
|
if (endDrag) {
|
|
dragEndReason = fn.name;
|
|
}
|
|
},
|
|
}[fn.name];
|
|
};
|
|
|
|
const cancel = assertIsDragging(async function cancel() {
|
|
await triggerEvent(window, null, "keydown", { key: "Escape" });
|
|
}, true);
|
|
|
|
/**
|
|
* @param {Element | string} [to]
|
|
* @param {Position} [position]
|
|
*/
|
|
const drop = assertIsDragging(async function drop(to, position) {
|
|
if (to) {
|
|
await moveTo(to, position);
|
|
}
|
|
await triggerEvent(target || source, null, "pointerup", targetPosition);
|
|
}, true);
|
|
|
|
/**
|
|
* @param {Element | string} selector
|
|
*/
|
|
const getEl = (selector) =>
|
|
selector instanceof Element ? selector : fixture.querySelector(selector);
|
|
|
|
/**
|
|
* @param {Position} [position]
|
|
*/
|
|
const getTargetPosition = (position) => {
|
|
const tRect = target.getBoundingClientRect();
|
|
const tPos = {
|
|
clientX: Math.floor(tRect.x),
|
|
clientY: Math.floor(tRect.y),
|
|
};
|
|
if (position && typeof position === "object") {
|
|
// x and y coordinates start from the element's initial coordinates
|
|
tPos.clientX += position.x || 0;
|
|
tPos.clientY += position.y || 0;
|
|
} else {
|
|
const positions = typeof position === "string" ? position.split("-") : [];
|
|
|
|
// X position
|
|
if (positions.includes("left")) {
|
|
tPos.clientX -= 1;
|
|
} else if (positions.includes("right")) {
|
|
tPos.clientX += Math.ceil(tRect.width) + 1;
|
|
} else {
|
|
tPos.clientX += Math.floor(tRect.width / 2);
|
|
}
|
|
|
|
// Y position
|
|
if (positions.includes("top")) {
|
|
tPos.clientY -= 1;
|
|
} else if (positions.includes("bottom")) {
|
|
tPos.clientY += Math.ceil(tRect.height) + 1;
|
|
} else {
|
|
tPos.clientY += Math.floor(tRect.height / 2);
|
|
}
|
|
}
|
|
return tPos;
|
|
};
|
|
|
|
/**
|
|
* @param {Element | string} [to]
|
|
* @param {Position} [position]
|
|
*/
|
|
const moveTo = assertIsDragging(async function moveTo(to, position) {
|
|
target = getEl(to);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
// Recompute target position
|
|
targetPosition = getTargetPosition(position);
|
|
|
|
// Move, enter and drop the element on the target
|
|
await triggerEvent(source, null, "pointermove", { ...targetPosition, button: -1 });
|
|
|
|
// "pointerenter" is fired on every parent of `target` that do not contain
|
|
// `from` (typically: different parent lists).
|
|
for (const parent of getDifferentParents(source, target)) {
|
|
triggerEvent(parent, null, "pointerenter", targetPosition);
|
|
}
|
|
await nextTick();
|
|
|
|
return dragHelpers;
|
|
}, false);
|
|
|
|
const dragHelpers = { cancel, drop, moveTo };
|
|
const fixture = getFixture();
|
|
|
|
const source = getEl(from instanceof Element ? from : fixture.querySelector(from));
|
|
const sourceRect = source.getBoundingClientRect();
|
|
|
|
let dragEndReason = null;
|
|
let target;
|
|
let targetPosition;
|
|
|
|
// Pointer down on main target
|
|
await triggerEvent(source, null, "pointerdown", {
|
|
pointerType,
|
|
clientX: sourceRect.x + sourceRect.width / 2,
|
|
clientY: sourceRect.y + sourceRect.height / 2,
|
|
});
|
|
|
|
return dragHelpers;
|
|
}
|
|
|
|
/**
|
|
* Returns the dropdown menu for a specific toggler.
|
|
*
|
|
* @param {HTMLElement} target
|
|
* @param {String|HTMLElement} togglerSelector
|
|
* @returns {HTMLElement|undefined}
|
|
*/
|
|
export function getDropdownMenu(target, togglerSelector) {
|
|
if (!(target instanceof HTMLElement)) {
|
|
throw new Error(`getDropdownMenu: target is not an HTMLElement.`);
|
|
}
|
|
let el =
|
|
togglerSelector instanceof HTMLElement
|
|
? togglerSelector
|
|
: target.querySelector(togglerSelector);
|
|
|
|
if (el && !el.classList.contains("o-dropdown")) {
|
|
el = el.querySelector(".o-dropdown");
|
|
}
|
|
if (!el) {
|
|
throw new Error(`getDropdownMenu: Could not find element "${togglerSelector}".`);
|
|
}
|
|
return getPopoverForTarget(el);
|
|
}
|
|
|
|
export async function clickDropdown(target, fieldName) {
|
|
const dropdownInput = target.querySelector(`[name='${fieldName}'] .dropdown input`);
|
|
dropdownInput.focus();
|
|
await nextTick();
|
|
await click(dropdownInput);
|
|
}
|
|
|
|
export async function clickOpenedDropdownItem(target, fieldName, itemContent) {
|
|
const dropdowns = target.querySelectorAll(`[name='${fieldName}'] .dropdown .dropdown-menu`);
|
|
if (dropdowns.length === 0) {
|
|
throw new Error(`No dropdown found for field ${fieldName}`);
|
|
} else if (dropdowns.length > 1) {
|
|
throw new Error(`Found ${dropdowns.length} dropdowns for field ${fieldName}`);
|
|
}
|
|
const dropdownItems = dropdowns[0].querySelectorAll("li");
|
|
const indexToClick = Array.from(dropdownItems)
|
|
.map((html) => html.textContent)
|
|
.indexOf(itemContent);
|
|
if (indexToClick === -1) {
|
|
throw new Error(`The element '${itemContent}' does not exist in the dropdown`);
|
|
}
|
|
await click(dropdownItems[indexToClick]);
|
|
}
|
|
|
|
export async function selectDropdownItem(target, fieldName, itemContent) {
|
|
await clickDropdown(target, fieldName);
|
|
await clickOpenedDropdownItem(target, fieldName, itemContent);
|
|
}
|
|
|
|
export function getNodesTextContent(nodes) {
|
|
return Array.from(nodes).map((n) => n.textContent);
|
|
}
|
|
|
|
/**
|
|
* Click to open the dropdown on a many2one
|
|
*/
|
|
export async function clickOpenM2ODropdown(el, fieldName, selector) {
|
|
const m2oSelector = `${selector || ""} .o_field_many2one[name=${fieldName}] input`;
|
|
const matches = el.querySelectorAll(m2oSelector);
|
|
if (matches.length !== 1) {
|
|
throw new Error(
|
|
`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`
|
|
);
|
|
}
|
|
|
|
await click(matches[0]);
|
|
return matches[0];
|
|
}
|
|
|
|
/**
|
|
* Click on the active (highlighted) selection in a m2o dropdown.
|
|
*/
|
|
// TO FIX
|
|
export async function clickM2OHighlightedItem(el, fieldName, selector) {
|
|
const m2oSelector = `${selector || ""} .o_field_many2one[name=${fieldName}] input`;
|
|
// const $dropdown = $(m2oSelector).autocomplete('widget');
|
|
const matches = el.querySelectorAll(m2oSelector);
|
|
if (matches.length !== 1) {
|
|
throw new Error(
|
|
`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`
|
|
);
|
|
}
|
|
// clicking on an li (no matter which one), will select the focussed one
|
|
return click(matches[0].parentElement.querySelector("li"));
|
|
}
|
|
|
|
// X2Many
|
|
export async function addRow(target, selector) {
|
|
await click(target.querySelector(`${selector ? selector : ""} .o_field_x2many_list_row_add a`));
|
|
}
|
|
|
|
export async function removeRow(target, index) {
|
|
await click(target.querySelectorAll(".o_list_record_remove")[index]);
|
|
}
|