433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
|
/** @odoo-module **/
|
||
|
|
||
|
import { browser } from "@web/core/browser/browser";
|
||
|
import { debounce } from "@web/core/utils/timing";
|
||
|
import { isVisible } from "@web/core/utils/ui";
|
||
|
import { tourState } from "./tour_state";
|
||
|
import {
|
||
|
callWithUnloadCheck,
|
||
|
getConsumeEventType,
|
||
|
getFirstVisibleElement,
|
||
|
getJQueryElementFromSelector,
|
||
|
getScrollParent,
|
||
|
RunningTourActionHelper,
|
||
|
} from "./tour_utils";
|
||
|
|
||
|
/**
|
||
|
* @typedef {import("@web/core/macro").MacroDescriptor} MacroDescriptor
|
||
|
*
|
||
|
* @typedef {import("../tour_service/tour_pointer_state").TourPointerState} TourPointerState
|
||
|
*
|
||
|
* @typedef {import("./tour_service").TourStep} TourStep
|
||
|
*
|
||
|
* @typedef {(stepIndex: number, step: TourStep, options: TourCompilerOptions) => MacroDescriptor[]} TourStepCompiler
|
||
|
*
|
||
|
* @typedef TourCompilerOptions
|
||
|
* @property {Tour} tour
|
||
|
* @property {number} stepDelay
|
||
|
* @property {keepWatchBrowser} boolean
|
||
|
* @property {showPointerDuration} number
|
||
|
* @property {*} pointer - used for controlling the pointer of the tour
|
||
|
*/
|
||
|
|
||
|
/**
|
||
|
* @param {string} selector - any valid jquery selector
|
||
|
* @param {boolean} inModal
|
||
|
* @param {string|undefined} shadowDOM - selector of the shadow root host
|
||
|
* @returns {Element | undefined}
|
||
|
*/
|
||
|
function findTrigger(selector, inModal, shadowDOM) {
|
||
|
const $target = $(shadowDOM ? document.querySelector(shadowDOM)?.shadowRoot : document);
|
||
|
const $visibleModal = $target.find(".modal:visible").last();
|
||
|
let $el;
|
||
|
if (inModal !== false && $visibleModal.length) {
|
||
|
$el = $visibleModal.find(selector);
|
||
|
} else {
|
||
|
$el = getJQueryElementFromSelector(selector, $target);
|
||
|
}
|
||
|
return getFirstVisibleElement($el).get(0);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {string|undefined} shadowDOM - selector of the shadow root host
|
||
|
*/
|
||
|
function findExtraTrigger(selector, shadowDOM) {
|
||
|
const $target = $(shadowDOM ? document.querySelector(shadowDOM)?.shadowRoot : document);
|
||
|
const $el = getJQueryElementFromSelector(selector, $target);
|
||
|
return getFirstVisibleElement($el).get(0);
|
||
|
}
|
||
|
|
||
|
function findStepTriggers(step) {
|
||
|
const triggerEl = findTrigger(step.trigger, step.in_modal, step.shadow_dom);
|
||
|
const altEl = findTrigger(step.alt_trigger, step.in_modal, step.shadow_dom);
|
||
|
const skipEl = findTrigger(step.skip_trigger, step.in_modal, step.shadow_dom);
|
||
|
|
||
|
// `extraTriggerOkay` should be true when `step.extra_trigger` is undefined.
|
||
|
// No need for it to be in the modal.
|
||
|
const extraTriggerOkay = step.extra_trigger
|
||
|
? findExtraTrigger(step.extra_trigger, step.shadow_dom)
|
||
|
: true;
|
||
|
|
||
|
return { triggerEl, altEl, extraTriggerOkay, skipEl };
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {TourStep} step
|
||
|
*/
|
||
|
function describeStep(step) {
|
||
|
return step.content ? `${step.content} (trigger: ${step.trigger})` : step.trigger;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {TourStep} step
|
||
|
*/
|
||
|
function describeFailedStepSimple(step, tour) {
|
||
|
return `Tour ${tour.name} failed at step ${describeStep(step)}`;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {TourStep} step
|
||
|
* @param {Tour} tour
|
||
|
*/
|
||
|
function describeFailedStepDetailed(step, tour) {
|
||
|
const offset = 3;
|
||
|
const stepIndex = tour.steps.findIndex((s) => s === step);
|
||
|
const start = stepIndex - offset >= 0 ? stepIndex - offset : 0;
|
||
|
const end =
|
||
|
stepIndex + offset + 1 <= tour.steps.length ? stepIndex + offset + 1 : tour.steps.length;
|
||
|
let result = "";
|
||
|
for (let i = start; i < end; i++) {
|
||
|
const highlight = i === stepIndex;
|
||
|
const stepString = JSON.stringify(
|
||
|
tour.steps[i],
|
||
|
(_key, value) => {
|
||
|
if (typeof value === "function") {
|
||
|
return "[function]";
|
||
|
} else {
|
||
|
return value;
|
||
|
}
|
||
|
},
|
||
|
2
|
||
|
);
|
||
|
result += `\n${highlight ? "----- FAILING STEP -----\n" : ""}${stepString},${
|
||
|
highlight ? "\n-----------------------" : ""
|
||
|
}`;
|
||
|
}
|
||
|
return `${describeFailedStepSimple(step, tour)}\n\n${result.trim()}`;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the element that will be used in listening to the `consumeEvent`.
|
||
|
* @param {HTMLElement} el
|
||
|
* @param {string} consumeEvent
|
||
|
*/
|
||
|
function getAnchorEl(el, consumeEvent) {
|
||
|
if (consumeEvent === "drag") {
|
||
|
// jQuery-ui draggable triggers 'drag' events on the .ui-draggable element,
|
||
|
// but the tip is attached to the .ui-draggable-handle element which may
|
||
|
// be one of its children (or the element itself)
|
||
|
return el.closest(".ui-draggable, .o_draggable");
|
||
|
}
|
||
|
if (consumeEvent === "input" && !["textarea", "input"].includes(el.tagName.toLowerCase())) {
|
||
|
return el.closest("[contenteditable='true']");
|
||
|
}
|
||
|
if (consumeEvent === "sort") {
|
||
|
// when an element is dragged inside a sortable container (with classname
|
||
|
// 'ui-sortable'), jQuery triggers the 'sort' event on the container
|
||
|
return el.closest(".ui-sortable, .o_sortable");
|
||
|
}
|
||
|
return el;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* IMPROVEMENT: Consider transitioning (moving) elements?
|
||
|
* @param {Element} el
|
||
|
* @param {TourStep} step
|
||
|
*/
|
||
|
function canContinue(el, step) {
|
||
|
const rootNode = el.getRootNode();
|
||
|
const isInDoc =
|
||
|
rootNode instanceof ShadowRoot
|
||
|
? el.ownerDocument.contains(rootNode.host)
|
||
|
: el.ownerDocument.contains(el);
|
||
|
const isElement = el instanceof el.ownerDocument.defaultView.Element || el instanceof Element;
|
||
|
const isBlocked = document.body.classList.contains("o_ui_blocked") || document.querySelector(".o_blockUI");
|
||
|
return (
|
||
|
isInDoc &&
|
||
|
isElement &&
|
||
|
!isBlocked &&
|
||
|
(!step.allowInvisible ? isVisible(el) : true) &&
|
||
|
(!el.disabled || step.isCheck)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {Object} params
|
||
|
* @param {HTMLElement} params.anchorEl
|
||
|
* @param {string} params.consumeEvent
|
||
|
* @param {() => void} params.onMouseEnter
|
||
|
* @param {() => void} params.onMouseLeave
|
||
|
* @param {(ev: Event) => any} params.onScroll
|
||
|
* @param {(ev: Event) => any} params.onConsume
|
||
|
*/
|
||
|
function setupListeners({
|
||
|
anchorEl,
|
||
|
consumeEvent,
|
||
|
onMouseEnter,
|
||
|
onMouseLeave,
|
||
|
onScroll,
|
||
|
onConsume,
|
||
|
}) {
|
||
|
anchorEl.addEventListener(consumeEvent, onConsume);
|
||
|
anchorEl.addEventListener("mouseenter", onMouseEnter);
|
||
|
anchorEl.addEventListener("mouseleave", onMouseLeave);
|
||
|
|
||
|
const cleanups = [
|
||
|
() => {
|
||
|
anchorEl.removeEventListener(consumeEvent, onConsume);
|
||
|
anchorEl.removeEventListener("mouseenter", onMouseEnter);
|
||
|
anchorEl.removeEventListener("mouseleave", onMouseLeave);
|
||
|
},
|
||
|
];
|
||
|
|
||
|
const scrollEl = getScrollParent(anchorEl);
|
||
|
if (scrollEl) {
|
||
|
const debouncedOnScroll = debounce(onScroll, 50);
|
||
|
scrollEl.addEventListener("scroll", debouncedOnScroll);
|
||
|
cleanups.push(() => scrollEl.removeEventListener("scroll", debouncedOnScroll));
|
||
|
}
|
||
|
|
||
|
return () => {
|
||
|
while (cleanups.length) {
|
||
|
cleanups.pop()();
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/** @type {TourStepCompiler} */
|
||
|
export function compileStepManual(stepIndex, step, options) {
|
||
|
const { tour, pointer, onStepConsummed } = options;
|
||
|
let proceedWith = null;
|
||
|
let removeListeners = () => {};
|
||
|
|
||
|
return [
|
||
|
{
|
||
|
action: () => console.log(step.trigger),
|
||
|
},
|
||
|
{
|
||
|
trigger: () => {
|
||
|
removeListeners();
|
||
|
|
||
|
if (proceedWith) {
|
||
|
return proceedWith;
|
||
|
}
|
||
|
|
||
|
const { triggerEl, altEl, extraTriggerOkay, skipEl } = findStepTriggers(step);
|
||
|
|
||
|
if (skipEl) {
|
||
|
return skipEl;
|
||
|
}
|
||
|
|
||
|
const stepEl = extraTriggerOkay && (triggerEl || altEl);
|
||
|
|
||
|
if (stepEl && canContinue(stepEl, step)) {
|
||
|
const consumeEvent = step.consumeEvent || getConsumeEventType(stepEl, step.run);
|
||
|
const anchorEl = getAnchorEl(stepEl, consumeEvent);
|
||
|
const debouncedToggleOpen = debounce(pointer.showContent, 50, true);
|
||
|
|
||
|
const updatePointer = () => {
|
||
|
pointer.setState({
|
||
|
onMouseEnter: () => debouncedToggleOpen(true),
|
||
|
onMouseLeave: () => debouncedToggleOpen(false),
|
||
|
});
|
||
|
pointer.pointTo(anchorEl, step);
|
||
|
};
|
||
|
|
||
|
removeListeners = setupListeners({
|
||
|
anchorEl,
|
||
|
consumeEvent,
|
||
|
onMouseEnter: () => pointer.showContent(true),
|
||
|
onMouseLeave: () => pointer.showContent(false),
|
||
|
onScroll: updatePointer,
|
||
|
onConsume: () => {
|
||
|
proceedWith = stepEl;
|
||
|
pointer.hide();
|
||
|
},
|
||
|
});
|
||
|
|
||
|
updatePointer();
|
||
|
} else {
|
||
|
pointer.hide();
|
||
|
}
|
||
|
},
|
||
|
action: () => {
|
||
|
tourState.set(tour.name, "currentIndex", stepIndex + 1);
|
||
|
pointer.hide();
|
||
|
proceedWith = null;
|
||
|
onStepConsummed(tour, step);
|
||
|
},
|
||
|
},
|
||
|
];
|
||
|
}
|
||
|
|
||
|
let tourTimeout;
|
||
|
|
||
|
/** @type {TourStepCompiler} */
|
||
|
export function compileStepAuto(stepIndex, step, options) {
|
||
|
const { tour, pointer, stepDelay, keepWatchBrowser, showPointerDuration, onStepConsummed } = options;
|
||
|
let skipAction = false;
|
||
|
return [
|
||
|
{
|
||
|
action: async () => {
|
||
|
// This delay is important for making the current set of tour tests pass.
|
||
|
// IMPROVEMENT: Find a way to remove this delay.
|
||
|
await new Promise(resolve => requestAnimationFrame(resolve))
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
action: async () => {
|
||
|
skipAction = false;
|
||
|
console.log(`Tour ${tour.name} on step: '${describeStep(step)}'`);
|
||
|
if (!keepWatchBrowser) {
|
||
|
browser.clearTimeout(tourTimeout);
|
||
|
tourTimeout = browser.setTimeout(() => {
|
||
|
// The logged text shows the relative position of the failed step.
|
||
|
// Useful for finding the failed step.
|
||
|
console.warn(describeFailedStepDetailed(step, tour));
|
||
|
// console.error notifies the test runner that the tour failed.
|
||
|
console.error(describeFailedStepSimple(step, tour));
|
||
|
}, (step.timeout || 10000) + stepDelay);
|
||
|
}
|
||
|
await new Promise((resolve) => browser.setTimeout(resolve, stepDelay));
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
trigger: () => {
|
||
|
const { triggerEl, altEl, extraTriggerOkay, skipEl } = findStepTriggers(step);
|
||
|
|
||
|
let stepEl = extraTriggerOkay && (triggerEl || altEl);
|
||
|
|
||
|
if (skipEl) {
|
||
|
skipAction = true;
|
||
|
stepEl = skipEl;
|
||
|
}
|
||
|
|
||
|
if (!stepEl) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return canContinue(stepEl, step) && stepEl;
|
||
|
},
|
||
|
action: async (stepEl) => {
|
||
|
tourState.set(tour.name, "currentIndex", stepIndex + 1);
|
||
|
|
||
|
if (skipAction) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const consumeEvent = step.consumeEvent || getConsumeEventType(stepEl, step.run);
|
||
|
// When in auto mode, we are not waiting for an event to be consumed, so the
|
||
|
// anchor is just the step element.
|
||
|
const $anchorEl = $(stepEl);
|
||
|
|
||
|
if (showPointerDuration > 0) {
|
||
|
// Useful in watch mode.
|
||
|
pointer.pointTo($anchorEl.get(0), step);
|
||
|
await new Promise((r) => browser.setTimeout(r, showPointerDuration));
|
||
|
pointer.hide();
|
||
|
}
|
||
|
|
||
|
// TODO: Delegate the following routine to the `ACTION_HELPERS` in the macro module.
|
||
|
const actionHelper = new RunningTourActionHelper({
|
||
|
consume_event: consumeEvent,
|
||
|
$anchor: $anchorEl,
|
||
|
});
|
||
|
|
||
|
let result;
|
||
|
if (typeof step.run === "function") {
|
||
|
const willUnload = await callWithUnloadCheck(() =>
|
||
|
// `this.$anchor` is expected in many `step.run`.
|
||
|
step.run.call({ $anchor: $anchorEl }, actionHelper)
|
||
|
);
|
||
|
result = willUnload && "will unload";
|
||
|
} else if (step.run !== undefined) {
|
||
|
const m = step.run.match(/^([a-zA-Z0-9_]+) *(?:\(? *(.+?) *\)?)?$/);
|
||
|
actionHelper[m[1]](m[2]);
|
||
|
} else if (!step.isCheck) {
|
||
|
if (stepIndex === tour.steps.length - 1) {
|
||
|
console.warn('Tour %s: ignoring action (auto) of last step', tour.name);
|
||
|
} else {
|
||
|
actionHelper.auto();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
action: () => {
|
||
|
onStepConsummed(tour, step);
|
||
|
},
|
||
|
},
|
||
|
];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {import("./tour_service").Tour} tour
|
||
|
* @param {object} options
|
||
|
* @param {TourStep[]} options.filteredSteps
|
||
|
* @param {TourStepCompiler} options.stepCompiler
|
||
|
* @param {*} options.pointer
|
||
|
* @param {number} options.stepDelay
|
||
|
* @param {boolean} options.keepWatchBrowser
|
||
|
* @param {number} options.showPointerDuration
|
||
|
* @param {number} options.checkDelay
|
||
|
* @param {(import("./tour_service").Tour) => void} options.onTourEnd
|
||
|
*/
|
||
|
export function compileTourToMacro(tour, options) {
|
||
|
const {
|
||
|
filteredSteps,
|
||
|
stepCompiler,
|
||
|
pointer,
|
||
|
stepDelay,
|
||
|
keepWatchBrowser,
|
||
|
showPointerDuration,
|
||
|
checkDelay,
|
||
|
onStepConsummed,
|
||
|
onTourEnd,
|
||
|
} = options;
|
||
|
const currentStepIndex = tourState.get(tour.name, "currentIndex");
|
||
|
return {
|
||
|
...tour,
|
||
|
checkDelay,
|
||
|
steps: filteredSteps
|
||
|
.reduce((newSteps, step, i) => {
|
||
|
if (i < currentStepIndex) {
|
||
|
// Don't include steps before the current index because they're already done.
|
||
|
return newSteps;
|
||
|
} else {
|
||
|
return [
|
||
|
...newSteps,
|
||
|
...stepCompiler(i, step, {
|
||
|
tour,
|
||
|
pointer,
|
||
|
stepDelay,
|
||
|
keepWatchBrowser,
|
||
|
showPointerDuration,
|
||
|
onStepConsummed,
|
||
|
}),
|
||
|
];
|
||
|
}
|
||
|
}, [])
|
||
|
.concat([
|
||
|
{
|
||
|
action() {
|
||
|
tourState.clear(tour.name);
|
||
|
onTourEnd(tour);
|
||
|
clearTimeout(tourTimeout);
|
||
|
},
|
||
|
},
|
||
|
]),
|
||
|
};
|
||
|
}
|