web_tour/static/src/tour_service/tour_compilers.js
Данил Воробьев 9341a5d690 initial commit
2024-05-03 12:34:42 +00:00

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);
},
},
]),
};
}