mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-22 14:12:09 +02:00
19.0 vanilla
This commit is contained in:
parent
20e6dadd87
commit
4b94f0abc5
205 changed files with 24700 additions and 14614 deletions
17
odoo-bringout-oca-ocb-web_tour/web_tour/static/src/@types/registries.d.ts
vendored
Normal file
17
odoo-bringout-oca-ocb-web_tour/web_tour/static/src/@types/registries.d.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
declare module "registries" {
|
||||
interface TourStep {
|
||||
content: string;
|
||||
trigger: string;
|
||||
run: string | (() => (void | Promise<void>));
|
||||
}
|
||||
|
||||
export interface ToursRegistryShape {
|
||||
test?: boolean;
|
||||
url: string;
|
||||
steps(): TourStep[];
|
||||
}
|
||||
|
||||
export interface GlobalRegistryCategories {
|
||||
"web_tour.tours": ToursRegistryShape;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import ToursDialog from "@web_tour/debug/tour_dialog_component";
|
||||
import utils from "web_tour.utils";
|
||||
|
||||
export function disableTours({ env }) {
|
||||
if (!env.services.user.isSystem) {
|
||||
return null;
|
||||
}
|
||||
const activeTours = env.services.tour.getActiveTours();
|
||||
if (activeTours.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "item",
|
||||
description: env._t("Disable Tours"),
|
||||
callback: async () => {
|
||||
const tourNames = activeTours.map(tour => tour.name);
|
||||
await env.services.orm.call("web_tour.tour", "consume", [tourNames]);
|
||||
for (const tourName of tourNames) {
|
||||
browser.localStorage.removeItem(utils.get_debugging_key(tourName));
|
||||
}
|
||||
browser.location.reload();
|
||||
},
|
||||
sequence: 50,
|
||||
};
|
||||
}
|
||||
|
||||
export function startTour({ env }) {
|
||||
if (!env.services.user.isSystem) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "item",
|
||||
description: env._t("Start Tour"),
|
||||
callback: async () => {
|
||||
env.services.dialog.add(ToursDialog);
|
||||
},
|
||||
sequence: 60,
|
||||
};
|
||||
}
|
||||
|
||||
registry
|
||||
.category("debug")
|
||||
.category("default")
|
||||
.add("web_tour.startTour", startTour)
|
||||
.add("web_tour.disableTours", disableTours);
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export default class ToursDialog extends Component {
|
||||
setup() {
|
||||
this.tourService = useService("tour");
|
||||
this.onboardingTours = this.tourService.getOnboardingTours();
|
||||
this.testingTours = this.tourService.getTestingTours();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resets the given tour to its initial step, in onboarding mode.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onStartTour(ev) {
|
||||
this.tourService.reset(ev.target.dataset.name);
|
||||
this.props.close();
|
||||
}
|
||||
/**
|
||||
* Starts the given tour in test mode.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onTestTour(ev) {
|
||||
this.tourService.run(ev.target.dataset.name);
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
ToursDialog.template = "web_tour.ToursDialog";
|
||||
ToursDialog.components = { Dialog };
|
||||
ToursDialog.title = _lt("Tours");
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web_tour.ToursDialog" owl="1">
|
||||
<Dialog title="this.constructor.title">
|
||||
<t t-call="web_tour.ToursDialog.Table">
|
||||
<t t-set="caption">Onboarding tours</t>
|
||||
<t t-set="tours" t-value="onboardingTours"/>
|
||||
</t>
|
||||
<t t-if="testingTours.length" t-call="web_tour.ToursDialog.Table">
|
||||
<t t-set="caption">Testing tours</t>
|
||||
<t t-set="tours" t-value="testingTours"/>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
<t t-name="web_tour.ToursDialog.Table" owl="1">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<caption style="caption-side: top; font-size: 14px">
|
||||
<t t-esc="caption"/>
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sequence</th>
|
||||
<th width="50%">Name</th>
|
||||
<th width="50%">Path</th>
|
||||
<th>Start</th>
|
||||
<th>Test</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="tours" t-as="tour" t-key="tour.name">
|
||||
<td><t t-esc="tour.sequence"/></td>
|
||||
<td><t t-esc="tour.name"/></td>
|
||||
<td><t t-esc="tour.url"/></td>
|
||||
<td>
|
||||
<button type="button"
|
||||
class="btn btn-primary fa fa-play o_start_tour"
|
||||
t-on-click.prevent="_onStartTour"
|
||||
t-att-data-name="tour.name"
|
||||
aria-label="Start tour"
|
||||
title="Start tour"/>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
class="btn btn-primary fa fa-cogs o_test_tour"
|
||||
t-on-click.prevent="_onTestTour"
|
||||
t-att-data-name="tour.name"
|
||||
aria-label="Test tour"
|
||||
title="Test tour"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0"?>
|
||||
<templates>
|
||||
<t t-name="web_tour.OnboardingItem">
|
||||
<DropdownItem>
|
||||
<div class="d-flex justify-content-between ps-3">
|
||||
<div class="align-self-center">
|
||||
<span class="form-check form-switch" t-on-click.stop.prevent="() => this.props.toggleItem()">
|
||||
<input type="checkbox" class="form-check-input" id="onboarding" t-att-checked="this.props.toursEnabled"/>
|
||||
<label class="form-check-label">Onboarding</label>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
odoo.define('web_tour.public.TourManager', function (require) {
|
||||
'use strict';
|
||||
|
||||
var TourManager = require('web_tour.TourManager');
|
||||
var lazyloader = require('web.public.lazyloader');
|
||||
|
||||
TourManager.include({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_waitBeforeTourStart: function () {
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
return lazyloader.allScriptsLoaded;
|
||||
}).then(function () {
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
|
||||
odoo.define('web_tour.RunningTourActionHelper', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var utils = require('web_tour.utils');
|
||||
var Tip = require('web_tour.Tip');
|
||||
|
||||
var get_first_visible_element = utils.get_first_visible_element;
|
||||
var get_jquery_element_from_selector = utils.get_jquery_element_from_selector;
|
||||
|
||||
var RunningTourActionHelper = core.Class.extend({
|
||||
init: function (tip_widget) {
|
||||
this.tip_widget = tip_widget;
|
||||
},
|
||||
click: function (element) {
|
||||
this._click(this._get_action_values(element));
|
||||
},
|
||||
dblclick: function (element) {
|
||||
this._click(this._get_action_values(element), 2);
|
||||
},
|
||||
tripleclick: function (element) {
|
||||
this._click(this._get_action_values(element), 3);
|
||||
},
|
||||
clicknoleave: function (element) {
|
||||
this._click(this._get_action_values(element), 1, false);
|
||||
},
|
||||
text: function (text, element) {
|
||||
this._text(this._get_action_values(element), text);
|
||||
},
|
||||
remove_text(text, element) {
|
||||
this._text(this._get_action_values(element), '\n');
|
||||
},
|
||||
text_blur: function (text, element) {
|
||||
this._text_blur(this._get_action_values(element), text);
|
||||
},
|
||||
drag_and_drop: function (to, element) {
|
||||
this._drag_and_drop_jquery(this._get_action_values(element), to);
|
||||
},
|
||||
drag_and_drop_native: function (toSel, element) {
|
||||
const to = get_jquery_element_from_selector(toSel)[0];
|
||||
this._drag_and_drop(this._get_action_values(element).$element[0], to);
|
||||
},
|
||||
drag_move_and_drop: function (to, element) {
|
||||
this._drag_move_and_drop(this._get_action_values(element), to);
|
||||
},
|
||||
keydown: function (keyCodes, element) {
|
||||
this._keydown(this._get_action_values(element), keyCodes.split(/[,\s]+/));
|
||||
},
|
||||
auto: function (element) {
|
||||
var values = this._get_action_values(element);
|
||||
if (values.consume_event === "input") {
|
||||
this._text(values);
|
||||
} else {
|
||||
this._click(values);
|
||||
}
|
||||
},
|
||||
_get_action_values: function (element) {
|
||||
var $e = get_jquery_element_from_selector(element);
|
||||
var $element = element ? get_first_visible_element($e) : this.tip_widget.$anchor;
|
||||
if ($element.length === 0) {
|
||||
$element = $e.first();
|
||||
}
|
||||
var consume_event = element ? Tip.getConsumeEventType($element) : this.tip_widget.consume_event;
|
||||
return {
|
||||
$element: $element,
|
||||
consume_event: consume_event,
|
||||
};
|
||||
},
|
||||
_click: function (values, nb, leave) {
|
||||
trigger_mouse_event(values.$element, "mouseover");
|
||||
values.$element.trigger("mouseenter");
|
||||
for (var i = 1 ; i <= (nb || 1) ; i++) {
|
||||
trigger_mouse_event(values.$element, "mousedown");
|
||||
trigger_mouse_event(values.$element, "mouseup");
|
||||
trigger_mouse_event(values.$element, "click", i);
|
||||
if (i % 2 === 0) {
|
||||
trigger_mouse_event(values.$element, "dblclick");
|
||||
}
|
||||
}
|
||||
if (leave !== false) {
|
||||
trigger_mouse_event(values.$element, "mouseout");
|
||||
values.$element.trigger("mouseleave");
|
||||
}
|
||||
|
||||
function trigger_mouse_event($element, type, count) {
|
||||
var e = document.createEvent("MouseEvents");
|
||||
e.initMouseEvent(type, true, true, window, count || 0, 0, 0, 0, 0, false, false, false, false, 0, $element[0]);
|
||||
$element[0].dispatchEvent(e);
|
||||
}
|
||||
},
|
||||
_text: function (values, text) {
|
||||
this._click(values);
|
||||
|
||||
text = text || "Test";
|
||||
if (values.consume_event === "input") {
|
||||
values.$element
|
||||
.trigger({ type: 'keydown', key: text[text.length - 1] })
|
||||
.val(text)
|
||||
.trigger({ type: 'keyup', key: text[text.length - 1] });
|
||||
values.$element[0].dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
}));
|
||||
} else if (values.$element.is("select")) {
|
||||
var $options = values.$element.find("option");
|
||||
$options.prop("selected", false).removeProp("selected");
|
||||
var $selectedOption = $options.filter(function () { return $(this).val() === text; });
|
||||
if ($selectedOption.length === 0) {
|
||||
$selectedOption = $options.filter(function () { return $(this).text().trim() === text; });
|
||||
}
|
||||
const regex = /option\s+([0-9]+)/;
|
||||
if ($selectedOption.length === 0 && regex.test(text)) {
|
||||
// Extract position as 1-based, as the nth selectors.
|
||||
const position = parseInt(regex.exec(text)[1]);
|
||||
$selectedOption = $options.eq(position - 1); // eq is 0-based.
|
||||
}
|
||||
$selectedOption.prop("selected", true);
|
||||
this._click(values);
|
||||
// For situations where an `oninput` is defined.
|
||||
values.$element.trigger("input");
|
||||
} else {
|
||||
values.$element.focusIn();
|
||||
values.$element.trigger($.Event( "keydown", {key: '_', keyCode: 95}));
|
||||
values.$element.text(text).trigger("input");
|
||||
values.$element.focusInEnd();
|
||||
values.$element.trigger($.Event( "keyup", {key: '_', keyCode: 95}));
|
||||
}
|
||||
values.$element[0].dispatchEvent(new Event("change", { bubbles: true, cancelable: false }));
|
||||
},
|
||||
_text_blur: function (values, text) {
|
||||
this._text(values, text);
|
||||
values.$element.trigger('focusout');
|
||||
values.$element.trigger('blur');
|
||||
},
|
||||
_calculateCenter: function ($el, selector) {
|
||||
const center = $el.offset();
|
||||
if (selector && selector.indexOf('iframe') !== -1) {
|
||||
const iFrameOffset = $('iframe').offset();
|
||||
center.left += iFrameOffset.left;
|
||||
center.top += iFrameOffset.top;
|
||||
}
|
||||
center.left += $el.outerWidth() / 2;
|
||||
center.top += $el.outerHeight() / 2;
|
||||
return center;
|
||||
},
|
||||
_drag_and_drop_jquery: function (values, to) {
|
||||
var $to;
|
||||
const elementCenter = this._calculateCenter(values.$element);
|
||||
if (to) {
|
||||
$to = get_jquery_element_from_selector(to);
|
||||
} else {
|
||||
$to = $(document.body);
|
||||
}
|
||||
|
||||
values.$element.trigger($.Event("mouseenter"));
|
||||
values.$element.trigger($.Event("mousedown", {which: 1, pageX: elementCenter.left, pageY: elementCenter.top}));
|
||||
// Some tests depends on elements present only when the element to drag
|
||||
// start to move while some other tests break while moving.
|
||||
if (!$to.length) {
|
||||
values.$element.trigger($.Event("mousemove", {which: 1, pageX: elementCenter.left + 1, pageY: elementCenter.top}));
|
||||
$to = get_jquery_element_from_selector(to);
|
||||
}
|
||||
|
||||
let toCenter = this._calculateCenter($to, to);
|
||||
values.$element.trigger($.Event("mousemove", {which: 1, pageX: toCenter.left, pageY: toCenter.top}));
|
||||
// Recalculate the center as the mousemove might have made the element bigger.
|
||||
toCenter = this._calculateCenter($to, to);
|
||||
values.$element.trigger($.Event("mouseup", {which: 1, pageX: toCenter.left, pageY: toCenter.top}));
|
||||
},
|
||||
_drag_and_drop: function (element, to) {
|
||||
const elementCenter = this._calculateCenter($(element));
|
||||
const toCenter = this._calculateCenter($(to));
|
||||
element.dispatchEvent(new Event("mouseenter"));
|
||||
element.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
which: 1,
|
||||
clientX: elementCenter.left,
|
||||
clientY: elementCenter.top,
|
||||
}));
|
||||
element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true, clientX: toCenter.left, clientY: toCenter.top}));
|
||||
to.dispatchEvent(new Event("mouseenter", { clientX: toCenter.left, clientY: toCenter.top }));
|
||||
element.dispatchEvent(new Event("mouseup", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
which: 1,
|
||||
}));
|
||||
},
|
||||
_drag_move_and_drop: function (values, params) {
|
||||
// Extract parameters from string: '[deltaX,deltaY]@from => actualTo'.
|
||||
const parts = /^\[(.+),(.+)\]@(.+) => (.+)/.exec(params);
|
||||
const initialMoveOffset = [parseInt(parts[1]), parseInt(parts[2])];
|
||||
const fromSelector = parts[3];
|
||||
const toSelector = parts[4];
|
||||
// Click on element.
|
||||
values.$element.trigger($.Event("mouseenter"));
|
||||
const elementCenter = this._calculateCenter(values.$element);
|
||||
values.$element.trigger($.Event("mousedown", {which: 1, pageX: elementCenter.left, pageY: elementCenter.top}));
|
||||
// Drag through "from".
|
||||
const fromCenter = this._calculateCenter(get_jquery_element_from_selector(fromSelector), fromSelector);
|
||||
values.$element.trigger($.Event("mousemove", {
|
||||
which: 1,
|
||||
pageX: fromCenter.left + initialMoveOffset[0],
|
||||
pageY: fromCenter.top + initialMoveOffset[1],
|
||||
}));
|
||||
// Drop into "to".
|
||||
const toCenter = this._calculateCenter(get_jquery_element_from_selector(toSelector), toSelector);
|
||||
values.$element.trigger($.Event("mouseup", {which: 1, pageX: toCenter.left, pageY: toCenter.top}));
|
||||
},
|
||||
_keydown: function (values, keyCodes) {
|
||||
while (keyCodes.length) {
|
||||
const eventOptions = {};
|
||||
const keyCode = keyCodes.shift();
|
||||
let insertedText = null;
|
||||
if (isNaN(keyCode)) {
|
||||
eventOptions.key = keyCode;
|
||||
} else {
|
||||
const code = parseInt(keyCode, 10);
|
||||
eventOptions.keyCode = code;
|
||||
eventOptions.which = code;
|
||||
if (
|
||||
code === 32 || // spacebar
|
||||
(code > 47 && code < 58) || // number keys
|
||||
(code > 64 && code < 91) || // letter keys
|
||||
(code > 95 && code < 112) || // numpad keys
|
||||
(code > 185 && code < 193) || // ;=,-./` (in order)
|
||||
(code > 218 && code < 223) // [\]' (in order))
|
||||
) {
|
||||
insertedText = String.fromCharCode(code);
|
||||
}
|
||||
}
|
||||
values.$element.trigger(Object.assign({ type: "keydown" }, eventOptions));
|
||||
if (insertedText) {
|
||||
values.$element[0].ownerDocument.execCommand("insertText", 0, insertedText);
|
||||
}
|
||||
values.$element.trigger(Object.assign({ type: "keyup" }, eventOptions));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return RunningTourActionHelper;
|
||||
});
|
||||
|
|
@ -1,680 +0,0 @@
|
|||
odoo.define('web_tour.Tip', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var Widget = require('web.Widget');
|
||||
var _t = core._t;
|
||||
|
||||
var Tip = Widget.extend({
|
||||
template: "Tip",
|
||||
events: {
|
||||
click: '_onTipClicked',
|
||||
mouseenter: '_onMouseEnter',
|
||||
mouseleave: '_onMouseLeave',
|
||||
transitionend: '_onTransitionEnd',
|
||||
},
|
||||
CENTER_ON_TEXT_TAGS: ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'],
|
||||
|
||||
/**
|
||||
* @param {Widget} parent
|
||||
* @param {Object} [info] description of the tip, containing the following keys:
|
||||
* - content [String] the html content of the tip
|
||||
* - event_handlers [Object] description of optional event handlers to bind to the tip:
|
||||
* - event [String] the event name
|
||||
* - selector [String] the jQuery selector on which the event should be bound
|
||||
* - handler [function] the handler
|
||||
* - position [String] tip's position ('top', 'right', 'left' or 'bottom'), default 'right'
|
||||
* - width [int] the width in px of the tip when opened, default 270
|
||||
* - space [int] space in px between anchor and tip, default to 0, added to
|
||||
* the natural space chosen in css
|
||||
* - hidden [boolean] if true, the tip won't be visible (but the handlers will still be
|
||||
* bound on the anchor, so that the tip is consumed if the user clicks on it)
|
||||
* - overlay [Object] x and y values for the number of pixels the mouseout detection area
|
||||
* overlaps the opened tip, default {x: 50, y: 50}
|
||||
*/
|
||||
init: function(parent, info) {
|
||||
this._super(parent);
|
||||
this.info = _.defaults(info, {
|
||||
position: "right",
|
||||
width: 270,
|
||||
space: 0,
|
||||
overlay: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
},
|
||||
content: _t("Click here to go to the next step."),
|
||||
scrollContent: _t("Scroll to reach the next step."),
|
||||
});
|
||||
this.position = {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
};
|
||||
this.initialPosition = this.info.position;
|
||||
this.viewPortState = 'in';
|
||||
this._onAncestorScroll = _.throttle(this._onAncestorScroll, 0.1);
|
||||
},
|
||||
/**
|
||||
* Attaches the tip to the provided $anchor and $altAnchor.
|
||||
* $altAnchor is an alternative trigger that can consume the step. The tip is
|
||||
* however only displayed on the $anchor.
|
||||
*
|
||||
* Note that the returned promise stays pending if the Tip widget was
|
||||
* destroyed in the meantime.
|
||||
*
|
||||
* @param {jQuery} $anchor the node on which the tip should be placed
|
||||
* @param {jQuery} $altAnchor an alternative node that can consume the step
|
||||
* @return {Promise}
|
||||
*/
|
||||
attach_to: async function ($anchor, $altAnchor) {
|
||||
this._setupAnchor($anchor, $altAnchor);
|
||||
|
||||
this.is_anchor_fixed_position = this.$anchor.css("position") === "fixed";
|
||||
|
||||
// The body never needs to have the o_tooltip_parent class. It is a
|
||||
// safe place to put the tip in the DOM at initialization and be able
|
||||
// to compute its dimensions and reposition it if required.
|
||||
await this.appendTo(document.body);
|
||||
if (this.isDestroyed()) {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
},
|
||||
start() {
|
||||
this.$tooltip_overlay = this.$(".o_tooltip_overlay");
|
||||
this.$tooltip_content = this.$(".o_tooltip_content");
|
||||
this.init_width = this.$el.outerWidth();
|
||||
this.init_height = this.$el.outerHeight();
|
||||
this.$el.addClass('active');
|
||||
this.el.style.setProperty('width', `${this.info.width}px`, 'important');
|
||||
this.el.style.setProperty('height', 'auto', 'important');
|
||||
this.el.style.setProperty('transition', 'none', 'important');
|
||||
this.content_width = this.$el.outerWidth(true);
|
||||
this.content_height = this.$el.outerHeight(true);
|
||||
this.$tooltip_content.html(this.info.scrollContent);
|
||||
this.scrollContentWidth = this.$el.outerWidth(true);
|
||||
this.scrollContentHeight = this.$el.outerHeight(true);
|
||||
this.$el.removeClass('active');
|
||||
this.el.style.removeProperty('width');
|
||||
this.el.style.removeProperty('height');
|
||||
this.el.style.removeProperty('transition');
|
||||
this.$tooltip_content.html(this.info.content);
|
||||
this.$window = $(window);
|
||||
// Fix the content font size as it was used to compute the height and
|
||||
// width of the container.
|
||||
this.$tooltip_content[0].style.fontSize = getComputedStyle(this.$tooltip_content[0])['font-size'];
|
||||
|
||||
this.$tooltip_content.css({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
_.each(this.info.event_handlers, data => {
|
||||
this.$tooltip_content.on(data.event, data.selector, data.handler);
|
||||
});
|
||||
|
||||
this._bind_anchor_events();
|
||||
this._updatePosition(true);
|
||||
|
||||
this.$el.toggleClass('d-none', !!this.info.hidden);
|
||||
this.el.classList.add('o_tooltip_visible');
|
||||
core.bus.on("resize", this, _.debounce(function () {
|
||||
if (this.isDestroyed()) {
|
||||
// Because of the debounce, destroy() might have been called in the meantime.
|
||||
return;
|
||||
}
|
||||
if (this.tip_opened) {
|
||||
this._to_bubble_mode(true);
|
||||
} else {
|
||||
this._reposition();
|
||||
}
|
||||
}, 500));
|
||||
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
destroy: function () {
|
||||
this._unbind_anchor_events();
|
||||
clearTimeout(this.timerIn);
|
||||
clearTimeout(this.timerOut);
|
||||
// clear this timeout so that we won't call _updatePosition after we
|
||||
// destroy the widget and leave an undesired bubble.
|
||||
clearTimeout(this._transitionEndTimer);
|
||||
|
||||
// Do not remove the parent class if it contains other tooltips
|
||||
const _removeParentClass = $el => {
|
||||
if ($el.children(".o_tooltip").not(this.$el[0]).length === 0) {
|
||||
$el.removeClass("o_tooltip_parent");
|
||||
}
|
||||
};
|
||||
if (this.$el && this.$ideal_location) {
|
||||
_removeParentClass(this.$ideal_location);
|
||||
}
|
||||
if (this.$el && this.$furtherIdealLocation) {
|
||||
_removeParentClass(this.$furtherIdealLocation);
|
||||
}
|
||||
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Updates the $anchor and $altAnchor the tip is attached to.
|
||||
* $altAnchor is an alternative trigger that can consume the step. The tip is
|
||||
* however only displayed on the $anchor.
|
||||
*
|
||||
* @param {jQuery} $anchor the node on which the tip should be placed
|
||||
* @param {jQuery} $altAnchor an alternative node that can consume the step
|
||||
*/
|
||||
update: function ($anchor, $altAnchor) {
|
||||
// We unbind/rebind events on each update because we support widgets
|
||||
// detaching and re-attaching nodes to their DOM element without keeping
|
||||
// the initial event handlers, with said node being potential tip
|
||||
// anchors (e.g. FieldMonetary > input element).
|
||||
this._unbind_anchor_events();
|
||||
if (!$anchor.is(this.$anchor)) {
|
||||
this._setupAnchor($anchor, $altAnchor);
|
||||
}
|
||||
this._bind_anchor_events();
|
||||
if (!this.$el) {
|
||||
// Ideally this case should not happen but this is still possible,
|
||||
// as update may be called before the `start` method is called.
|
||||
// The `start` method is calling _updatePosition too anyway.
|
||||
return;
|
||||
}
|
||||
this._delegateEvents();
|
||||
this._updatePosition(true);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return {boolean} true if tip is visible
|
||||
*/
|
||||
isShown() {
|
||||
return this.el && !this.info.hidden;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sets the $anchor and $altAnchor the tip is attached to.
|
||||
* $altAnchor is an alternative trigger that can consume the step. The tip is
|
||||
* however only displayed on the $anchor.
|
||||
*
|
||||
* @param {jQuery} $anchor the node on which the tip should be placed
|
||||
* @param {jQuery} $altAnchor an alternative node that can consume the step
|
||||
*/
|
||||
_setupAnchor: function ($anchor, $altAnchor) {
|
||||
this.$anchor = $anchor;
|
||||
this.$altAnchor = $altAnchor;
|
||||
this.$ideal_location = this._get_ideal_location();
|
||||
this.$furtherIdealLocation = this._get_ideal_location(this.$ideal_location);
|
||||
},
|
||||
/**
|
||||
* Figures out which direction the tip should take and if it is at the
|
||||
* bottom or the top of the targeted element or if it's an indicator to
|
||||
* scroll. Relocates and repositions if necessary.
|
||||
*
|
||||
* @private
|
||||
* @param {boolean} [forceReposition=false]
|
||||
*/
|
||||
_updatePosition: function (forceReposition = false) {
|
||||
if (this.info.hidden) {
|
||||
return;
|
||||
}
|
||||
if (this.isDestroyed()) {
|
||||
// TODO This should not be needed if the chain of events leading
|
||||
// here was fully cancelled by destroy().
|
||||
return;
|
||||
}
|
||||
let halfHeight = 0;
|
||||
if (this.initialPosition === 'right' || this.initialPosition === 'left') {
|
||||
halfHeight = this.$anchor.innerHeight() / 2;
|
||||
}
|
||||
|
||||
const paddingTop = parseInt(this.$ideal_location.css('padding-top'));
|
||||
const topViewport = window.pageYOffset + paddingTop;
|
||||
const botViewport = window.pageYOffset + window.innerHeight;
|
||||
const topOffset = this.$anchor.offset().top;
|
||||
const botOffset = topOffset + this.$anchor.innerHeight();
|
||||
|
||||
// Check if the viewport state change to know if we need to move the anchor of the tip.
|
||||
// up : the target element is above the current viewport
|
||||
// down : the target element is below the current viewport
|
||||
// in : the target element is in the current viewport
|
||||
let viewPortState = 'in';
|
||||
let position = this.info.position;
|
||||
if (botOffset - halfHeight < topViewport) {
|
||||
viewPortState = 'up';
|
||||
position = 'bottom';
|
||||
} else if (topOffset + halfHeight > botViewport) {
|
||||
viewPortState = 'down';
|
||||
position = 'top';
|
||||
} else {
|
||||
// Adjust the placement of the tip regarding its anchor depending
|
||||
// if we came from the bottom or the top.
|
||||
if (topOffset < topViewport + this.$el.innerHeight()) {
|
||||
position = halfHeight ? this.initialPosition : "bottom";
|
||||
} else if (botOffset > botViewport - this.$el.innerHeight()) {
|
||||
position = halfHeight ? this.initialPosition : "top";
|
||||
}
|
||||
}
|
||||
|
||||
// If the direction or the anchor change : The tip position is updated.
|
||||
if (forceReposition || this.info.position !== position || this.viewPortState !== viewPortState) {
|
||||
this.$el.removeClass('top right bottom left').addClass(position);
|
||||
this.viewPortState = viewPortState;
|
||||
this.info.position = position;
|
||||
let $location;
|
||||
if (this.viewPortState === 'in') {
|
||||
this.$tooltip_content.html(this.info.content);
|
||||
$location = this.$ideal_location;
|
||||
} else {
|
||||
this.$tooltip_content.html(this.info.scrollContent);
|
||||
$location = this.$furtherIdealLocation;
|
||||
}
|
||||
// Update o_tooltip_parent class and tip DOM location. Note:
|
||||
// important to only remove/add the class when necessary to not
|
||||
// notify a DOM mutation which could retrigger this function.
|
||||
const $oldLocation = this.$el.parent();
|
||||
if (!this.tip_opened) {
|
||||
if (!$location.is($oldLocation)) {
|
||||
$oldLocation.removeClass('o_tooltip_parent');
|
||||
const cssPosition = $location.css("position");
|
||||
if (cssPosition === "static" || cssPosition === "relative") {
|
||||
$location.addClass("o_tooltip_parent");
|
||||
}
|
||||
this.$el.appendTo($location);
|
||||
}
|
||||
this._reposition();
|
||||
}
|
||||
}
|
||||
},
|
||||
_get_ideal_location: function ($anchor = this.$anchor) {
|
||||
var $location = this.info.location ? $(this.info.location) : $anchor;
|
||||
if ($location.is("html,body")) {
|
||||
return $(document.body);
|
||||
}
|
||||
|
||||
var o;
|
||||
var p;
|
||||
do {
|
||||
$location = $location.parent();
|
||||
o = $location.css("overflow");
|
||||
p = $location.css("position");
|
||||
} while (
|
||||
$location.hasClass('dropdown-menu') ||
|
||||
$location.hasClass('o_notebook_headers') ||
|
||||
$location.hasClass('o_forbidden_tooltip_parent') ||
|
||||
(
|
||||
(o === "visible" || o.includes("hidden")) && // Possible case where the overflow = "hidden auto"
|
||||
p !== "fixed" &&
|
||||
$location[0].tagName.toUpperCase() !== 'BODY'
|
||||
)
|
||||
);
|
||||
|
||||
return $location;
|
||||
},
|
||||
_reposition: function () {
|
||||
this.$el.removeClass("o_animated");
|
||||
|
||||
// Reverse left/right position if direction is right to left
|
||||
var appendAt = this.info.position;
|
||||
var rtlMap = {left: 'right', right: 'left'};
|
||||
if (rtlMap[appendAt] && _t.database.parameters.direction === 'rtl') {
|
||||
appendAt = rtlMap[appendAt];
|
||||
}
|
||||
|
||||
// Get the correct tip's position depending of the tip's state
|
||||
let $parent = this.$ideal_location;
|
||||
if ($parent.is('html,body') && this.viewPortState !== "in") {
|
||||
this.el.style.setProperty('position', 'fixed', 'important');
|
||||
} else {
|
||||
this.el.style.removeProperty('position');
|
||||
}
|
||||
|
||||
if (this.viewPortState === 'in') {
|
||||
this.$el.position({
|
||||
my: this._get_spaced_inverted_position(appendAt),
|
||||
at: appendAt,
|
||||
of: this.$anchor,
|
||||
collision: "none",
|
||||
using: props => {
|
||||
const {top} = props;
|
||||
let {left} = props;
|
||||
const anchorEl = this.$anchor[0];
|
||||
if (this.CENTER_ON_TEXT_TAGS.includes(anchorEl.nodeName) && anchorEl.hasChildNodes()) {
|
||||
const textContainerWidth = anchorEl.getBoundingClientRect().width;
|
||||
const textNode = anchorEl.firstChild;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textNode);
|
||||
const textWidth = range.getBoundingClientRect().width;
|
||||
|
||||
const alignment = window.getComputedStyle(anchorEl).getPropertyValue('text-align');
|
||||
const posVertical = (this.info.position === 'top' || this.info.position === 'bottom');
|
||||
if (alignment === 'left') {
|
||||
if (posVertical) {
|
||||
left = left - textContainerWidth / 2 + textWidth / 2;
|
||||
} else if (this.info.position === 'right') {
|
||||
left = left - textContainerWidth + textWidth;
|
||||
}
|
||||
} else if (alignment === 'right') {
|
||||
if (posVertical) {
|
||||
left = left + textContainerWidth / 2 - textWidth / 2;
|
||||
} else if (this.info.position === 'left') {
|
||||
left = left + textContainerWidth - textWidth;
|
||||
}
|
||||
} else if (alignment === 'center') {
|
||||
if (this.info.position === 'left') {
|
||||
left = left + textContainerWidth / 2 - textWidth / 2;
|
||||
} else if (this.info.position === 'right') {
|
||||
left = left - textContainerWidth / 2 + textWidth / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.el.style.setProperty('top', `${top}px`, 'important');
|
||||
this.el.style.setProperty('left', `${left}px`, 'important');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const paddingTop = parseInt($parent.css('padding-top'));
|
||||
const paddingLeft = parseInt($parent.css('padding-left'));
|
||||
const paddingRight = parseInt($parent.css('padding-right'));
|
||||
const topPosition = $parent[0].offsetTop;
|
||||
const center = (paddingLeft + paddingRight) + ((($parent[0].clientWidth - (paddingLeft + paddingRight)) / 2) - this.$el[0].offsetWidth / 2);
|
||||
let top;
|
||||
if (this.viewPortState === 'up') {
|
||||
top = topPosition + this.$el.innerHeight() + paddingTop;
|
||||
} else {
|
||||
top = topPosition + $parent.innerHeight() - this.$el.innerHeight() * 2;
|
||||
}
|
||||
this.el.style.setProperty('top', `${top}px`, 'important');
|
||||
this.el.style.setProperty('left', `${center}px`, 'important');
|
||||
}
|
||||
|
||||
// Reverse overlay if direction is right to left
|
||||
var positionRight = _t.database.parameters.direction === 'rtl' ? "right" : "left";
|
||||
var positionLeft = _t.database.parameters.direction === 'rtl' ? "left" : "right";
|
||||
|
||||
// get the offset position of this.$el
|
||||
// Couldn't use offset() or position() because their values are not the desired ones in all cases
|
||||
const offset = {top: this.$el[0].offsetTop, left: this.$el[0].offsetLeft};
|
||||
this.$tooltip_overlay.css({
|
||||
top: -Math.min((this.info.position === "bottom" ? this.info.space : this.info.overlay.y), offset.top),
|
||||
right: -Math.min((this.info.position === positionRight ? this.info.space : this.info.overlay.x), this.$window.width() - (offset.left + this.init_width)),
|
||||
bottom: -Math.min((this.info.position === "top" ? this.info.space : this.info.overlay.y), this.$window.height() - (offset.top + this.init_height)),
|
||||
left: -Math.min((this.info.position === positionLeft ? this.info.space : this.info.overlay.x), offset.left),
|
||||
});
|
||||
this.position = offset;
|
||||
|
||||
this.$el.addClass("o_animated");
|
||||
},
|
||||
_bind_anchor_events: function () {
|
||||
// The consume_event taken for RunningTourActionHelper is the one of $anchor and not $altAnchor.
|
||||
this.consume_event = this.info.consumeEvent || Tip.getConsumeEventType(this.$anchor, this.info.run);
|
||||
this.$consumeEventAnchors = this._getAnchorAndCreateEvent(this.consume_event, this.$anchor);
|
||||
if (this.$altAnchor.length) {
|
||||
const consumeEvent = this.info.consumeEvent || Tip.getConsumeEventType(this.$altAnchor, this.info.run);
|
||||
this.$consumeEventAnchors = this.$consumeEventAnchors.add(
|
||||
this._getAnchorAndCreateEvent(consumeEvent, this.$altAnchor)
|
||||
);
|
||||
}
|
||||
this.$anchor.on('mouseenter.anchor', () => this._to_info_mode());
|
||||
this.$anchor.on('mouseleave.anchor', () => this._to_bubble_mode());
|
||||
|
||||
this.$scrolableElement = this.$ideal_location.is('html,body') ? $(window) : this.$ideal_location;
|
||||
this.$scrolableElement.on('scroll.Tip', () => this._onAncestorScroll());
|
||||
},
|
||||
/**
|
||||
* Gets the anchor corresponding to the provided arguments and attaches the
|
||||
* event to the $anchor in order to consume the step accordingly.
|
||||
*
|
||||
* @private
|
||||
* @param {String} consumeEvent
|
||||
* @param {jQuery} $anchor the node on which the tip should be placed
|
||||
* @return {jQuery}
|
||||
*/
|
||||
_getAnchorAndCreateEvent: function(consumeEvent, $anchor) {
|
||||
let $consumeEventAnchors = $anchor;
|
||||
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)
|
||||
$consumeEventAnchors = $anchor.closest('.ui-draggable');
|
||||
} else if (consumeEvent === "input" && !$anchor.is('textarea, input')) {
|
||||
$consumeEventAnchors = $anchor.closest("[contenteditable='true']");
|
||||
} else if (consumeEvent.includes('apply.daterangepicker')) {
|
||||
$consumeEventAnchors = $anchor.parent().children('.o_field_date_range');
|
||||
} else if (consumeEvent === "sort") {
|
||||
// when an element is dragged inside a sortable container (with classname
|
||||
// 'ui-sortable'), jQuery triggers the 'sort' event on the container
|
||||
$consumeEventAnchors = $anchor.closest('.ui-sortable');
|
||||
}
|
||||
$consumeEventAnchors.on(consumeEvent + ".anchor", (function (e) {
|
||||
if (e.type !== "mousedown" || e.which === 1) { // only left click
|
||||
if (this.info.consumeVisibleOnly && !this.isShown()) {
|
||||
// Do not consume non-displayed tips.
|
||||
return;
|
||||
}
|
||||
this.trigger("tip_consumed");
|
||||
this._unbind_anchor_events();
|
||||
}
|
||||
}).bind(this));
|
||||
return $consumeEventAnchors;
|
||||
},
|
||||
_unbind_anchor_events: function () {
|
||||
if (this.$anchor) {
|
||||
this.$anchor.off(".anchor");
|
||||
}
|
||||
if (this.$consumeEventAnchors) {
|
||||
this.$consumeEventAnchors.off(".anchor");
|
||||
}
|
||||
if (this.$scrolableElement) {
|
||||
this.$scrolableElement.off('.Tip');
|
||||
}
|
||||
},
|
||||
_get_spaced_inverted_position: function (position) {
|
||||
if (position === "right") return "left+" + this.info.space;
|
||||
if (position === "left") return "right-" + this.info.space;
|
||||
if (position === "bottom") return "top+" + this.info.space;
|
||||
return "bottom-" + this.info.space;
|
||||
},
|
||||
_to_info_mode: function (force) {
|
||||
if (this.timerOut !== undefined) {
|
||||
clearTimeout(this.timerOut);
|
||||
this.timerOut = undefined;
|
||||
return;
|
||||
}
|
||||
if (this.tip_opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (force === true) {
|
||||
this._build_info_mode();
|
||||
} else {
|
||||
this.timerIn = setTimeout(this._build_info_mode.bind(this), 100);
|
||||
}
|
||||
},
|
||||
_build_info_mode: function () {
|
||||
clearTimeout(this.timerIn);
|
||||
this.timerIn = undefined;
|
||||
|
||||
this.tip_opened = true;
|
||||
|
||||
var offset = this.$el.offset();
|
||||
|
||||
// When this.$el doesn't have any parents, it means that the tip is no
|
||||
// longer in the DOM and so, it shouldn't be open. It happens when the
|
||||
// tip is opened after being destroyed.
|
||||
if (!this.$el.parent().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$el.parent()[0] !== this.$el[0].ownerDocument.body) {
|
||||
this.$el.detach();
|
||||
this.el.style.setProperty('top', `${offset.top}px`, 'important');
|
||||
this.el.style.setProperty('left', `${offset.left}px`, 'important');
|
||||
this.$el.appendTo(this.$el[0].ownerDocument.body);
|
||||
}
|
||||
|
||||
var mbLeft = 0;
|
||||
var mbTop = 0;
|
||||
var overflow = false;
|
||||
var posVertical = (this.info.position === "top" || this.info.position === "bottom");
|
||||
if (posVertical) {
|
||||
overflow = (offset.left + this.content_width + this.info.overlay.x > this.$window.width());
|
||||
} else {
|
||||
overflow = (offset.top + this.content_height + this.info.overlay.y > this.$window.height());
|
||||
}
|
||||
if (posVertical && overflow || this.info.position === "left" || (_t.database.parameters.direction === 'rtl' && this.info.position == "right")) {
|
||||
mbLeft -= (this.content_width - this.init_width);
|
||||
}
|
||||
if (!posVertical && overflow || this.info.position === "top") {
|
||||
mbTop -= (this.viewPortState === 'down') ? this.init_height - 5 : (this.content_height - this.init_height);
|
||||
}
|
||||
|
||||
|
||||
const [contentWidth, contentHeight] = this.viewPortState === 'in'
|
||||
? [this.content_width, this.content_height]
|
||||
: [this.scrollContentWidth, this.scrollContentHeight];
|
||||
this.$el.toggleClass("inverse", overflow);
|
||||
this.$el.removeClass("o_animated").addClass("active");
|
||||
this.el.style.setProperty('width', `${contentWidth}px`, 'important');
|
||||
this.el.style.setProperty('height', `${contentHeight}px`, 'important');
|
||||
this.el.style.setProperty('margin-left', `${mbLeft}px`, 'important');
|
||||
this.el.style.setProperty('margin-top', `${mbTop}px`, 'important');
|
||||
|
||||
this._transitionEndTimer = setTimeout(() => this._onTransitionEnd(), 400);
|
||||
},
|
||||
_to_bubble_mode: function (force) {
|
||||
if (this.timerIn !== undefined) {
|
||||
clearTimeout(this.timerIn);
|
||||
this.timerIn = undefined;
|
||||
return;
|
||||
}
|
||||
if (!this.tip_opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (force === true) {
|
||||
this._build_bubble_mode();
|
||||
} else {
|
||||
this.timerOut = setTimeout(this._build_bubble_mode.bind(this), 300);
|
||||
}
|
||||
},
|
||||
_build_bubble_mode: function () {
|
||||
clearTimeout(this.timerOut);
|
||||
this.timerOut = undefined;
|
||||
|
||||
this.tip_opened = false;
|
||||
this.$el.removeClass("active").addClass("o_animated");
|
||||
this.el.style.setProperty('width', `${this.init_width}px`, 'important');
|
||||
this.el.style.setProperty('height', `${this.init_height}px`, 'important');
|
||||
this.el.style.setProperty('margin', '0', 'important');
|
||||
|
||||
this._transitionEndTimer = setTimeout(() => this._onTransitionEnd(), 400);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onAncestorScroll: function () {
|
||||
if (this.tip_opened) {
|
||||
this._to_bubble_mode(true);
|
||||
} else {
|
||||
this._updatePosition(true);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onMouseEnter: function () {
|
||||
this._to_info_mode();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onMouseLeave: function () {
|
||||
this._to_bubble_mode();
|
||||
},
|
||||
/**
|
||||
* On touch devices, closes the tip when clicked.
|
||||
*
|
||||
* Also stop propagation to avoid undesired behavior, such as the kanban
|
||||
* quick create closing when the user clicks on the tooltip.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onTipClicked: function (ev) {
|
||||
if (config.device.touch && this.tip_opened) {
|
||||
this._to_bubble_mode();
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onTransitionEnd: function () {
|
||||
if (this._transitionEndTimer) {
|
||||
clearTimeout(this._transitionEndTimer);
|
||||
this._transitionEndTimer = undefined;
|
||||
if (!this.tip_opened) {
|
||||
this._updatePosition(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {jQuery} $element
|
||||
* @param {string} [run] the run parameter of the tip (only strings are useful)
|
||||
*/
|
||||
Tip.getConsumeEventType = function ($element, run) {
|
||||
if ($element.has("input.o-autocomplete--input.o_input").length > 0) {
|
||||
// Components that utilizes the AutoComplete component is expected to
|
||||
// contain an input with the class o-autocomplete--input.
|
||||
// And when an option is selected, the component triggers
|
||||
// 'AutoComplete:OPTION_SELECTED' event.
|
||||
return 'AutoComplete:OPTION_SELECTED';
|
||||
} else if ($element.hasClass('o_field_many2one') || $element.hasClass('o_field_many2manytags')) {
|
||||
return 'autocompleteselect';
|
||||
} else if ($element.is("textarea") || $element.filter("input").is(function () {
|
||||
var type = $(this).attr("type");
|
||||
return !type || !!type.match(/^(email|number|password|search|tel|text|url)$/);
|
||||
})) {
|
||||
// FieldDateRange triggers a special event when using the widget
|
||||
if ($element.hasClass("o_field_date_range")) {
|
||||
return "apply.daterangepicker input";
|
||||
}
|
||||
if (config.device.isMobile &&
|
||||
$element.closest('.o_field_widget').is('.o_field_many2one, .o_field_many2many')) {
|
||||
return "click";
|
||||
}
|
||||
return "input";
|
||||
} else if ($element.hasClass('ui-draggable-handle')) {
|
||||
return "drag";
|
||||
} else if (typeof run === 'string' && run.indexOf('drag_and_drop') === 0) {
|
||||
// this is a heuristic: the element has to be dragged and dropped but it
|
||||
// doesn't have class 'ui-draggable-handle', so we check if it has an
|
||||
// ui-sortable parent, and if so, we conclude that its event type is 'sort'
|
||||
if ($element.closest('.ui-sortable').length) {
|
||||
return 'sort';
|
||||
}
|
||||
if (run.indexOf("drag_and_drop_native") === 0 && $element.hasClass('o_record_draggable') || $element.closest('.o_record_draggable').length) {
|
||||
return 'mousedown';
|
||||
}
|
||||
}
|
||||
return "click";
|
||||
};
|
||||
|
||||
return Tip;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import * as hootDom from "@odoo/hoot-dom";
|
||||
import { enableEventLogs, setupEventActions } from "@web/../lib/hoot-dom/helpers/events";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Macro } from "@web/core/macro";
|
||||
import { config as transitionConfig } from "@web/core/transition";
|
||||
import { TourStepAutomatic } from "@web_tour/js/tour_automatic/tour_step_automatic";
|
||||
import { tourState } from "@web_tour/js/tour_state";
|
||||
|
||||
export class TourAutomatic {
|
||||
mode = "auto";
|
||||
allowUnload = true;
|
||||
constructor(data) {
|
||||
Object.assign(this, data);
|
||||
this.steps = this.steps.map((step, index) => new TourStepAutomatic(step, this, index));
|
||||
this.config = tourState.getCurrentConfig() || {};
|
||||
}
|
||||
|
||||
get currentIndex() {
|
||||
return tourState.getCurrentIndex();
|
||||
}
|
||||
|
||||
get currentStep() {
|
||||
return this.steps[this.currentIndex];
|
||||
}
|
||||
|
||||
get debugMode() {
|
||||
return this.config.debug !== false;
|
||||
}
|
||||
|
||||
start() {
|
||||
setupEventActions(document.createElement("div"), { allowSubmit: true });
|
||||
enableEventLogs(this.debugMode);
|
||||
const { delayToCheckUndeterminisms, stepDelay } = this.config;
|
||||
const macroSteps = this.steps
|
||||
.filter((step) => step.index >= this.currentIndex)
|
||||
.flatMap((step) => [
|
||||
{
|
||||
action: async () => {
|
||||
if (this.debugMode) {
|
||||
console.groupCollapsed(step.describeMe);
|
||||
console.log(step.stringify);
|
||||
if (stepDelay > 0) {
|
||||
await hootDom.delay(stepDelay);
|
||||
}
|
||||
if (step.break) {
|
||||
// eslint-disable-next-line no-debugger
|
||||
debugger;
|
||||
}
|
||||
} else {
|
||||
console.log(step.describeMe);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: step.trigger ? () => step.findTrigger() : null,
|
||||
timeout:
|
||||
step.pause && this.debugMode
|
||||
? 9999999
|
||||
: step.timeout || this.timeout || 10000,
|
||||
action: async (trigger) => {
|
||||
if (delayToCheckUndeterminisms > 0) {
|
||||
await step.checkForUndeterminisms(trigger, delayToCheckUndeterminisms);
|
||||
}
|
||||
this.allowUnload = false;
|
||||
if (!step.skipped && step.expectUnloadPage) {
|
||||
this.allowUnload = true;
|
||||
setTimeout(() => {
|
||||
const message = `
|
||||
The key { expectUnloadPage } is defined but page has not been unloaded within 20000 ms.
|
||||
You probably don't need it.
|
||||
`.replace(/^\s+/gm, "");
|
||||
this.throwError(message);
|
||||
}, 20000);
|
||||
}
|
||||
await step.doAction();
|
||||
if (this.debugMode) {
|
||||
console.log(trigger);
|
||||
if (step.skipped) {
|
||||
console.log("This step has been skipped");
|
||||
} else {
|
||||
console.log("This step has run successfully");
|
||||
}
|
||||
console.groupEnd();
|
||||
if (step.pause) {
|
||||
await this.pause();
|
||||
}
|
||||
}
|
||||
tourState.setCurrentIndex(step.index + 1);
|
||||
if (this.allowUnload) {
|
||||
return "StopTheMacro!";
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const end = () => {
|
||||
delete window[hootNameSpace];
|
||||
transitionConfig.disabled = false;
|
||||
tourState.clear();
|
||||
//No need to catch error yet.
|
||||
window.addEventListener(
|
||||
"error",
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener(
|
||||
"unhandledrejection",
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
this.macro = new Macro({
|
||||
name: this.name,
|
||||
steps: macroSteps,
|
||||
onError: ({ error }) => {
|
||||
if (error.type === "Timeout") {
|
||||
this.throwError(...this.currentStep.describeWhyIFailed, error.message);
|
||||
} else {
|
||||
this.throwError(error.message);
|
||||
}
|
||||
end();
|
||||
},
|
||||
onComplete: () => {
|
||||
browser.console.log("tour succeeded");
|
||||
// Used to see easily in the python console and to know which tour has been succeeded in suite tours case.
|
||||
const succeeded = `║ TOUR ${this.name} SUCCEEDED ║`;
|
||||
const msg = [succeeded];
|
||||
msg.unshift("╔" + "═".repeat(succeeded.length - 2) + "╗");
|
||||
msg.push("╚" + "═".repeat(succeeded.length - 2) + "╝");
|
||||
browser.console.log(`\n\n${msg.join("\n")}\n`);
|
||||
end();
|
||||
},
|
||||
});
|
||||
|
||||
const beforeUnloadHandler = () => {
|
||||
if (!this.allowUnload) {
|
||||
const message = `
|
||||
Be sure to use { expectUnloadPage: true } for any step
|
||||
that involves firing a beforeUnload event.
|
||||
This avoid a non-deterministic behavior by explicitly stopping
|
||||
the tour that might continue before the page is unloaded.
|
||||
`.replace(/^\s+/gm, "");
|
||||
this.throwError(message);
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnloadHandler);
|
||||
|
||||
if (this.debugMode && this.currentIndex === 0) {
|
||||
// Starts the tour with a debugger to allow you to choose devtools configuration.
|
||||
// eslint-disable-next-line no-debugger
|
||||
debugger;
|
||||
}
|
||||
transitionConfig.disabled = true;
|
||||
const hootNameSpace = hootDom.exposeHelpers(hootDom);
|
||||
console.debug(`Hoot DOM helpers available from \`window.${hootNameSpace}\``);
|
||||
this.macro.start();
|
||||
}
|
||||
|
||||
get describeWhereIFailed() {
|
||||
const offset = 3;
|
||||
const start = Math.max(this.currentIndex - offset, 0);
|
||||
const end = Math.min(this.currentIndex + offset, this.steps.length - 1);
|
||||
const result = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
const step = this.steps[i];
|
||||
const stepString = step.stringify;
|
||||
const text = [stepString];
|
||||
if (i === this.currentIndex) {
|
||||
const line = "-".repeat(10);
|
||||
const failing_step = `${line} FAILED: ${step.describeMe} ${line}`;
|
||||
text.unshift(failing_step);
|
||||
text.push("-".repeat(failing_step.length));
|
||||
}
|
||||
result.push(...text);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [error]
|
||||
*/
|
||||
throwError(...args) {
|
||||
console.groupEnd();
|
||||
tourState.setCurrentTourOnError();
|
||||
// console.error notifies the test runner that the tour failed.
|
||||
browser.console.error([`FAILED: ${this.currentStep.describeMe}.`, ...args].join("\n"));
|
||||
// The logged text shows the relative position of the failed step.
|
||||
// Useful for finding the failed step.
|
||||
browser.console.dir(this.describeWhereIFailed);
|
||||
if (this.debugMode) {
|
||||
// eslint-disable-next-line no-debugger
|
||||
debugger;
|
||||
}
|
||||
}
|
||||
|
||||
async pause() {
|
||||
const styles = [
|
||||
"background: black; color: white; font-size: 14px",
|
||||
"background: black; color: orange; font-size: 14px",
|
||||
];
|
||||
console.log(
|
||||
`%cTour is paused. Use %cplay()%c to continue.`,
|
||||
styles[0],
|
||||
styles[1],
|
||||
styles[0]
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
window.play = () => {
|
||||
resolve();
|
||||
delete window.play;
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export class TourHelpers {
|
||||
constructor(anchor) {
|
||||
this.anchor = anchor;
|
||||
this.delay = 20;
|
||||
return new Proxy(this, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (typeof value === "function" && prop !== "constructor") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import * as hoot from "@odoo/hoot-dom";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { TourHelpers } from "./tour_helpers";
|
||||
|
||||
patch(TourHelpers.prototype, {
|
||||
/**
|
||||
* Ensures that the given {@link Selector} is checked.
|
||||
* @description
|
||||
* If it is not checked, a click is triggered on the input.
|
||||
* If the input is still not checked after the click, an error is thrown.
|
||||
*
|
||||
* @param {string|Node} selector
|
||||
* @example
|
||||
* run: "check", //Checks the action element
|
||||
* @example
|
||||
* run: "check input[type=checkbox]", // Checks the selector
|
||||
*/
|
||||
async check(selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.check(element);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the **value** of the **{@link Selector}**.
|
||||
* @description
|
||||
* This is done using the following sequence:
|
||||
* - pressing "Control" + "A" to select the whole value;
|
||||
* - pressing "Backspace" to delete the value;
|
||||
* - (optional) triggering a "change" event by pressing "Enter".
|
||||
*
|
||||
* @param {Selector} selector
|
||||
* @example
|
||||
* run: "clear", // Clears the value of the action element
|
||||
* @example
|
||||
* run: "clear input#my_input", // Clears the value of the selector
|
||||
*/
|
||||
async clear(selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
await hoot.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs a click sequence on the given **{@link Selector}**
|
||||
* @description Let's see more informations about click sequence here: {@link hoot.click}
|
||||
* @param {Selector} selector
|
||||
* @param {import("@odoo/hoot-dom").PointerOptions} options
|
||||
* @example
|
||||
* run: "click", // Click on the action element
|
||||
* @example
|
||||
* run: "click .o_rows:first", // Click on the selector
|
||||
*/
|
||||
async click(selector, options = { interactive: false }) {
|
||||
const element = this._get_action_element(selector);
|
||||
// FIXME: should always target interactive element, but some tour steps are
|
||||
// targetting elements affected by 'pointer-events: none' for some reason.
|
||||
// This option should ultimately disappear, with all affected cased fixed
|
||||
// individually (no common cause found during a quick investigation).
|
||||
await hoot.click(element, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs two click sequences on the given **{@link Selector}**.
|
||||
* @description Let's see more informations about click sequence here: {@link hoot.dblclick}
|
||||
* @param {Selector} selector
|
||||
* @example
|
||||
* run: "dblclick", // Double click on the action element
|
||||
* @example
|
||||
* run: "dblclick .o_rows:first", // Double click on the selector
|
||||
*/
|
||||
async dblclick(selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.dblclick(element);
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts a drag sequence on the active element (anchor) and drop it on the given **{@link Selector}**.
|
||||
* @param {Selector} selector
|
||||
* @param {hoot.PointerOptions} options
|
||||
* @example
|
||||
* run: "drag_and_drop .o_rows:first", // Drag the active element and drop it in the selector
|
||||
* @example
|
||||
* async run(helpers) {
|
||||
* await helpers.drag_and_drop(".o_rows:first", {
|
||||
* position: {
|
||||
* top: 40,
|
||||
* left: 5,
|
||||
* },
|
||||
* relative: true,
|
||||
* });
|
||||
* }
|
||||
*/
|
||||
async drag_and_drop(selector, options) {
|
||||
if (typeof options !== "object") {
|
||||
options = { position: "top", relative: true };
|
||||
}
|
||||
const dragEffectDelay = async () => {
|
||||
await hoot.animationFrame();
|
||||
await hoot.delay(this.delay);
|
||||
};
|
||||
|
||||
const element = this.anchor;
|
||||
const { drop, moveTo } = await hoot.drag(element);
|
||||
await dragEffectDelay();
|
||||
await hoot.hover(element, {
|
||||
position: {
|
||||
top: 20,
|
||||
left: 20,
|
||||
},
|
||||
relative: true,
|
||||
});
|
||||
await dragEffectDelay();
|
||||
const target = await hoot.waitFor(selector, {
|
||||
visible: true,
|
||||
timeout: 1000,
|
||||
});
|
||||
await moveTo(target, options);
|
||||
await dragEffectDelay();
|
||||
await drop(target, options);
|
||||
await dragEffectDelay();
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit input or textarea given by **{@link selector}**
|
||||
* @param {string} text
|
||||
* @param {Selector} selector
|
||||
* @example
|
||||
* run: "edit Hello Mr. Doku",
|
||||
*/
|
||||
async edit(text, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
await hoot.edit(text);
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit only editable wysiwyg element given by **{@link Selector}**
|
||||
* @param {string} text
|
||||
* @param {Selector} selector
|
||||
*/
|
||||
async editor(text, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
const InEditor = Boolean(element.closest(".odoo-editor-editable"));
|
||||
if (!InEditor) {
|
||||
throw new Error("run 'editor' always on an element in an editor");
|
||||
}
|
||||
await hoot.click(element);
|
||||
this._set_range(element, "start");
|
||||
await hoot.keyDown("_");
|
||||
element.textContent = text;
|
||||
await hoot.manuallyDispatchProgrammaticEvent(element, "input");
|
||||
this._set_range(element, "stop");
|
||||
await hoot.keyUp("_");
|
||||
await hoot.manuallyDispatchProgrammaticEvent(element, "change");
|
||||
},
|
||||
|
||||
/**
|
||||
* Fills the **{@link Selector}** with the given `value`.
|
||||
* @description This helper is intended for `<input>` and `<textarea>` elements,
|
||||
* with the exception of `"checkbox"` and `"radio"` types, which should be
|
||||
* selected using the {@link check} helper.
|
||||
* In tour, it's mainly usefull for autocomplete components.
|
||||
* @param {string} value
|
||||
* @param {Selector} selector
|
||||
*/
|
||||
async fill(value, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
await hoot.fill(value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs a hover sequence on the given **{@link Selector}**.
|
||||
* @param {Selector} selector
|
||||
* @param {import("@odoo/hoot-dom").PointerOptions} options
|
||||
* @example
|
||||
* run: "hover",
|
||||
*/
|
||||
async hover(selector, options) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.hover(element, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Only for input[type="range"]
|
||||
* @param {string|number} value
|
||||
* @param {Selector} selector
|
||||
*/
|
||||
async range(value, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
await hoot.setInputRange(element, value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs a keyboard event sequence.
|
||||
* @example
|
||||
* run : "press Enter",
|
||||
*/
|
||||
async press(...args) {
|
||||
await hoot.press(args.flatMap((arg) => typeof arg === "string" && arg.split("+")));
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs a selection event sequence on **{@link Selector}**. This helper is intended
|
||||
* for `<select>` elements only.
|
||||
* @description Select the option by its value
|
||||
* @param {string} value
|
||||
* @param {Selector} selector
|
||||
* @example
|
||||
* run(helpers) => {
|
||||
* helpers.select("Kevin17", "select#mySelect");
|
||||
* },
|
||||
* @example
|
||||
* run: "select Foden47",
|
||||
*/
|
||||
async select(value, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
await hoot.select(value, { target: element });
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs a selection event sequence on **{@link Selector}**
|
||||
* @description Select the option by its index
|
||||
* @param {number} index starts at 0
|
||||
* @param {Selector} selector
|
||||
* @example
|
||||
* run: "selectByIndex 2", //Select the third option
|
||||
*/
|
||||
async selectByIndex(index, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
const value = hoot.queryValue(`option:eq(${index})`, { root: element });
|
||||
if (value) {
|
||||
await hoot.select(value, { target: element });
|
||||
await hoot.manuallyDispatchProgrammaticEvent(element, "input");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs a selection event sequence on **{@link Selector}**
|
||||
* @description Select option(s) by there labels
|
||||
* @param {string|RegExp} contains
|
||||
* @param {Selector} selector
|
||||
* @example
|
||||
* run: "selectByLabel Jeremy Doku", //Select all options where label contains Jeremy Doku
|
||||
*/
|
||||
async selectByLabel(contains, selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.click(element);
|
||||
const values = hoot.queryAllValues(`option:contains(${contains})`, { root: element });
|
||||
await hoot.select(values, { target: element });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensures that the given {@link Selector} is unchecked.
|
||||
* @description
|
||||
* If it is checked, a click is triggered on the input.
|
||||
* If the input is still checked after the click, an error is thrown.
|
||||
*
|
||||
* @param {string|Node} selector
|
||||
* @example
|
||||
* run: "uncheck", // Unchecks the action element
|
||||
* @example
|
||||
* run: "uncheck input[type=checkbox]", // Unchecks the selector
|
||||
*/
|
||||
async uncheck(selector) {
|
||||
const element = this._get_action_element(selector);
|
||||
await hoot.uncheck(element);
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to {@link url}.
|
||||
*
|
||||
* @param {string} url
|
||||
* @example
|
||||
* run: "goToUrl /shop", // Go to /shop
|
||||
*/
|
||||
async goToUrl(url) {
|
||||
const linkEl = document.createElement("a");
|
||||
linkEl.href = url;
|
||||
await hoot.click(linkEl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensures that the given canvas selector **{@link Selector}** contains pixels.
|
||||
* @param {string|Node} selector
|
||||
*/
|
||||
async canvasNotEmpty(selector) {
|
||||
const canvas = this._get_action_element(selector);
|
||||
if (canvas.tagName.toLowerCase() !== "canvas") {
|
||||
throw new Error(`canvasNotEmpty is only suitable for canvas elements.`);
|
||||
}
|
||||
await hoot.waitUntil(() => {
|
||||
const context = canvas.getContext("2d");
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = new Uint32Array(imageData.data.buffer);
|
||||
return pixels.some((pixel) => pixel !== 0); // pixel is on
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Node for **{@link Selector}**
|
||||
* @param {Selector} selector
|
||||
* @returns {Node}
|
||||
* @default this.anchor
|
||||
*/
|
||||
_get_action_element(selector) {
|
||||
if (typeof selector === "string" && selector.length) {
|
||||
const nodes = hoot.queryAll(selector);
|
||||
return nodes.find(hoot.isVisible) || nodes.at(0);
|
||||
} else if (typeof selector === "object" && Boolean(selector?.nodeType)) {
|
||||
return selector;
|
||||
}
|
||||
return this.anchor;
|
||||
},
|
||||
|
||||
// Useful for wysiwyg editor.
|
||||
_set_range(element, start_or_stop) {
|
||||
function _node_length(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.nodeValue.length;
|
||||
} else {
|
||||
return node.childNodes.length;
|
||||
}
|
||||
}
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
selection.removeAllRanges();
|
||||
const range = new Range();
|
||||
let node = element;
|
||||
let length = 0;
|
||||
if (start_or_stop === "start") {
|
||||
while (node.firstChild) {
|
||||
node = node.firstChild;
|
||||
}
|
||||
} else {
|
||||
while (node.lastChild) {
|
||||
node = node.lastChild;
|
||||
}
|
||||
length = _node_length(node);
|
||||
}
|
||||
range.setStart(node, length);
|
||||
range.setEnd(node, length);
|
||||
selection.addRange(range);
|
||||
},
|
||||
|
||||
queryAll(target, options) {
|
||||
return hoot.queryAll(target, options);
|
||||
},
|
||||
|
||||
queryFirst(target, options) {
|
||||
return hoot.queryFirst(target, options);
|
||||
},
|
||||
|
||||
queryOne(target, options) {
|
||||
return hoot.queryOne(target, options);
|
||||
},
|
||||
|
||||
waitFor(target, options) {
|
||||
return hoot.waitFor(target, options);
|
||||
},
|
||||
|
||||
waitUntil(predicate, options) {
|
||||
return hoot.waitUntil(predicate, options);
|
||||
},
|
||||
|
||||
animationFrame(...args) {
|
||||
return hoot.animationFrame(...args);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { tourState } from "@web_tour/js/tour_state";
|
||||
import * as hoot from "@odoo/hoot-dom";
|
||||
import { serializeChanges, serializeMutation } from "@web_tour/js/utils/tour_utils";
|
||||
import { TourHelpers } from "@web_tour/js/tour_automatic/tour_helpers";
|
||||
import { TourStep } from "@web_tour/js/tour_step";
|
||||
import { getTag } from "@web/core/utils/xml";
|
||||
import { MacroMutationObserver } from "@web/core/macro";
|
||||
|
||||
async function waitForMutations(target = document, timeout = 1000 / 16) {
|
||||
return new Promise((resolve) => {
|
||||
let observer;
|
||||
let timer;
|
||||
const mutationList = [];
|
||||
function onMutation(mutations) {
|
||||
mutationList.push(...(mutations || []));
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(mutationList);
|
||||
}, timeout);
|
||||
}
|
||||
observer = new MacroMutationObserver(onMutation);
|
||||
observer.observe(target);
|
||||
onMutation([]);
|
||||
});
|
||||
}
|
||||
export class TourStepAutomatic extends TourStep {
|
||||
skipped = false;
|
||||
error = "";
|
||||
constructor(data, tour, index) {
|
||||
super(data, tour);
|
||||
this.index = index;
|
||||
this.tourConfig = tourState.getCurrentConfig();
|
||||
}
|
||||
|
||||
async checkForUndeterminisms(initialElement, delay) {
|
||||
if (delay <= 0 || !initialElement) {
|
||||
return;
|
||||
}
|
||||
const tagName = initialElement.tagName?.toLowerCase();
|
||||
if (["body", "html"].includes(tagName) || !tagName) {
|
||||
return;
|
||||
}
|
||||
const snapshot = initialElement.cloneNode(true);
|
||||
const mutations = await waitForMutations(initialElement, delay);
|
||||
let reason;
|
||||
if (!hoot.isVisible(initialElement)) {
|
||||
reason = `Initial element is no longer visible`;
|
||||
} else if (!initialElement.isEqualNode(snapshot)) {
|
||||
reason =
|
||||
`Initial element has changed:\n` +
|
||||
JSON.stringify(serializeChanges(snapshot, initialElement), null, 2);
|
||||
} else if (mutations.length) {
|
||||
const changes = [...new Set(mutations.map(serializeMutation))];
|
||||
reason =
|
||||
`Initial element has mutated ${mutations.length} times:\n` +
|
||||
JSON.stringify(changes, null, 2);
|
||||
}
|
||||
if (reason) {
|
||||
throw new Error(
|
||||
`Potential non deterministic behavior found in ${delay}ms for trigger ${this.trigger}.\n${reason}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get describeWhyIFailed() {
|
||||
const errors = [];
|
||||
if (this.element) {
|
||||
errors.push(`Element has been found.`);
|
||||
if (this.isUIBlocked) {
|
||||
errors.push("BUT: DOM is blocked by UI.");
|
||||
}
|
||||
if (!this.elementIsInModal) {
|
||||
errors.push(
|
||||
`BUT: It is not allowed to do action on an element that's below a modal.`
|
||||
);
|
||||
}
|
||||
if (!this.elementIsEnabled) {
|
||||
errors.push(
|
||||
`BUT: Element is not enabled. TIP: You can use :enable to wait the element is enabled before doing action on it.`
|
||||
);
|
||||
}
|
||||
if (!this.parentFrameIsReady) {
|
||||
errors.push(`BUT: parent frame is not ready ([is-ready='false']).`);
|
||||
}
|
||||
} else {
|
||||
const checkElement = hoot.queryFirst(this.trigger);
|
||||
if (checkElement) {
|
||||
errors.push(`Element has been found.`);
|
||||
errors.push(
|
||||
`BUT: Element is not visible. TIP: You can use :not(:visible) to force the search for an invisible element.`
|
||||
);
|
||||
} else {
|
||||
errors.push(`Element (${this.trigger}) has not been found.`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* When return null or false, macro continues.
|
||||
*/
|
||||
async doAction() {
|
||||
if (this.skipped) {
|
||||
return false;
|
||||
}
|
||||
const actionHelper = new TourHelpers(this.element);
|
||||
if (typeof this.run === "function") {
|
||||
return await this.run.call({ anchor: this.element }, actionHelper);
|
||||
} else if (typeof this.run === "string") {
|
||||
let lastResult = null;
|
||||
for (const todo of this.run.split("&&")) {
|
||||
const m = String(todo)
|
||||
.trim()
|
||||
.match(/^(?<action>\w*) *\(? *(?<arguments>.*?)\)?$/);
|
||||
lastResult = await actionHelper[m.groups?.action](m.groups?.arguments);
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each time it returns false, tour engine wait for a mutation
|
||||
* to retry to find the trigger.
|
||||
* @returns {(HTMLElement|Boolean)}
|
||||
*/
|
||||
findTrigger() {
|
||||
if (!this.active) {
|
||||
this.skipped = true;
|
||||
return true;
|
||||
}
|
||||
const visible = !/:(hidden|visible)\b/.test(this.trigger);
|
||||
this.element = hoot.queryFirst(this.trigger, { visible });
|
||||
if (this.element) {
|
||||
return !this.isUIBlocked &&
|
||||
this.elementIsEnabled &&
|
||||
this.elementIsInModal &&
|
||||
this.parentFrameIsReady
|
||||
? this.element
|
||||
: false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get isUIBlocked() {
|
||||
return (
|
||||
document.body.classList.contains("o_ui_blocked") ||
|
||||
document.querySelector(".o_blockUI") ||
|
||||
document.querySelector(".o_is_blocked")
|
||||
);
|
||||
}
|
||||
|
||||
get parentFrameIsReady() {
|
||||
if (this.trigger.match(/\[is-ready=(true|false)\]/)) {
|
||||
return true;
|
||||
}
|
||||
const parentFrame = hoot.getParentFrame(this.element);
|
||||
return parentFrame && parentFrame.contentDocument.body.hasAttribute("is-ready")
|
||||
? parentFrame.contentDocument.body.getAttribute("is-ready") === "true"
|
||||
: true;
|
||||
}
|
||||
|
||||
get elementIsInModal() {
|
||||
if (this.hasAction) {
|
||||
const overlays = hoot.queryFirst(
|
||||
".popover, .o-we-command, .o-we-toolbar, .o_notification"
|
||||
);
|
||||
const modal = hoot.queryFirst(".modal:visible:not(.o_inactive_modal):last");
|
||||
if (modal && !overlays && !this.trigger.startsWith("body")) {
|
||||
return (
|
||||
modal.contains(hoot.getParentFrame(this.element)) ||
|
||||
modal.contains(this.element)
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get elementIsEnabled() {
|
||||
const isTag = (array) => array.includes(getTag(this.element, true));
|
||||
if (this.hasAction) {
|
||||
if (isTag(["input", "textarea"])) {
|
||||
return hoot.isEditable(this.element);
|
||||
} else if (isTag(["button", "select"])) {
|
||||
return !this.element.disabled;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get hasAction() {
|
||||
return ["string", "function"].includes(typeof this.run) && !this.skipped;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
import { tourState } from "@web_tour/js/tour_state";
|
||||
import { debounce } from "@web/core/utils/timing";
|
||||
import * as hoot from "@odoo/hoot-dom";
|
||||
import { utils } from "@web/core/ui/ui_service";
|
||||
import { TourStep } from "@web_tour/js/tour_step";
|
||||
import { MacroMutationObserver } from "@web/core/macro";
|
||||
import { getScrollParent } from "@web_tour/js/utils/tour_utils";
|
||||
|
||||
/**
|
||||
* @typedef ConsumeEvent
|
||||
* @property {string} name
|
||||
* @property {Element} target
|
||||
* @property {(ev: Event) => boolean} conditional
|
||||
*/
|
||||
|
||||
export class TourInteractive {
|
||||
mode = "manual";
|
||||
currentAction;
|
||||
currentActionIndex;
|
||||
anchorEls = [];
|
||||
removeListeners = () => {};
|
||||
|
||||
/**
|
||||
* @param {Tour} data
|
||||
*/
|
||||
constructor(data) {
|
||||
Object.assign(this, data);
|
||||
this.steps = this.steps.map((step) => new TourStep(step, this));
|
||||
this.actions = this.steps.flatMap((s) => this.getSubActions(s));
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("@web_tour/js/tour_pointer/tour_pointer").TourPointer} pointer
|
||||
* @param {Function} onTourEnd
|
||||
*/
|
||||
start(env, pointer, onTourEnd) {
|
||||
env.bus.addEventListener("ACTION_MANAGER:UPDATE", () => (this.isBusy = true));
|
||||
env.bus.addEventListener("ACTION_MANAGER:UI-UPDATED", () => (this.isBusy = false));
|
||||
|
||||
this.pointer = pointer;
|
||||
this.debouncedToggleOpen = debounce(this.pointer.showContent, 50, true);
|
||||
this.onTourEnd = onTourEnd;
|
||||
this.observer = new MacroMutationObserver(() => this._onMutation());
|
||||
this.observer.observe(document.body);
|
||||
this.currentActionIndex = tourState.getCurrentIndex();
|
||||
this.play();
|
||||
}
|
||||
|
||||
backward() {
|
||||
let tempIndex = this.currentActionIndex;
|
||||
let tempAction,
|
||||
tempAnchors = [];
|
||||
while (!tempAnchors.length && tempIndex >= 0) {
|
||||
tempIndex--;
|
||||
tempAction = this.actions.at(tempIndex);
|
||||
if (!tempAction.step.active || tempAction.event === "warn") {
|
||||
continue;
|
||||
}
|
||||
tempAnchors = tempAction && this.findTriggers(tempAction.anchor);
|
||||
}
|
||||
|
||||
if (tempIndex >= 0) {
|
||||
this.currentActionIndex = tempIndex;
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
findTriggers(anchor) {
|
||||
if (!anchor) {
|
||||
anchor = this.currentAction.anchor;
|
||||
}
|
||||
|
||||
return anchor
|
||||
.split(/,\s*(?![^(]*\))/)
|
||||
.map((part) => hoot.queryFirst(part, { visible: true }))
|
||||
.filter((el) => !!el)
|
||||
.map((el) => this.getAnchorEl(el, this.currentAction.event))
|
||||
.filter((el) => !!el);
|
||||
}
|
||||
|
||||
play() {
|
||||
this.removeListeners();
|
||||
if (this.currentActionIndex === this.actions.length) {
|
||||
this.observer.disconnect();
|
||||
this.onTourEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentAction = this.actions.at(this.currentActionIndex);
|
||||
|
||||
if (!this.currentAction.step.active || this.currentAction.event === "warn") {
|
||||
if (this.currentAction.event === "warn") {
|
||||
console.warn(`Step '${this.currentAction.anchor}' ignored.`);
|
||||
}
|
||||
this.currentActionIndex++;
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(this.currentAction.event, this.currentAction.anchor);
|
||||
|
||||
tourState.setCurrentIndex(this.currentActionIndex);
|
||||
this.anchorEls = this.findTriggers();
|
||||
this.setActionListeners();
|
||||
this.updatePointer();
|
||||
}
|
||||
|
||||
updatePointer() {
|
||||
if (this.anchorEls.length) {
|
||||
this.pointer.pointTo(
|
||||
this.anchorEls[0],
|
||||
this.currentAction.pointerInfo,
|
||||
this.currentAction.event === "drop"
|
||||
);
|
||||
this.pointer.setState({
|
||||
onMouseEnter: () => this.debouncedToggleOpen(true),
|
||||
onMouseLeave: () => this.debouncedToggleOpen(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setActionListeners() {
|
||||
const cleanups = this.anchorEls.flatMap((anchorEl, index) => {
|
||||
const toListen = {
|
||||
anchorEl,
|
||||
consumeEvents: this.getConsumeEventType(anchorEl, this.currentAction.event),
|
||||
onConsume: () => {
|
||||
this.pointer.hide();
|
||||
this.currentActionIndex++;
|
||||
this.play();
|
||||
},
|
||||
onError: () => {
|
||||
if (this.currentAction.event === "drop") {
|
||||
this.pointer.hide();
|
||||
this.currentActionIndex--;
|
||||
this.play();
|
||||
}
|
||||
},
|
||||
};
|
||||
if (index === 0) {
|
||||
return this.setupListeners({
|
||||
...toListen,
|
||||
onMouseEnter: () => this.pointer.showContent(true),
|
||||
onMouseLeave: () => this.pointer.showContent(false),
|
||||
onScroll: () => this.updatePointer(),
|
||||
});
|
||||
} else {
|
||||
return this.setupListeners({
|
||||
...toListen,
|
||||
onScroll: () => {},
|
||||
});
|
||||
}
|
||||
});
|
||||
this.removeListeners = () => {
|
||||
this.anchorEls = [];
|
||||
while (cleanups.length) {
|
||||
cleanups.pop()();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} params.anchorEl
|
||||
* @param {import("../../tour_utils").ConsumeEvent[]} params.consumeEvents
|
||||
* @param {() => void} params.onMouseEnter
|
||||
* @param {() => void} params.onMouseLeave
|
||||
* @param {(ev: Event) => any} params.onScroll
|
||||
* @param {(ev: Event) => any} params.onConsume
|
||||
* @param {() => any} params.onError
|
||||
*/
|
||||
setupListeners({
|
||||
anchorEl,
|
||||
consumeEvents,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onScroll,
|
||||
onConsume,
|
||||
onError = () => {},
|
||||
}) {
|
||||
consumeEvents = consumeEvents.map((c) => ({
|
||||
target: c.target,
|
||||
type: c.name,
|
||||
listener: function (ev) {
|
||||
if (!c.conditional || c.conditional(ev)) {
|
||||
onConsume();
|
||||
} else {
|
||||
onError();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
for (const consume of consumeEvents) {
|
||||
consume.target.addEventListener(consume.type, consume.listener, true);
|
||||
}
|
||||
anchorEl.addEventListener("mouseenter", onMouseEnter);
|
||||
anchorEl.addEventListener("mouseleave", onMouseLeave);
|
||||
|
||||
const cleanups = [
|
||||
() => {
|
||||
for (const consume of consumeEvents) {
|
||||
consume.target.removeEventListener(consume.type, consume.listener, true);
|
||||
}
|
||||
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 cleanups;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import("../tour_service").TourStep} step
|
||||
* @returns {{
|
||||
* event: string,
|
||||
* anchor: string,
|
||||
* pointerInfo: { tooltipPosition: string?, content: string? },
|
||||
* }[]}
|
||||
*/
|
||||
getSubActions(step) {
|
||||
const actions = [];
|
||||
if (!step.run || typeof step.run === "function") {
|
||||
actions.push({
|
||||
step,
|
||||
event: "warn",
|
||||
anchor: step.trigger,
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
for (const todo of step.run.split("&&")) {
|
||||
const m = String(todo)
|
||||
.trim()
|
||||
.match(/^(?<action>\w*) *\(? *(?<arguments>.*?)\)?$/);
|
||||
|
||||
let action = m.groups?.action;
|
||||
const anchor = m.groups?.arguments || step.trigger;
|
||||
const pointerInfo = {
|
||||
content: step.content,
|
||||
tooltipPosition: step.tooltipPosition,
|
||||
};
|
||||
|
||||
if (action === "drag_and_drop") {
|
||||
actions.push({
|
||||
step,
|
||||
event: "drag",
|
||||
anchor: step.trigger,
|
||||
pointerInfo,
|
||||
});
|
||||
action = "drop";
|
||||
}
|
||||
|
||||
actions.push({
|
||||
step,
|
||||
event: action,
|
||||
anchor: action === "edit" ? step.trigger : anchor,
|
||||
pointerInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} [element]
|
||||
* @param {string} [runCommand]
|
||||
* @returns {ConsumeEvent[]}
|
||||
*/
|
||||
getConsumeEventType(element, runCommand) {
|
||||
const consumeEvents = [];
|
||||
if (runCommand === "click") {
|
||||
consumeEvents.push({
|
||||
name: "click",
|
||||
target: element,
|
||||
});
|
||||
|
||||
// Click on a field widget with an autocomplete should be also completed with a selection though Enter or Tab
|
||||
// This case is for the steps that click on field_widget
|
||||
if (element.querySelector(".o-autocomplete--input")) {
|
||||
consumeEvents.push({
|
||||
name: "keydown",
|
||||
target: element.querySelector(".o-autocomplete--input"),
|
||||
conditional: (ev) =>
|
||||
["Tab", "Enter"].includes(ev.key) &&
|
||||
ev.target.parentElement.querySelector(
|
||||
".o-autocomplete--dropdown-item .ui-state-active"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Click on an element of a dropdown should be also completed with a selection though Enter or Tab
|
||||
// This case is for the steps that click on a dropdown-item
|
||||
if (element.closest(".o-autocomplete--dropdown-menu")) {
|
||||
consumeEvents.push({
|
||||
name: "keydown",
|
||||
target: element.closest(".o-autocomplete").querySelector("input"),
|
||||
conditional: (ev) => ["Tab", "Enter"].includes(ev.key),
|
||||
});
|
||||
}
|
||||
|
||||
// Press enter on a button do the same as a click
|
||||
if (element.tagName === "BUTTON") {
|
||||
consumeEvents.push({
|
||||
name: "keydown",
|
||||
target: element,
|
||||
conditional: (ev) => ev.key === "Enter",
|
||||
});
|
||||
|
||||
// Pressing enter in the input group does the same as clicking on the button
|
||||
if (element.closest(".input-group")) {
|
||||
for (const inputEl of element.parentElement.querySelectorAll("input")) {
|
||||
consumeEvents.push({
|
||||
name: "keydown",
|
||||
target: inputEl,
|
||||
conditional: (ev) => ev.key === "Enter",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (["fill", "edit"].includes(runCommand)) {
|
||||
if (
|
||||
utils.isSmall() &&
|
||||
element.closest(".o_field_widget")?.matches(".o_field_many2one, .o_field_many2many")
|
||||
) {
|
||||
consumeEvents.push({
|
||||
name: "click",
|
||||
target: element,
|
||||
});
|
||||
} else {
|
||||
consumeEvents.push({
|
||||
name: "input",
|
||||
target: element,
|
||||
});
|
||||
if (element.classList.contains("o-autocomplete--input")) {
|
||||
consumeEvents.push({
|
||||
name: "keydown",
|
||||
target: element,
|
||||
conditional: (ev) => {
|
||||
if (
|
||||
["Tab", "Enter"].includes(ev.key) &&
|
||||
ev.target.parentElement.querySelector(
|
||||
".o-autocomplete--dropdown-item .ui-state-active"
|
||||
)
|
||||
) {
|
||||
const nextStep = this.actions.at(this.currentActionIndex + 1);
|
||||
if (
|
||||
this.findTriggers(nextStep.anchor)
|
||||
.at(0)
|
||||
?.closest(".o-autocomplete--dropdown-item")
|
||||
) {
|
||||
// Skip the next step if the next one is a click on a dropdown item
|
||||
this.currentActionIndex++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
});
|
||||
consumeEvents.push({
|
||||
name: "click",
|
||||
target: element.ownerDocument,
|
||||
conditional: (ev) => {
|
||||
if (ev.target.closest(".o-autocomplete--dropdown-item")) {
|
||||
const nextStep = this.actions.at(this.currentActionIndex + 1);
|
||||
if (
|
||||
this.findTriggers(nextStep.anchor)
|
||||
.at(0)
|
||||
?.closest(".o-autocomplete--dropdown-item")
|
||||
) {
|
||||
// Skip the next step if the next one is a click on a dropdown item
|
||||
this.currentActionIndex++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & drop run command
|
||||
if (runCommand === "drag") {
|
||||
consumeEvents.push({
|
||||
name: "pointerdown",
|
||||
target: element,
|
||||
});
|
||||
}
|
||||
|
||||
if (runCommand === "drop") {
|
||||
consumeEvents.push({
|
||||
name: "pointerup",
|
||||
target: element.ownerDocument,
|
||||
conditional: (ev) =>
|
||||
element.ownerDocument
|
||||
.elementsFromPoint(ev.clientX, ev.clientY)
|
||||
.includes(element),
|
||||
});
|
||||
consumeEvents.push({
|
||||
name: "drop",
|
||||
target: element.ownerDocument,
|
||||
conditional: (ev) =>
|
||||
element.ownerDocument
|
||||
.elementsFromPoint(ev.clientX, ev.clientY)
|
||||
.includes(element),
|
||||
});
|
||||
}
|
||||
|
||||
return consumeEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the element that will be used in listening to the `consumeEvent`.
|
||||
* @param {HTMLElement} el
|
||||
* @param {string} consumeEvent
|
||||
*/
|
||||
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, .o_we_draggable, .o-draggable, [draggable='true']"
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
_onMutation() {
|
||||
if (this.currentAction) {
|
||||
const tempAnchors = this.findTriggers();
|
||||
if (
|
||||
tempAnchors.length &&
|
||||
(tempAnchors.some((a) => !this.anchorEls.includes(a)) ||
|
||||
this.anchorEls.some((a) => !tempAnchors.includes(a)))
|
||||
) {
|
||||
this.removeListeners();
|
||||
this.anchorEls = tempAnchors;
|
||||
this.setActionListeners();
|
||||
} else if (!tempAnchors.length && this.anchorEls.length) {
|
||||
this.pointer.hide();
|
||||
if (
|
||||
!hoot.queryFirst(".o_home_menu", { visible: true }) &&
|
||||
!hoot.queryFirst(".dropdown-item.o_loading", { visible: true }) &&
|
||||
!this.isBusy
|
||||
) {
|
||||
this.backward();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.updatePointer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,576 +0,0 @@
|
|||
odoo.define('web_tour.TourManager', function(require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var config = require('web.config');
|
||||
var local_storage = require('web.local_storage');
|
||||
var mixins = require('web.mixins');
|
||||
var utils = require('web_tour.utils');
|
||||
var TourStepUtils = require('web_tour.TourStepUtils');
|
||||
var RunningTourActionHelper = require('web_tour.RunningTourActionHelper');
|
||||
var ServicesMixin = require('web.ServicesMixin');
|
||||
var session = require('web.session');
|
||||
var Tip = require('web_tour.Tip');
|
||||
const {Markup} = require('web.utils');
|
||||
const { config: transitionConfig } = require("@web/core/transition");
|
||||
|
||||
var _t = core._t;
|
||||
const { markup } = require("@odoo/owl");
|
||||
|
||||
var RUNNING_TOUR_TIMEOUT = 10000;
|
||||
|
||||
var get_step_key = utils.get_step_key;
|
||||
var get_debugging_key = utils.get_debugging_key;
|
||||
var get_running_key = utils.get_running_key;
|
||||
var get_running_delay_key = utils.get_running_delay_key;
|
||||
var get_first_visible_element = utils.get_first_visible_element;
|
||||
var do_before_unload = utils.do_before_unload;
|
||||
var get_jquery_element_from_selector = utils.get_jquery_element_from_selector;
|
||||
|
||||
return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
|
||||
init: function(parent, consumed_tours, disabled = false) {
|
||||
mixins.EventDispatcherMixin.init.call(this);
|
||||
this.setParent(parent);
|
||||
|
||||
this.$body = $('body');
|
||||
this.active_tooltips = {};
|
||||
this.tours = {};
|
||||
// remove the tours being debug from the list of consumed tours
|
||||
this.consumed_tours = (consumed_tours || []).filter(tourName => {
|
||||
return !local_storage.getItem(get_debugging_key(tourName));
|
||||
});
|
||||
this.disabled = disabled;
|
||||
this.running_tour = local_storage.getItem(get_running_key());
|
||||
if (this.running_tour) {
|
||||
// Transitions can cause DOM mutations which will cause the tour_service
|
||||
// MutationObserver to wait longer before proceeding to the next step
|
||||
// this can slow down tours
|
||||
transitionConfig.disabled = true;
|
||||
}
|
||||
this.running_step_delay = parseInt(local_storage.getItem(get_running_delay_key()), 10) || 0;
|
||||
this.edition = (_.last(session.server_version_info) === 'e') ? 'enterprise' : 'community';
|
||||
this._log = [];
|
||||
console.log('Tour Manager is ready. running_tour=' + this.running_tour);
|
||||
},
|
||||
/**
|
||||
* Registers a tour described by the following arguments *in order*
|
||||
*
|
||||
* @param {string} name - tour's name
|
||||
* @param {Object} [options] - options (optional), available options are:
|
||||
* @param {boolean} [options.test=false] - true if this is only for tests
|
||||
* @param {boolean} [options.skip_enabled=false]
|
||||
* true to add a link in its tips to consume the whole tour
|
||||
* @param {string} [options.url]
|
||||
* the url to load when manually running the tour
|
||||
* @param {boolean} [options.rainbowMan=true]
|
||||
* whether or not the rainbowman must be shown at the end of the tour
|
||||
* @param {string} [options.fadeout]
|
||||
* Delay for rainbowman to disappear. 'fast' will make rainbowman dissapear, quickly,
|
||||
* 'medium', 'slow' and 'very_slow' will wait little longer before disappearing, no
|
||||
* will keep rainbowman on screen until user clicks anywhere outside rainbowman
|
||||
* @param {boolean} [options.sequence=1000]
|
||||
* priority sequence of the tour (lowest is first, tours with the same
|
||||
* sequence will be executed in a non deterministic order).
|
||||
* @param {Promise} [options.wait_for]
|
||||
* indicates when the tour can be started
|
||||
* @param {string|function} [options.rainbowManMessage]
|
||||
text or function returning the text displayed under the rainbowman
|
||||
at the end of the tour.
|
||||
* @param {Object[]} steps - steps' descriptions, each step being an object
|
||||
* containing a tip description
|
||||
*/
|
||||
register: function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var last_arg = args[args.length - 1];
|
||||
var name = args[0];
|
||||
if (this.tours[name]) {
|
||||
console.warn(_.str.sprintf("Tour %s is already defined", name));
|
||||
return;
|
||||
}
|
||||
var options = args.length === 2 ? {} : args[1];
|
||||
var steps = last_arg instanceof Array ? last_arg : [last_arg];
|
||||
var tour = {
|
||||
name: options.saveAs || name,
|
||||
steps: steps,
|
||||
url: options.url,
|
||||
rainbowMan: options.rainbowMan === undefined ? true : !!options.rainbowMan,
|
||||
rainbowManMessage: options.rainbowManMessage,
|
||||
fadeout: options.fadeout || 'medium',
|
||||
sequence: options.sequence || 1000,
|
||||
test: options.test,
|
||||
wait_for: options.wait_for || Promise.resolve(),
|
||||
};
|
||||
if (options.skip_enabled) {
|
||||
tour.skip_link = Markup`<p><span class="o_skip_tour">${_t('Skip tour')}</span></p>`;
|
||||
tour.skip_handler = function (tip) {
|
||||
this._deactivate_tip(tip);
|
||||
this._consume_tour(name);
|
||||
};
|
||||
}
|
||||
this.tours[tour.name] = tour;
|
||||
},
|
||||
/**
|
||||
* Returns a promise which is resolved once the tour can be started. This
|
||||
* is when the DOM is ready and at the end of the execution stack so that
|
||||
* all tours have potentially been extended by all apps.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_waitBeforeTourStart: function () {
|
||||
return new Promise(function (resolve) {
|
||||
$(function () {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
});
|
||||
},
|
||||
_register_all: function (do_update) {
|
||||
var self = this;
|
||||
if (this._allRegistered) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this._allRegistered = true;
|
||||
return self._waitBeforeTourStart().then(function () {
|
||||
return Promise.all(_.map(self.tours, function (tour, name) {
|
||||
return self._register(do_update, tour, name);
|
||||
})).then(() => self.update());
|
||||
});
|
||||
},
|
||||
_register: function (do_update, tour, name) {
|
||||
const debuggingTour = local_storage.getItem(get_debugging_key(name));
|
||||
if (this.disabled && !this.running_tour && !debuggingTour) {
|
||||
this.consumed_tours.push(name);
|
||||
}
|
||||
|
||||
if (tour.ready) return Promise.resolve();
|
||||
|
||||
const tour_is_consumed = this._isTourConsumed(name);
|
||||
|
||||
return tour.wait_for.then((function () {
|
||||
tour.current_step = parseInt(local_storage.getItem(get_step_key(name))) || 0;
|
||||
tour.steps = _.filter(tour.steps, (function (step) {
|
||||
return (!step.edition || step.edition === this.edition) &&
|
||||
(step.mobile === undefined || step.mobile === config.device.isMobile);
|
||||
}).bind(this));
|
||||
|
||||
if (tour_is_consumed || tour.current_step >= tour.steps.length) {
|
||||
local_storage.removeItem(get_step_key(name));
|
||||
tour.current_step = 0;
|
||||
}
|
||||
|
||||
tour.ready = true;
|
||||
|
||||
if (debuggingTour ||
|
||||
(do_update && (this.running_tour === name ||
|
||||
(!this.running_tour && !tour.test && !tour_is_consumed)))) {
|
||||
this._to_next_step(name, 0);
|
||||
}
|
||||
}).bind(this));
|
||||
},
|
||||
/**
|
||||
* Resets the given tour to its initial step, and prevent it from being
|
||||
* marked as consumed at reload.
|
||||
*
|
||||
* @param {string} tourName
|
||||
*/
|
||||
reset: function (tourName) {
|
||||
// remove it from the list of consumed tours
|
||||
const index = this.consumed_tours.indexOf(tourName);
|
||||
if (index >= 0) {
|
||||
this.consumed_tours.splice(index, 1);
|
||||
}
|
||||
// mark it as being debugged
|
||||
local_storage.setItem(get_debugging_key(tourName), true);
|
||||
// reset it to the first step
|
||||
const tour = this.tours[tourName];
|
||||
tour.current_step = 0;
|
||||
local_storage.removeItem(get_step_key(tourName));
|
||||
this._to_next_step(tourName, 0);
|
||||
// redirect to its starting point (or /web by default)
|
||||
window.location.href = window.location.origin + (tour.url || '/web');
|
||||
},
|
||||
run: async function (tour_name, step_delay) {
|
||||
console.log(_.str.sprintf("Preparing tour %s", tour_name));
|
||||
if (this.running_tour) {
|
||||
this._deactivate_tip(this.active_tooltips[this.running_tour]);
|
||||
this._consume_tour(this.running_tour, _.str.sprintf("Killing tour %s", this.running_tour));
|
||||
return;
|
||||
}
|
||||
var tour = this.tours[tour_name];
|
||||
if (!tour) {
|
||||
console.warn(_.str.sprintf("Unknown Tour %s", name));
|
||||
return;
|
||||
}
|
||||
console.log(_.str.sprintf("Running tour %s", tour_name));
|
||||
this.running_tour = tour_name;
|
||||
this.running_step_delay = step_delay || this.running_step_delay;
|
||||
local_storage.setItem(get_running_key(), this.running_tour);
|
||||
local_storage.setItem(get_running_delay_key(), this.running_step_delay);
|
||||
|
||||
this._deactivate_tip(this.active_tooltips[tour_name]);
|
||||
|
||||
tour.current_step = 0;
|
||||
this._to_next_step(tour_name, 0);
|
||||
local_storage.setItem(get_step_key(tour_name), tour.current_step);
|
||||
|
||||
if (tour.url) {
|
||||
this.pause();
|
||||
do_before_unload(null, (function () {
|
||||
this.play();
|
||||
this.update();
|
||||
}).bind(this));
|
||||
|
||||
window.location.href = window.location.origin + tour.url;
|
||||
} else {
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
pause: function () {
|
||||
this.paused = true;
|
||||
},
|
||||
play: function () {
|
||||
this.paused = false;
|
||||
},
|
||||
/**
|
||||
* Checks for tooltips to activate (only from the running tour or specified tour if there
|
||||
* is one, from all active tours otherwise). Should be called each time the DOM changes.
|
||||
*/
|
||||
update: function (tour_name) {
|
||||
if (this.paused) return;
|
||||
|
||||
tour_name = this.running_tour || tour_name;
|
||||
if (tour_name) {
|
||||
var tour = this.tours[tour_name];
|
||||
if (!tour || !tour.ready) return;
|
||||
|
||||
let self = this;
|
||||
self._check_for_skipping_step(self.active_tooltips[tour_name], tour_name);
|
||||
|
||||
if (this.running_tour && this.running_tour_timeout === undefined) {
|
||||
this._set_running_tour_timeout(this.running_tour, this.active_tooltips[this.running_tour]);
|
||||
}
|
||||
setTimeout(function () {
|
||||
self._check_for_tooltip(self.active_tooltips[tour_name], tour_name);
|
||||
});
|
||||
} else {
|
||||
const sortedTooltips = Object.keys(this.active_tooltips).sort(
|
||||
(a, b) => this.tours[a].sequence - this.tours[b].sequence
|
||||
);
|
||||
let visibleTip = false;
|
||||
for (const tourName of sortedTooltips) {
|
||||
var tip = this.active_tooltips[tourName];
|
||||
this._check_for_skipping_step(tip, tourName)
|
||||
tip.hidden = visibleTip;
|
||||
visibleTip = this._check_for_tooltip(tip, tourName) || visibleTip;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Check if the current step of a tour needs to be skipped. If so, skip the step and update
|
||||
*
|
||||
* @param {Object} step
|
||||
* @param {string} tour_name
|
||||
*/
|
||||
_check_for_skipping_step: function (step, tour_name) {
|
||||
if (step && step.skip_trigger) {
|
||||
let $skip_trigger;
|
||||
$skip_trigger = get_jquery_element_from_selector(step.skip_trigger);
|
||||
let skipping = get_first_visible_element($skip_trigger).length;
|
||||
if (skipping) {
|
||||
this._to_next_step(tour_name);
|
||||
this.update(tour_name);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Check (and activate or update) a help tooltip for a tour.
|
||||
*
|
||||
* @param {Object} tip
|
||||
* @param {string} tour_name
|
||||
* @returns {boolean} true if a tip was found and activated/updated
|
||||
*/
|
||||
_check_for_tooltip: function (tip, tour_name) {
|
||||
if (tip === undefined) {
|
||||
return true;
|
||||
}
|
||||
if ($('body').hasClass('o_ui_blocked')) {
|
||||
this._deactivate_tip(tip);
|
||||
this._log.push("blockUI is preventing the tip to be consumed");
|
||||
return false;
|
||||
}
|
||||
this.$modal_displayed = $('.modal:visible').last();
|
||||
|
||||
var $trigger;
|
||||
if (tip.in_modal !== false && this.$modal_displayed.length) {
|
||||
$trigger = this.$modal_displayed.find(tip.trigger);
|
||||
} else {
|
||||
$trigger = get_jquery_element_from_selector(tip.trigger);
|
||||
}
|
||||
var $visible_trigger = get_first_visible_element($trigger);
|
||||
|
||||
var extra_trigger = true;
|
||||
var $extra_trigger;
|
||||
if (tip.extra_trigger) {
|
||||
$extra_trigger = get_jquery_element_from_selector(tip.extra_trigger);
|
||||
extra_trigger = get_first_visible_element($extra_trigger).length;
|
||||
}
|
||||
|
||||
var $visible_alt_trigger = $();
|
||||
if (tip.alt_trigger) {
|
||||
var $alt_trigger;
|
||||
if (tip.in_modal !== false && this.$modal_displayed.length) {
|
||||
$alt_trigger = this.$modal_displayed.find(tip.alt_trigger);
|
||||
} else {
|
||||
$alt_trigger = get_jquery_element_from_selector(tip.alt_trigger);
|
||||
}
|
||||
$visible_alt_trigger = get_first_visible_element($alt_trigger);
|
||||
}
|
||||
|
||||
var triggered = $visible_trigger.length && extra_trigger;
|
||||
if (triggered) {
|
||||
if (!tip.widget) {
|
||||
this._activate_tip(tip, tour_name, $visible_trigger, $visible_alt_trigger);
|
||||
} else {
|
||||
tip.widget.update($visible_trigger, $visible_alt_trigger);
|
||||
}
|
||||
} else {
|
||||
if ($trigger.iframeContainer || ($extra_trigger && $extra_trigger.iframeContainer)) {
|
||||
var $el = $();
|
||||
if ($trigger.iframeContainer) {
|
||||
$el = $el.add($trigger.iframeContainer);
|
||||
}
|
||||
if (($extra_trigger && $extra_trigger.iframeContainer) && $trigger.iframeContainer !== $extra_trigger.iframeContainer) {
|
||||
$el = $el.add($extra_trigger.iframeContainer);
|
||||
}
|
||||
var self = this;
|
||||
$el.off('load').one('load', function () {
|
||||
$el.off('load');
|
||||
if (self.active_tooltips[tour_name] === tip) {
|
||||
self.update(tour_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
this._deactivate_tip(tip);
|
||||
|
||||
if (this.running_tour === tour_name) {
|
||||
this._log.push("_check_for_tooltip");
|
||||
this._log.push("- modal_displayed: " + this.$modal_displayed.length);
|
||||
this._log.push("- trigger '" + tip.trigger + "': " + $trigger.length);
|
||||
this._log.push("- visible trigger '" + tip.trigger + "': " + $visible_trigger.length);
|
||||
if ($extra_trigger !== undefined) {
|
||||
this._log.push("- extra_trigger '" + tip.extra_trigger + "': " + $extra_trigger.length);
|
||||
this._log.push("- visible extra_trigger '" + tip.extra_trigger + "': " + extra_trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
return !!triggered;
|
||||
},
|
||||
/**
|
||||
* Activates the provided tip for the provided tour, $anchor and $alt_trigger.
|
||||
* $alt_trigger is an alternative trigger that can consume the step. The tip is
|
||||
* however only displayed on the $anchor.
|
||||
*
|
||||
* @param {Object} tip
|
||||
* @param {String} tour_name
|
||||
* @param {jQuery} $anchor
|
||||
* @param {jQuery} $alt_trigger
|
||||
* @private
|
||||
*/
|
||||
_activate_tip: function(tip, tour_name, $anchor, $alt_trigger) {
|
||||
var tour = this.tours[tour_name];
|
||||
var tip_info = tip;
|
||||
if (tour.skip_link) {
|
||||
tip_info = _.extend(_.omit(tip_info, 'content'), {
|
||||
content: Markup`${tip.content}${tour.skip_link}`,
|
||||
event_handlers: [{
|
||||
event: 'click',
|
||||
selector: '.o_skip_tour',
|
||||
handler: tour.skip_handler.bind(this, tip),
|
||||
}],
|
||||
});
|
||||
}
|
||||
tip.widget = new Tip(this, tip_info);
|
||||
if (this.running_tour !== tour_name) {
|
||||
tip.widget.on('tip_consumed', this, this._consume_tip.bind(this, tip, tour_name));
|
||||
}
|
||||
tip.widget.attach_to($anchor, $alt_trigger).then(this._to_next_running_step.bind(this, tip, tour_name));
|
||||
},
|
||||
_deactivate_tip: function(tip) {
|
||||
if (tip && tip.widget) {
|
||||
tip.widget.destroy();
|
||||
delete tip.widget;
|
||||
}
|
||||
},
|
||||
_describeTip: function(tip) {
|
||||
return tip.content ? tip.content + ' (trigger: ' + tip.trigger + ')' : tip.trigger;
|
||||
},
|
||||
_consume_tip: function(tip, tour_name) {
|
||||
this._deactivate_tip(tip);
|
||||
this._to_next_step(tour_name);
|
||||
|
||||
var is_running = (this.running_tour === tour_name);
|
||||
if (is_running) {
|
||||
var stepDescription = this._describeTip(tip);
|
||||
console.log(_.str.sprintf("Tour %s: step '%s' succeeded", tour_name, stepDescription));
|
||||
}
|
||||
|
||||
if (this.active_tooltips[tour_name]) {
|
||||
local_storage.setItem(get_step_key(tour_name), this.tours[tour_name].current_step);
|
||||
if (is_running) {
|
||||
this._log = [];
|
||||
this._set_running_tour_timeout(tour_name, this.active_tooltips[tour_name]);
|
||||
}
|
||||
this.update(tour_name);
|
||||
} else {
|
||||
this._consume_tour(tour_name);
|
||||
}
|
||||
},
|
||||
_to_next_step: function (tour_name, inc) {
|
||||
var tour = this.tours[tour_name];
|
||||
tour.current_step += (inc !== undefined ? inc : 1);
|
||||
if (this.running_tour !== tour_name) {
|
||||
var index = _.findIndex(tour.steps.slice(tour.current_step), function (tip) {
|
||||
return !tip.auto;
|
||||
});
|
||||
if (index >= 0) {
|
||||
tour.current_step += index;
|
||||
} else {
|
||||
tour.current_step = tour.steps.length;
|
||||
}
|
||||
}
|
||||
this.active_tooltips[tour_name] = tour.steps[tour.current_step];
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} tourName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isTourConsumed(tourName) {
|
||||
return this.consumed_tours.includes(tourName);
|
||||
},
|
||||
_consume_tour: function (tour_name, error) {
|
||||
delete this.active_tooltips[tour_name];
|
||||
//display rainbow at the end of any tour
|
||||
if (this.tours[tour_name].rainbowMan && this.running_tour !== tour_name &&
|
||||
this.tours[tour_name].current_step === this.tours[tour_name].steps.length) {
|
||||
let message = this.tours[tour_name].rainbowManMessage;
|
||||
if (message) {
|
||||
message = typeof message === 'function' ? message() : message;
|
||||
} else {
|
||||
message = markup(_t('<strong><b>Good job!</b> You went through all steps of this tour.</strong>'));
|
||||
}
|
||||
const fadeout = this.tours[tour_name].fadeout;
|
||||
core.bus.trigger('show-effect', {
|
||||
type: "rainbow_man",
|
||||
message,
|
||||
fadeout,
|
||||
});
|
||||
}
|
||||
this.tours[tour_name].current_step = 0;
|
||||
local_storage.removeItem(get_step_key(tour_name));
|
||||
local_storage.removeItem(get_debugging_key(tour_name));
|
||||
if (this.running_tour === tour_name) {
|
||||
this._stop_running_tour_timeout();
|
||||
local_storage.removeItem(get_running_key());
|
||||
local_storage.removeItem(get_running_delay_key());
|
||||
this.running_tour = undefined;
|
||||
this.running_step_delay = undefined;
|
||||
if (error) {
|
||||
_.each(this._log, function (log) {
|
||||
console.log(log);
|
||||
});
|
||||
let documentHTML = document.documentElement.outerHTML;
|
||||
// Replace empty iframe tags with their content
|
||||
const iframeEls = Array.from(document.body.querySelectorAll('iframe.o_iframe'));
|
||||
if (iframeEls.length) {
|
||||
const matches = documentHTML.match(/<iframe[^>]+class=["'][^"']*\bo_iframe\b[^>]+><\/iframe>/g);
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const iframeWithContent = matches[i].replace(/></, `>${iframeEls[i].contentDocument.documentElement.outerHTML}<`);
|
||||
documentHTML = documentHTML.replace(matches[i], `\n${iframeWithContent}\n`);
|
||||
}
|
||||
}
|
||||
console.log(documentHTML);
|
||||
console.error(error); // will be displayed as error info
|
||||
} else {
|
||||
console.log(_.str.sprintf("Tour %s succeeded", tour_name));
|
||||
console.log("test successful"); // browser_js wait for message "test successful"
|
||||
}
|
||||
this._log = [];
|
||||
} else {
|
||||
var self = this;
|
||||
this._rpc({
|
||||
model: 'web_tour.tour',
|
||||
method: 'consume',
|
||||
args: [[tour_name]],
|
||||
})
|
||||
.then(function () {
|
||||
self.consumed_tours.push(tour_name);
|
||||
});
|
||||
}
|
||||
},
|
||||
_set_running_tour_timeout: function (tour_name, step) {
|
||||
this._stop_running_tour_timeout();
|
||||
this.running_tour_timeout = setTimeout((function() {
|
||||
var descr = this._describeTip(step);
|
||||
this._consume_tour(tour_name, _.str.sprintf("Tour %s failed at step %s", tour_name, descr));
|
||||
}).bind(this), (step.timeout || RUNNING_TOUR_TIMEOUT) + this.running_step_delay);
|
||||
},
|
||||
_stop_running_tour_timeout: function () {
|
||||
clearTimeout(this.running_tour_timeout);
|
||||
this.running_tour_timeout = undefined;
|
||||
},
|
||||
_to_next_running_step: function (tip, tour_name) {
|
||||
if (this.running_tour !== tour_name) return;
|
||||
var self = this;
|
||||
this._stop_running_tour_timeout();
|
||||
if (this.running_step_delay) {
|
||||
// warning: due to the delay, it may happen that the $anchor isn't
|
||||
// in the DOM anymore when exec is called, either because:
|
||||
// - it has been removed from the DOM meanwhile and the tip's
|
||||
// selector doesn't match anything anymore
|
||||
// - it has been re-rendered and thus the selector still has a match
|
||||
// in the DOM, but executing the step with that $anchor won't work
|
||||
_.delay(exec, this.running_step_delay);
|
||||
} else {
|
||||
exec();
|
||||
}
|
||||
|
||||
function exec() {
|
||||
const anchorIsInDocument = tip.widget.$anchor[0].ownerDocument.contains(tip.widget.$anchor[0]);
|
||||
const uiIsBlocked = $('body').hasClass('o_ui_blocked');
|
||||
if (!anchorIsInDocument || uiIsBlocked) {
|
||||
// trigger is no longer in the DOM, or UI is now blocked, so run the same step again
|
||||
self._deactivate_tip(self.active_tooltips[tour_name]);
|
||||
self._to_next_step(tour_name, 0);
|
||||
self.update();
|
||||
return;
|
||||
}
|
||||
var action_helper = new RunningTourActionHelper(tip.widget);
|
||||
do_before_unload(self._consume_tip.bind(self, tip, tour_name));
|
||||
|
||||
var tour = self.tours[tour_name];
|
||||
if (typeof tip.run === "function") {
|
||||
try {
|
||||
tip.run.call(tip.widget, action_helper);
|
||||
} catch (e) {
|
||||
console.error(`Tour ${tour_name} failed at step ${self._describeTip(tip)}: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
} else if (tip.run !== undefined) {
|
||||
var m = tip.run.match(/^([a-zA-Z0-9_]+) *(?:\(? *(.+?) *\)?)?$/);
|
||||
try {
|
||||
action_helper[m[1]](m[2]);
|
||||
} catch (e) {
|
||||
console.error(`Tour ${tour_name} failed at step ${self._describeTip(tip)}: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
} else if (tour.current_step === tour.steps.length - 1) {
|
||||
console.log('Tour %s: ignoring action (auto) of last step', tour_name);
|
||||
} else {
|
||||
action_helper.auto();
|
||||
}
|
||||
}
|
||||
},
|
||||
stepUtils: new TourStepUtils(this)
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { Component, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { usePosition } from "@web/core/position/position_hook";
|
||||
|
||||
/**
|
||||
* @typedef {import("./tour_pointer_state").TourPointerState} TourPointerState
|
||||
*
|
||||
* @typedef TourPointerProps
|
||||
* @property {TourPointerState} pointerState
|
||||
* @property {boolean} bounce
|
||||
*/
|
||||
|
||||
/** @extends {Component<TourPointerProps, any>} */
|
||||
export class TourPointer extends Component {
|
||||
static props = {
|
||||
pointerState: {
|
||||
type: Object,
|
||||
shape: {
|
||||
anchor: { type: HTMLElement, optional: true },
|
||||
content: { type: String, optional: true },
|
||||
isOpen: { type: Boolean, optional: true },
|
||||
isVisible: { type: Boolean, optional: true },
|
||||
isZone: { type: Boolean, optional: true },
|
||||
onClick: { type: [Function, { value: null }], optional: true },
|
||||
onMouseEnter: { type: [Function, { value: null }], optional: true },
|
||||
onMouseLeave: { type: [Function, { value: null }], optional: true },
|
||||
position: {
|
||||
type: [
|
||||
{ value: "left" },
|
||||
{ value: "right" },
|
||||
{ value: "top" },
|
||||
{ value: "bottom" },
|
||||
],
|
||||
optional: true,
|
||||
},
|
||||
rev: { type: Number, optional: true },
|
||||
},
|
||||
},
|
||||
bounce: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
bounce: true,
|
||||
};
|
||||
|
||||
static template = "web_tour.TourPointer";
|
||||
static width = 28; // in pixels
|
||||
static height = 28; // in pixels
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
const positionOptions = {
|
||||
margin: 6,
|
||||
onPositioned: (pointer, position) => {
|
||||
const popperRect = pointer.getBoundingClientRect();
|
||||
const { top, left, direction } = position;
|
||||
if (direction === "top") {
|
||||
// position from the bottom instead of the top as it is needed
|
||||
// to ensure the expand animation is properly done
|
||||
pointer.style.bottom = `${window.innerHeight - top - popperRect.height}px`;
|
||||
pointer.style.removeProperty("top");
|
||||
} else if (direction === "left") {
|
||||
// position from the right instead of the left as it is needed
|
||||
// to ensure the expand animation is properly done
|
||||
pointer.style.right = `${window.innerWidth - left - popperRect.width}px`;
|
||||
pointer.style.removeProperty("left");
|
||||
}
|
||||
},
|
||||
};
|
||||
Object.defineProperty(positionOptions, "position", {
|
||||
get: () => this.position,
|
||||
set: () => {}, // do not let the position hook change the position
|
||||
enumerable: true,
|
||||
});
|
||||
const position = usePosition(
|
||||
"pointer",
|
||||
() => this.props.pointerState.anchor,
|
||||
positionOptions
|
||||
);
|
||||
const rootRef = useRef("pointer");
|
||||
const zoneRef = useRef("zone");
|
||||
/** @type {DOMREct | null} */
|
||||
let dimensions = null;
|
||||
let lastMeasuredContent = null;
|
||||
let lastOpenState = this.isOpen;
|
||||
let lastAnchor;
|
||||
let [anchorX, anchorY] = [0, 0];
|
||||
useEffect(() => {
|
||||
const { el: pointer } = rootRef;
|
||||
const { el: zone } = zoneRef;
|
||||
if (pointer) {
|
||||
const hasContentChanged = lastMeasuredContent !== this.content;
|
||||
const hasOpenStateChanged = lastOpenState !== this.isOpen;
|
||||
lastOpenState = this.isOpen;
|
||||
|
||||
// Check is the pointed element is a zone
|
||||
if (this.props.pointerState.isZone) {
|
||||
const { anchor } = this.props.pointerState;
|
||||
let offsetLeft = 0;
|
||||
let offsetTop = 0;
|
||||
if (document !== anchor.ownerDocument) {
|
||||
const iframe = [...document.querySelectorAll("iframe")].filter(
|
||||
(e) => e.contentDocument === anchor.ownerDocument
|
||||
)[0];
|
||||
offsetLeft = iframe.getBoundingClientRect().left;
|
||||
offsetTop = iframe.getBoundingClientRect().top;
|
||||
}
|
||||
const { left, top, width, height } = anchor.getBoundingClientRect();
|
||||
zone.style.minWidth = width + "px";
|
||||
zone.style.minHeight = height + "px";
|
||||
zone.style.left = left + offsetLeft + "px";
|
||||
zone.style.top = top + offsetTop + "px";
|
||||
}
|
||||
|
||||
// Content changed: we must re-measure the dimensions of the text.
|
||||
if (hasContentChanged) {
|
||||
lastMeasuredContent = this.content;
|
||||
pointer.style.removeProperty("width");
|
||||
pointer.style.removeProperty("height");
|
||||
dimensions = pointer.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// If the content or the "is open" state changed: we must apply
|
||||
// new width and height properties
|
||||
if (hasContentChanged || hasOpenStateChanged) {
|
||||
const [width, height] = this.isOpen
|
||||
? [dimensions.width, dimensions.height]
|
||||
: [this.constructor.width, this.constructor.height];
|
||||
if (this.isOpen) {
|
||||
pointer.style.removeProperty("transition");
|
||||
} else {
|
||||
// No transition if switching from open to closed
|
||||
pointer.style.setProperty("transition", "none");
|
||||
}
|
||||
pointer.style.setProperty("width", `${width}px`);
|
||||
pointer.style.setProperty("height", `${height}px`);
|
||||
}
|
||||
|
||||
if (!this.isOpen) {
|
||||
const { anchor } = this.props.pointerState;
|
||||
if (anchor === lastAnchor) {
|
||||
const { x, y, width } = anchor.getBoundingClientRect();
|
||||
const [lastAnchorX, lastAnchorY] = [anchorX, anchorY];
|
||||
[anchorX, anchorY] = [x, y];
|
||||
// Let's just say that the anchor is static if it moved less than 1px.
|
||||
const delta = Math.sqrt(
|
||||
Math.pow(x - lastAnchorX, 2) + Math.pow(y - lastAnchorY, 2)
|
||||
);
|
||||
if (delta < 1) {
|
||||
position.lock();
|
||||
return;
|
||||
}
|
||||
const wouldOverflow = window.innerWidth - x - width / 2 < dimensions?.width;
|
||||
pointer.classList.toggle("o_expand_left", wouldOverflow);
|
||||
}
|
||||
lastAnchor = anchor;
|
||||
pointer.style.bottom = "";
|
||||
pointer.style.right = "";
|
||||
position.unlock();
|
||||
}
|
||||
} else {
|
||||
lastMeasuredContent = null;
|
||||
lastOpenState = false;
|
||||
lastAnchor = null;
|
||||
dimensions = null;
|
||||
}
|
||||
});
|
||||
this.state = useState({ triggerBelow: false });
|
||||
this.ui = useService("ui");
|
||||
const onActiveElementChanged = () => {
|
||||
const activeEl = this.ui.activeElement;
|
||||
const pointerAnchor = this.props.pointerState.anchor;
|
||||
if (pointerAnchor) {
|
||||
this.state.triggerBelow = !activeEl.contains(pointerAnchor);
|
||||
}
|
||||
};
|
||||
useBus(this.ui.bus, "active-element-changed", onActiveElementChanged);
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
return (
|
||||
this.props.pointerState.isVisible &&
|
||||
(this.ui.activeElement.contains(this.props.pointerState.anchor) ||
|
||||
!this.state.triggerBelow)
|
||||
);
|
||||
}
|
||||
|
||||
get content() {
|
||||
return this.props.pointerState.content || "";
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.props.pointerState.isOpen && this.content;
|
||||
}
|
||||
|
||||
get position() {
|
||||
return this.props.pointerState.position || "top";
|
||||
}
|
||||
|
||||
async onStopClicked() {
|
||||
await this.orm.call("res.users", "switch_tour_enabled", [false]);
|
||||
browser.location.reload();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="web_tour.TourPointer">
|
||||
<div
|
||||
t-if="this.isVisible"
|
||||
t-ref="pointer"
|
||||
t-attf-class="
|
||||
o_tour_pointer
|
||||
o_{{ position }}
|
||||
{{ isOpen ? 'o_open' : (props.bounce ? 'o_bouncing' : '') }}
|
||||
{{ props.pointerState.onClick ? 'cursor-pointer' : '' }}
|
||||
"
|
||||
t-attf-style="
|
||||
--TourPointer__width: {{ constructor.width }}px;
|
||||
--TourPointer__height: {{ constructor.height }}px;
|
||||
"
|
||||
t-on-mouseenter="props.pointerState.onMouseEnter or (() => {})"
|
||||
t-on-mouseleave="props.pointerState.onMouseLeave or (() => {})"
|
||||
t-on-click="props.pointerState.onClick or (() => {})"
|
||||
>
|
||||
<div class="o_tour_pointer_tip position-absolute" />
|
||||
<div
|
||||
class="o_tour_pointer_content rounded overflow-hidden px-3 py-2 w-100 h-100 position-relative"
|
||||
t-att-class="{ 'invisible': !isOpen }"
|
||||
>
|
||||
<span>
|
||||
<t t-out="content" />
|
||||
</span>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-link" t-on-click="onStopClicked">Stop Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_tour_dropzone position-fixed pe-none" t-if="props.pointerState.isVisible and props.pointerState.isZone" t-ref="zone" style="border: 3px dashed #714b67;"/>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { reactive } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { TourPointer } from "@web_tour/js/tour_pointer/tour_pointer";
|
||||
import { getScrollParent } from "@web_tour/js/utils/tour_utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/core/position/position_hook").Direction} Direction
|
||||
*
|
||||
* @typedef {"in" | "out-below" | "out-above" | "unknown"} IntersectionPosition
|
||||
*
|
||||
* @typedef {ReturnType<createPointerState>["methods"]} TourPointerMethods
|
||||
*
|
||||
* @typedef TourPointerState
|
||||
* @property {HTMLElement} [anchor]
|
||||
* @property {string} [content]
|
||||
* @property {boolean} [isOpen]
|
||||
* @property {() => {}} [onClick]
|
||||
* @property {() => {}} [onMouseEnter]
|
||||
* @property {() => {}} [onMouseLeave]
|
||||
* @property {boolean} isVisible
|
||||
* @property {boolean} isZone
|
||||
* @property {Direction} position
|
||||
* @property {number} rev
|
||||
*
|
||||
* @typedef {import("../tour_service").TourStep} TourStep
|
||||
*/
|
||||
|
||||
class Intersection {
|
||||
constructor() {
|
||||
/** @type {Element | null} */
|
||||
this.currentTarget = null;
|
||||
this.rootBounds = null;
|
||||
/** @type {IntersectionPosition} */
|
||||
this._targetPosition = "unknown";
|
||||
this._observer = new IntersectionObserver((observations) =>
|
||||
this._handleObservations(observations)
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {IntersectionObserverCallback} */
|
||||
_handleObservations(observations) {
|
||||
if (observations.length < 1) {
|
||||
return;
|
||||
}
|
||||
const observation = observations[observations.length - 1];
|
||||
this.rootBounds = observation.rootBounds;
|
||||
if (this.rootBounds && this.currentTarget) {
|
||||
if (observation.isIntersecting) {
|
||||
this._targetPosition = "in";
|
||||
} else {
|
||||
const scrollParentElement =
|
||||
getScrollParent(this.currentTarget) || document.documentElement;
|
||||
const targetBounds = this.currentTarget.getBoundingClientRect();
|
||||
if (targetBounds.bottom > scrollParentElement.clientHeight) {
|
||||
this._targetPosition = "out-below";
|
||||
} else if (targetBounds.top < 0) {
|
||||
this._targetPosition = "out-above";
|
||||
} else if (targetBounds.left < 0) {
|
||||
this._targetPosition = "out-left";
|
||||
} else if (targetBounds.right > scrollParentElement.clientWidth) {
|
||||
this._targetPosition = "out-right";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._targetPosition = "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
get targetPosition() {
|
||||
if (!this.rootBounds) {
|
||||
return this.currentTarget ? "in" : "unknown";
|
||||
} else {
|
||||
return this._targetPosition;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} newTarget
|
||||
*/
|
||||
setTarget(newTarget) {
|
||||
if (this.currentTarget !== newTarget) {
|
||||
if (this.currentTarget) {
|
||||
this._observer.unobserve(this.currentTarget);
|
||||
}
|
||||
if (newTarget) {
|
||||
this._observer.observe(newTarget);
|
||||
}
|
||||
this.currentTarget = newTarget;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function createPointerState() {
|
||||
/**
|
||||
* @param {Partial<TourPointerState>} newState
|
||||
*/
|
||||
const setState = (newState) => {
|
||||
Object.assign(state, newState);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {TourStep} step
|
||||
* @param {HTMLElement} [anchor]
|
||||
* @param {boolean} [isZone] will border de zone. e.g.: a dropzone
|
||||
*/
|
||||
const pointTo = (anchor, step, isZone) => {
|
||||
intersection.setTarget(anchor);
|
||||
if (anchor) {
|
||||
let { tooltipPosition, content } = step;
|
||||
switch (intersection.targetPosition) {
|
||||
case "unknown": {
|
||||
// Do nothing for unknown target position.
|
||||
break;
|
||||
}
|
||||
case "in": {
|
||||
if (document.body.contains(floatingAnchor)) {
|
||||
floatingAnchor.remove();
|
||||
}
|
||||
setState({
|
||||
anchor,
|
||||
content,
|
||||
isZone,
|
||||
onClick: null,
|
||||
position: tooltipPosition,
|
||||
isVisible: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const onClick = () => {
|
||||
anchor.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
hide();
|
||||
};
|
||||
|
||||
const scrollParent = getScrollParent(anchor);
|
||||
if (!scrollParent) {
|
||||
setState({
|
||||
anchor,
|
||||
content,
|
||||
isZone,
|
||||
onClick: null,
|
||||
position: tooltipPosition,
|
||||
isVisible: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let { x, y, width, height } = scrollParent.getBoundingClientRect();
|
||||
|
||||
// If the scrolling element is within an iframe the offsets
|
||||
// must be computed taking into account the iframe.
|
||||
const iframeEl = scrollParent.ownerDocument.defaultView.frameElement;
|
||||
if (iframeEl) {
|
||||
const iframeOffset = iframeEl.getBoundingClientRect();
|
||||
x += iframeOffset.x;
|
||||
y += iframeOffset.y;
|
||||
}
|
||||
if (intersection.targetPosition === "out-below") {
|
||||
tooltipPosition = "top";
|
||||
content = _t("Scroll down to reach the next step.");
|
||||
floatingAnchor.style.top = `${y + height - TourPointer.height}px`;
|
||||
floatingAnchor.style.left = `${x + width / 2}px`;
|
||||
} else if (intersection.targetPosition === "out-above") {
|
||||
tooltipPosition = "bottom";
|
||||
content = _t("Scroll up to reach the next step.");
|
||||
floatingAnchor.style.top = `${y + TourPointer.height}px`;
|
||||
floatingAnchor.style.left = `${x + width / 2}px`;
|
||||
}
|
||||
if (intersection.targetPosition === "out-left") {
|
||||
tooltipPosition = "right";
|
||||
content = _t("Scroll left to reach the next step.");
|
||||
floatingAnchor.style.top = `${y + height / 2}px`;
|
||||
floatingAnchor.style.left = `${x + TourPointer.width}px`;
|
||||
} else if (intersection.targetPosition === "out-right") {
|
||||
tooltipPosition = "left";
|
||||
content = _t("Scroll right to reach the next step.");
|
||||
floatingAnchor.style.top = `${y + height / 2}px`;
|
||||
floatingAnchor.style.left = `${x + width - TourPointer.width}px`;
|
||||
}
|
||||
if (!document.contains(floatingAnchor)) {
|
||||
document.body.appendChild(floatingAnchor);
|
||||
}
|
||||
setState({
|
||||
anchor: floatingAnchor,
|
||||
content,
|
||||
onClick,
|
||||
position: tooltipPosition,
|
||||
isZone,
|
||||
isVisible: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
function hide() {
|
||||
setState({ content: "", isVisible: false, isOpen: false });
|
||||
}
|
||||
|
||||
function showContent(isOpen) {
|
||||
setState({ isOpen });
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
intersection.stop();
|
||||
if (document.body.contains(floatingAnchor)) {
|
||||
floatingAnchor.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {TourPointerState} */
|
||||
const state = reactive({});
|
||||
const intersection = new Intersection();
|
||||
const floatingAnchor = document.createElement("div");
|
||||
floatingAnchor.className = "position-fixed";
|
||||
|
||||
return { state, setState, showContent, pointTo, hide, destroy };
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { queryAll, queryFirst, queryOne } from "@odoo/hoot-dom";
|
||||
import { Component, useState, useExternalListener } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { tourRecorderState } from "./tour_recorder_state";
|
||||
|
||||
const PRECISE_IDENTIFIERS = ["data-menu-xmlid", "name", "contenteditable"];
|
||||
const ODOO_CLASS_REGEX = /^oe?(-|_)[\w-]+$/;
|
||||
const VALIDATING_KEYS = ["Enter", "Tab"];
|
||||
|
||||
/**
|
||||
* @param {EventTarget[]} paths composedPath of an click event
|
||||
* @returns {string}
|
||||
*/
|
||||
const getShortestSelector = (paths) => {
|
||||
paths.reverse();
|
||||
let filteredPath = [];
|
||||
let hasOdooClass = false;
|
||||
for (
|
||||
let currentElem = paths.pop();
|
||||
(currentElem && queryAll(filteredPath.join(" > ")).length !== 1) || !hasOdooClass;
|
||||
currentElem = paths.pop()
|
||||
) {
|
||||
if (currentElem.parentElement.contentEditable === "true") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentPredicate = currentElem.tagName.toLowerCase();
|
||||
const odooClass = [...currentElem.classList].find((c) => c.match(ODOO_CLASS_REGEX));
|
||||
if (odooClass) {
|
||||
currentPredicate = `.${odooClass}`;
|
||||
hasOdooClass = true;
|
||||
}
|
||||
|
||||
// If we are inside a link or button the previous elements, like <i></i>, <span></span>, etc., can be removed
|
||||
if (["BUTTON", "A"].includes(currentElem.tagName)) {
|
||||
filteredPath = [];
|
||||
}
|
||||
|
||||
for (const identifier of PRECISE_IDENTIFIERS) {
|
||||
const identifierValue = currentElem.getAttribute(identifier);
|
||||
if (identifierValue) {
|
||||
currentPredicate += `[${identifier}='${CSS.escape(identifierValue)}']`;
|
||||
}
|
||||
}
|
||||
|
||||
const siblingNodes = queryAll(":scope > " + currentPredicate, {
|
||||
root: currentElem.parentElement,
|
||||
});
|
||||
if (siblingNodes.length > 1) {
|
||||
currentPredicate += `:nth-child(${
|
||||
[...currentElem.parentElement.children].indexOf(currentElem) + 1
|
||||
})`;
|
||||
}
|
||||
|
||||
filteredPath.unshift(currentPredicate);
|
||||
}
|
||||
|
||||
if (filteredPath.length > 2) {
|
||||
return reducePath(filteredPath);
|
||||
}
|
||||
|
||||
return filteredPath.join(" > ");
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string[]} paths
|
||||
* @returns {string}
|
||||
*/
|
||||
const reducePath = (paths) => {
|
||||
const numberOfElement = paths.length - 2;
|
||||
let currentElement = "";
|
||||
let hasReduced = false;
|
||||
let path = paths.shift();
|
||||
for (let i = 0; i < numberOfElement; i++) {
|
||||
currentElement = paths.shift();
|
||||
if (queryAll(`${path} ${paths.join(" > ")}`).length === 1) {
|
||||
hasReduced = true;
|
||||
} else {
|
||||
path += `${hasReduced ? " " : " > "}${currentElement}`;
|
||||
hasReduced = false;
|
||||
}
|
||||
}
|
||||
path += `${hasReduced ? " " : " > "}${paths.shift()}`;
|
||||
return path;
|
||||
};
|
||||
|
||||
export class TourRecorder extends Component {
|
||||
static template = "web_tour.TourRecorder";
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = {
|
||||
onClose: { type: Function },
|
||||
};
|
||||
static defaultState = {
|
||||
recording: false,
|
||||
url: "",
|
||||
editedElement: undefined,
|
||||
tourName: "",
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.originClickEvent = false;
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.state = useState({
|
||||
...TourRecorder.defaultState,
|
||||
steps: [],
|
||||
});
|
||||
|
||||
this.state.steps = tourRecorderState.getCurrentTourRecorder();
|
||||
this.state.recording = tourRecorderState.isRecording() === "1";
|
||||
useExternalListener(document, "pointerdown", this.setStartingEvent, { capture: true });
|
||||
useExternalListener(document, "pointerup", this.recordClickEvent, { capture: true });
|
||||
useExternalListener(document, "keydown", this.recordConfirmationKeyboardEvent, {
|
||||
capture: true,
|
||||
});
|
||||
useExternalListener(document, "keyup", this.recordKeyboardEvent, { capture: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
setStartingEvent(ev) {
|
||||
if (!this.state.recording || ev.target.closest(".o_tour_recorder")) {
|
||||
return;
|
||||
}
|
||||
this.originClickEvent = ev.composedPath().filter((p) => p instanceof Element);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
recordClickEvent(ev) {
|
||||
if (!this.state.recording || ev.target.closest(".o_tour_recorder")) {
|
||||
return;
|
||||
}
|
||||
const pathElements = ev.composedPath().filter((p) => p instanceof Element);
|
||||
this.addTourStep([...pathElements]);
|
||||
|
||||
const lastStepInput = this.state.steps.at(-1);
|
||||
// Check that pointerdown and pointerup paths are different to know if it's a drag&drop or a click
|
||||
if (
|
||||
JSON.stringify(pathElements.map((e) => e.tagName)) !==
|
||||
JSON.stringify(this.originClickEvent.map((e) => e.tagName))
|
||||
) {
|
||||
lastStepInput.run = `drag_and_drop ${lastStepInput.trigger}`;
|
||||
lastStepInput.trigger = getShortestSelector(this.originClickEvent);
|
||||
} else {
|
||||
const lastStepInput = this.state.steps.at(-1);
|
||||
lastStepInput.run = "click";
|
||||
}
|
||||
|
||||
tourRecorderState.setCurrentTourRecorder(this.state.steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
recordConfirmationKeyboardEvent(ev) {
|
||||
if (
|
||||
!this.state.recording ||
|
||||
!this.state.editedElement ||
|
||||
ev.target.closest(".o_tour_recorder")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
[...this.state.editedElement.classList].includes("o-autocomplete--input") &&
|
||||
VALIDATING_KEYS.includes(ev.key)
|
||||
) {
|
||||
const selectedRow = queryFirst(".ui-state-active", {
|
||||
root: this.state.editedElement.parentElement,
|
||||
});
|
||||
this.state.steps.push({
|
||||
trigger: `.o-autocomplete--dropdown-item > a:contains('${selectedRow.textContent}'), .fa-circle-o-notch`,
|
||||
run: "click",
|
||||
});
|
||||
this.state.editedElement = undefined;
|
||||
}
|
||||
tourRecorderState.setCurrentTourRecorder(this.state.steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
recordKeyboardEvent(ev) {
|
||||
if (
|
||||
!this.state.recording ||
|
||||
VALIDATING_KEYS.includes(ev.key) ||
|
||||
ev.target.closest(".o_tour_recorder")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.editedElement) {
|
||||
if (
|
||||
ev.target.matches(
|
||||
"input:not(:disabled), textarea:not(:disabled), [contenteditable=true]"
|
||||
)
|
||||
) {
|
||||
this.state.editedElement = ev.target;
|
||||
this.state.steps.push({
|
||||
trigger: getShortestSelector(ev.composedPath()),
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.editedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastStep = this.state.steps.at(-1);
|
||||
if (this.state.editedElement.contentEditable === "true") {
|
||||
lastStep.run = `editor ${this.state.editedElement.textContent}`;
|
||||
} else {
|
||||
lastStep.run = `edit ${this.state.editedElement.value}`;
|
||||
}
|
||||
tourRecorderState.setCurrentTourRecorder(this.state.steps);
|
||||
}
|
||||
|
||||
toggleRecording() {
|
||||
this.state.recording = !this.state.recording;
|
||||
tourRecorderState.setIsRecording(this.state.recording);
|
||||
this.state.editedElement = undefined;
|
||||
if (this.state.recording && !this.state.url) {
|
||||
this.state.url = browser.location.pathname + browser.location.search;
|
||||
}
|
||||
}
|
||||
|
||||
async saveTour() {
|
||||
const newTour = {
|
||||
name: this.state.tourName.replaceAll(" ", "_"),
|
||||
url: this.state.url,
|
||||
step_ids: this.state.steps.map((s) => x2ManyCommands.create(undefined, s)),
|
||||
custom: true,
|
||||
};
|
||||
|
||||
const result = await this.orm.create("web_tour.tour", [newTour]);
|
||||
if (result) {
|
||||
this.notification.add(_t("Custom tour '%s' has been added.", newTour.name), {
|
||||
type: "success",
|
||||
});
|
||||
this.resetTourRecorderState();
|
||||
} else {
|
||||
this.notification.add(_t("Custom tour '%s' couldn't be saved!", newTour.name), {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetTourRecorderState() {
|
||||
Object.assign(this.state, { ...TourRecorder.defaultState, steps: [] });
|
||||
tourRecorderState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element[]} path
|
||||
*/
|
||||
addTourStep(path) {
|
||||
const shortestPath = getShortestSelector(path);
|
||||
const target = queryOne(shortestPath);
|
||||
this.state.editedElement =
|
||||
target.matches(
|
||||
"input:not(:disabled), textarea:not(:disabled), [contenteditable=true]"
|
||||
) && target;
|
||||
this.state.steps.push({
|
||||
trigger: shortestPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="web_tour.TourRecorder">
|
||||
<div class="d-flex position-fixed bottom-0 start-0 bg-primary o_tour_recorder">
|
||||
<div t-ref="tour_recorder" class="d-flex">
|
||||
<button class="o_button_record btn btn-primary rounded-0" t-on-click.prevent.stop="toggleRecording">
|
||||
<span class="px-2 me-1 rounded-circle" t-att-class="state.recording ? 'bg-danger': 'bg-secondary'" role="status" aria-hidden="true"></span>
|
||||
Record
|
||||
<span class="fst-italic fw-lighter" t-if="state.editedElement"> (recording keyboard)</span>
|
||||
</button>
|
||||
<Dropdown position="'top-end'">
|
||||
<button class="o_button_steps btn btn-primary rounded-0">
|
||||
Steps <span class="badge rounded-pill bg-danger"><t t-esc="state.steps.length"/></span>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div class="o_tour_recorder p-2">
|
||||
<h4>Steps:</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>n°</td>
|
||||
<td>trigger</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.steps" t-as="step" t-key="step_index">
|
||||
<tr class="o_tour_step" t-att-class="step.triggerNotUnique ? 'text-danger' : ''">
|
||||
<td><t t-esc="step_index + 1"/>.</td>
|
||||
<td class="o_tour_step_trigger">
|
||||
<t t-esc="step.trigger"/>
|
||||
<span t-if="step.run and step.run != 'click'" class="fst-italic fw-lighter"><br/>(run: <t t-esc="step.run"/>) </span>
|
||||
</td>
|
||||
<td><button class="o_button_delete_step btn btn-link text-danger fa fa-trash mx-1" t-on-click.prevent.stop="() => state.steps.splice(step_index, 1)"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<Dropdown t-if="state.steps.length" position="'top-end'">
|
||||
<button class="o_button_save btn btn-primary px-1 rounded-0">
|
||||
<i class="fa fa-floppy-o"></i>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div class="o_tour_recorder p-1" style="min-width: 30vw;">
|
||||
<form class="p-1" t-on-submit.prevent="saveTour">
|
||||
<label for="name" class="o_form_label my-1">Name:</label><br/>
|
||||
<input t-att-value="state.tourName" t-on-change="(ev) => state.tourName = ev.target.value" class="o_input" placeholder="name_of_the_tour" type="text" name="name"/>
|
||||
<label for="url" class="o_form_label my-1">Url:</label><br/>
|
||||
<input t-att-value="state.url" t-on-change="(ev) => state.url = ev.target.value" class="o_input" type="text" name="url"/>
|
||||
<button class="o_button_save_confirm btn btn-primary mt-3">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<button t-if="state.steps.length" class="btn btn-primary px-1" t-on-click="resetTourRecorderState"><i class="fa fa-undo"></i></button>
|
||||
<button class="btn btn-primary position-absolute bottom-0 start-100 rounded-0 border-1 o_tour_recorder_close_button" t-on-click.prevent.stop="() => props.onClose()"><i class="fa fa-close"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
const CURRENT_TOUR_RECORDER_LOCAL_STORAGE = "current_tour_recorder";
|
||||
const CURRENT_TOUR_RECORDER_RECORD_LOCAL_STORAGE = "current_tour_recorder.record";
|
||||
export const TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY = "tour_recorder_active";
|
||||
|
||||
/**
|
||||
* Wrapper around localStorage for persistence of the current recording.
|
||||
* Useful for resuming recording when the page refreshed.
|
||||
*/
|
||||
export const tourRecorderState = {
|
||||
isRecording() {
|
||||
return browser.localStorage.getItem(CURRENT_TOUR_RECORDER_RECORD_LOCAL_STORAGE) || "0";
|
||||
},
|
||||
setIsRecording(isRecording) {
|
||||
browser.localStorage.setItem(
|
||||
CURRENT_TOUR_RECORDER_RECORD_LOCAL_STORAGE,
|
||||
isRecording ? "1" : "0"
|
||||
);
|
||||
},
|
||||
setCurrentTourRecorder(tour) {
|
||||
tour = JSON.stringify(tour);
|
||||
browser.localStorage.setItem(CURRENT_TOUR_RECORDER_LOCAL_STORAGE, tour);
|
||||
},
|
||||
getCurrentTourRecorder() {
|
||||
const tour = browser.localStorage.getItem(CURRENT_TOUR_RECORDER_LOCAL_STORAGE) || "[]";
|
||||
return JSON.parse(tour);
|
||||
},
|
||||
clear() {
|
||||
browser.localStorage.removeItem(CURRENT_TOUR_RECORDER_LOCAL_STORAGE);
|
||||
browser.localStorage.removeItem(CURRENT_TOUR_RECORDER_RECORD_LOCAL_STORAGE);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,242 +1,294 @@
|
|||
odoo.define('web_tour.tour', function (require) {
|
||||
"use strict";
|
||||
import { Component, markup, whenReady, validate } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { createPointerState } from "@web_tour/js/tour_pointer/tour_pointer_state";
|
||||
import { tourState } from "@web_tour/js/tour_state";
|
||||
import { callWithUnloadCheck } from "@web_tour/js/utils/tour_utils";
|
||||
import {
|
||||
tourRecorderState,
|
||||
TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY,
|
||||
} from "@web_tour/js/tour_recorder/tour_recorder_state";
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
|
||||
var rootWidget = require('root.widget');
|
||||
var rpc = require('web.rpc');
|
||||
var session = require('web.session');
|
||||
var TourManager = require('web_tour.TourManager');
|
||||
const { device } = require('web.config');
|
||||
class OnboardingItem extends Component {
|
||||
static components = { DropdownItem };
|
||||
static template = "web_tour.OnboardingItem";
|
||||
static props = {
|
||||
toursEnabled: { type: Boolean },
|
||||
toggleItem: { type: Function },
|
||||
};
|
||||
setup() {}
|
||||
}
|
||||
|
||||
const untrackedClassnames = ["o_tooltip", "o_tooltip_content", "o_tooltip_overlay"];
|
||||
const StepSchema = {
|
||||
id: { type: [String], optional: true },
|
||||
content: { type: [String, Object], optional: true }, //allow object(_t && markup)
|
||||
debugHelp: { type: String, optional: true },
|
||||
isActive: { type: Array, element: String, optional: true },
|
||||
run: { type: [String, Function, Boolean], optional: true },
|
||||
timeout: {
|
||||
optional: true,
|
||||
validate(value) {
|
||||
return value >= 0 && value <= 60000;
|
||||
},
|
||||
},
|
||||
tooltipPosition: {
|
||||
optional: true,
|
||||
validate(value) {
|
||||
return ["top", "bottom", "left", "right"].includes(value);
|
||||
},
|
||||
},
|
||||
trigger: { type: String },
|
||||
expectUnloadPage: { type: Boolean, optional: true },
|
||||
//ONLY IN DEBUG MODE
|
||||
pause: { type: Boolean, optional: true },
|
||||
break: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace
|
||||
* @property {Object} active_tooltips
|
||||
* @property {Object} tours
|
||||
* @property {Array} consumed_tours
|
||||
* @property {String} running_tour
|
||||
* @property {Number} running_step_delay
|
||||
* @property {'community' | 'enterprise'} edition
|
||||
* @property {Array} _log
|
||||
*/
|
||||
return session.is_bound.then(function () {
|
||||
var defs = [];
|
||||
// Load the list of consumed tours and the tip template only if we are admin, in the frontend,
|
||||
// tours being only available for the admin. For the backend, the list of consumed is directly
|
||||
// in the page source.
|
||||
if (session.is_frontend && session.is_admin) {
|
||||
var def = rpc.query({
|
||||
model: 'web_tour.tour',
|
||||
method: 'get_consumed_tours',
|
||||
});
|
||||
defs.push(def);
|
||||
}
|
||||
return Promise.all(defs).then(function (results) {
|
||||
var consumed_tours = session.is_frontend ? results[0] : session.web_tours;
|
||||
const disabled = session.tour_disable || device.isMobile;
|
||||
var tour_manager = new TourManager(rootWidget, consumed_tours, disabled);
|
||||
const TourSchema = {
|
||||
name: { type: String, optional: true },
|
||||
steps: Function,
|
||||
url: { type: String, optional: true },
|
||||
wait_for: { type: [Function, Object], optional: true },
|
||||
};
|
||||
|
||||
// The tests can be loaded inside an iframe. The tour manager should
|
||||
// not run in that context, as it will already run in its parent
|
||||
// window.
|
||||
const isInIframe = window.frameElement && window.frameElement.classList.contains('o_iframe');
|
||||
if (isInIframe) {
|
||||
return tour_manager;
|
||||
}
|
||||
registry.category("web_tour.tours").addValidation(TourSchema);
|
||||
const debugMenuRegistry = registry.category("debug").category("default");
|
||||
|
||||
function _isTrackedNode(node) {
|
||||
if (node.classList) {
|
||||
return !untrackedClassnames
|
||||
.some(className => node.classList.contains(className));
|
||||
export const tourService = {
|
||||
// localization dependency to make sure translations used by tours are loaded
|
||||
dependencies: ["orm", "effect", "overlay", "localization"],
|
||||
start: async (env, { orm, effect, overlay }) => {
|
||||
await whenReady();
|
||||
let toursEnabled = session?.tour_enabled;
|
||||
const tourRegistry = registry.category("web_tour.tours");
|
||||
const pointer = createPointerState();
|
||||
pointer.stop = () => {};
|
||||
|
||||
debugMenuRegistry.add("onboardingItem", () => ({
|
||||
type: "component",
|
||||
Component: OnboardingItem,
|
||||
props: {
|
||||
toursEnabled: toursEnabled || false,
|
||||
toggleItem: async () => {
|
||||
tourState.clear();
|
||||
toursEnabled = await orm.call("res.users", "switch_tour_enabled", [
|
||||
!toursEnabled,
|
||||
]);
|
||||
browser.location.reload();
|
||||
},
|
||||
},
|
||||
sequence: 500,
|
||||
section: "testing",
|
||||
}));
|
||||
|
||||
function getTourFromRegistry(tourName) {
|
||||
if (!tourRegistry.contains(tourName)) {
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
const tour = tourRegistry.get(tourName);
|
||||
return {
|
||||
...tour,
|
||||
steps: tour.steps(),
|
||||
name: tourName,
|
||||
wait_for: tour.wait_for || Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
const classSplitRegex = /\s+/g;
|
||||
const tooltipParentRegex = /\bo_tooltip_parent\b/;
|
||||
let currentMutations = [];
|
||||
function _processMutations() {
|
||||
const hasTrackedMutation = currentMutations.some(mutation => {
|
||||
// First check if the mutation applied on an element we do not
|
||||
// track (like the tour tips themself).
|
||||
if (!_isTrackedNode(mutation.target)) {
|
||||
return false;
|
||||
}
|
||||
async function getTourFromDB(tourName) {
|
||||
const tour = await orm.call("web_tour.tour", "get_tour_json_by_name", [tourName]);
|
||||
if (!tour) {
|
||||
throw new Error(`Tour '${tourName}' is not found in the database.`);
|
||||
}
|
||||
|
||||
if (mutation.type === 'characterData') {
|
||||
return true;
|
||||
}
|
||||
if (!tour.steps.length && tourRegistry.contains(tour.name)) {
|
||||
tour.steps = tourRegistry.get(tour.name).steps();
|
||||
}
|
||||
|
||||
if (mutation.type === 'childList') {
|
||||
// If it is a modification to the DOM hierarchy, only
|
||||
// consider the addition/removal of tracked nodes.
|
||||
for (const nodes of [mutation.addedNodes, mutation.removedNodes]) {
|
||||
for (const node of nodes) {
|
||||
if (_isTrackedNode(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else if (mutation.type === 'attributes') {
|
||||
// Get old and new value of the attribute. Note: as we
|
||||
// compute the new value after a setTimeout, this might not
|
||||
// actually be the new value for that particular mutation
|
||||
// record but this is the one after all mutations. This is
|
||||
// normally not an issue: e.g. "a" -> "a b" -> "a" will be
|
||||
// seen as "a" -> "a" (not "a b") + "a b" -> "a" but we
|
||||
// only need to detect *one* tracked mutation to know we
|
||||
// have to update tips anyway.
|
||||
const oldV = mutation.oldValue ? mutation.oldValue.trim() : '';
|
||||
const newV = (mutation.target.getAttribute(mutation.attributeName) || '').trim();
|
||||
return tour;
|
||||
}
|
||||
|
||||
// Not sure why but this occurs, especially on ID change
|
||||
// (probably some strange jQuery behavior, see below).
|
||||
// Also sometimes, a class is just considered changed while
|
||||
// it just loses the spaces around the class names.
|
||||
if (oldV === newV) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mutation.attributeName === 'id') {
|
||||
// Check if this is not an ID change done by jQuery for
|
||||
// performance reasons.
|
||||
return !(oldV.includes('sizzle') || newV.includes('sizzle'));
|
||||
} else if (mutation.attributeName === 'class') {
|
||||
// Check if the change is *only* about receiving or
|
||||
// losing the 'o_tooltip_parent' class, which is linked
|
||||
// to the tour service system. We have to check the
|
||||
// potential addition of another class as we compute
|
||||
// the new value after a setTimeout. So this case:
|
||||
// 'a' -> 'a b' -> 'a b o_tooltip_parent' produces 2
|
||||
// mutation records but will be seen here as
|
||||
// 1) 'a' -> 'a b o_tooltip_parent'
|
||||
// 2) 'a b' -> 'a b o_tooltip_parent'
|
||||
const hadClass = tooltipParentRegex.test(oldV);
|
||||
const newClasses = mutation.target.classList;
|
||||
const hasClass = newClasses.contains('o_tooltip_parent');
|
||||
return !(hadClass !== hasClass
|
||||
&& Math.abs(oldV.split(classSplitRegex).length - newClasses.length) === 1);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Either all the mutations have been ignored or one was detected as
|
||||
// tracked and will trigger a tour manager update.
|
||||
currentMutations = [];
|
||||
|
||||
// Update the tour manager if required.
|
||||
if (hasTrackedMutation) {
|
||||
tour_manager.update();
|
||||
function validateStep(step) {
|
||||
try {
|
||||
validate(step, StepSchema);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error in schema for TourStep ${JSON.stringify(step, null, 4)}\n${
|
||||
error.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use a MutationObserver to detect DOM changes. When a mutation occurs,
|
||||
// only add it to the list of mutations to process and delay the
|
||||
// mutation processing. We have to record them all and not in a
|
||||
// debounced way otherwise we may ignore tracked ones in a serie of
|
||||
// 10 tracked mutations followed by an untracked one. Most of them
|
||||
// will trigger a tip check anyway so, most of the time, processing the
|
||||
// first ones will be enough to ensure that a tip update has to be done.
|
||||
let mutationTimer;
|
||||
const observer = new MutationObserver(mutations => {
|
||||
clearTimeout(mutationTimer);
|
||||
currentMutations = currentMutations.concat(mutations);
|
||||
mutationTimer = setTimeout(() => _processMutations(), 750);
|
||||
});
|
||||
async function startTour(tourName, options = {}) {
|
||||
pointer.stop();
|
||||
const tourFromRegistry = getTourFromRegistry(tourName);
|
||||
|
||||
// Now that the observer is configured, we have to start it when needed.
|
||||
const observerOptions = {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeOldValue: true,
|
||||
characterData: true,
|
||||
if (!tourFromRegistry && !options.fromDB) {
|
||||
// Sometime tours are not loaded depending on the modules.
|
||||
// For example, point_of_sale do not load all tours assets.
|
||||
return;
|
||||
}
|
||||
|
||||
const tour = options.fromDB ? { name: tourName, url: options.url } : tourFromRegistry;
|
||||
if (!session.is_public && !toursEnabled && options.mode === "manual") {
|
||||
toursEnabled = await orm.call("res.users", "switch_tour_enabled", [!toursEnabled]);
|
||||
}
|
||||
|
||||
let tourConfig = {
|
||||
delayToCheckUndeterminisms: 0,
|
||||
stepDelay: 0,
|
||||
keepWatchBrowser: false,
|
||||
mode: "auto",
|
||||
showPointerDuration: 0,
|
||||
debug: false,
|
||||
redirect: true,
|
||||
};
|
||||
|
||||
tourConfig = Object.assign(tourConfig, options);
|
||||
tourState.setCurrentConfig(tourConfig);
|
||||
tourState.setCurrentTour(tour.name);
|
||||
tourState.setCurrentIndex(0);
|
||||
|
||||
const willUnload = callWithUnloadCheck(() => {
|
||||
if (tour.url && tourConfig.startUrl != tour.url && tourConfig.redirect) {
|
||||
redirect(tour.url);
|
||||
}
|
||||
});
|
||||
if (!willUnload) {
|
||||
await resumeTour();
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeTour() {
|
||||
const tourName = tourState.getCurrentTour();
|
||||
const tourConfig = tourState.getCurrentConfig();
|
||||
|
||||
let tour = getTourFromRegistry(tourName);
|
||||
if (tourConfig.fromDB) {
|
||||
tour = await getTourFromDB(tourName);
|
||||
}
|
||||
if (!tour) {
|
||||
return;
|
||||
}
|
||||
|
||||
tour.steps.forEach((step) => validateStep(step));
|
||||
|
||||
if (tourConfig.mode === "auto") {
|
||||
if (!odoo.loader.modules.get("@web_tour/js/tour_automatic/tour_automatic")) {
|
||||
await loadBundle("web_tour.automatic", { css: false });
|
||||
}
|
||||
const { TourAutomatic } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_automatic/tour_automatic"
|
||||
);
|
||||
new TourAutomatic(tour).start();
|
||||
} else {
|
||||
await loadBundle("web_tour.interactive");
|
||||
const { TourPointer } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_pointer/tour_pointer"
|
||||
);
|
||||
pointer.stop = overlay.add(
|
||||
TourPointer,
|
||||
{
|
||||
pointerState: pointer.state,
|
||||
bounce: !(tourConfig.mode === "auto" && tourConfig.keepWatchBrowser),
|
||||
},
|
||||
{
|
||||
sequence: 1100, // sequence based on bootstrap z-index values.
|
||||
}
|
||||
);
|
||||
const { TourInteractive } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_interactive/tour_interactive"
|
||||
);
|
||||
new TourInteractive(tour).start(env, pointer, async () => {
|
||||
pointer.stop();
|
||||
tourState.clear();
|
||||
browser.console.log("tour succeeded");
|
||||
let message = tourConfig.rainbowManMessage || tour.rainbowManMessage;
|
||||
if (message) {
|
||||
message = window.DOMPurify.sanitize(tourConfig.rainbowManMessage);
|
||||
effect.add({
|
||||
type: "rainbow_man",
|
||||
message: markup(message),
|
||||
});
|
||||
}
|
||||
|
||||
const nextTour = await orm.call("web_tour.tour", "consume", [tour.name]);
|
||||
if (nextTour) {
|
||||
startTour(nextTour.name, {
|
||||
mode: "manual",
|
||||
redirect: false,
|
||||
rainbowManMessage: nextTour.rainbowManMessage,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tourRecorder() {
|
||||
await loadBundle("web_tour.recorder");
|
||||
const { TourRecorder } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_recorder/tour_recorder"
|
||||
);
|
||||
const remove = overlay.add(
|
||||
TourRecorder,
|
||||
{
|
||||
onClose: () => {
|
||||
remove();
|
||||
browser.localStorage.removeItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY);
|
||||
tourRecorderState.clear();
|
||||
},
|
||||
},
|
||||
{ sequence: 99999 }
|
||||
);
|
||||
}
|
||||
|
||||
async function startTourRecorder() {
|
||||
if (!browser.localStorage.getItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY)) {
|
||||
await tourRecorder();
|
||||
}
|
||||
browser.localStorage.setItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY, "1");
|
||||
}
|
||||
|
||||
if (!window.frameElement) {
|
||||
const paramsTourName = new URLSearchParams(browser.location.search).get("tour");
|
||||
if (paramsTourName) {
|
||||
startTour(paramsTourName, { mode: "manual", fromDB: true });
|
||||
}
|
||||
|
||||
if (tourState.getCurrentTour()) {
|
||||
if (tourState.getCurrentConfig().mode === "auto" || toursEnabled) {
|
||||
resumeTour();
|
||||
} else {
|
||||
tourState.clear();
|
||||
}
|
||||
} else if (session.current_tour) {
|
||||
startTour(session.current_tour.name, {
|
||||
mode: "manual",
|
||||
redirect: false,
|
||||
rainbowManMessage: session.current_tour.rainbowManMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
browser.localStorage.getItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY) &&
|
||||
!session.is_public
|
||||
) {
|
||||
await tourRecorder();
|
||||
}
|
||||
}
|
||||
|
||||
odoo.startTour = startTour;
|
||||
odoo.isTourReady = (tourName) => getTourFromRegistry(tourName).wait_for.then(() => true);
|
||||
|
||||
return {
|
||||
startTour,
|
||||
startTourRecorder,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
var start_service = (function () {
|
||||
return function (observe) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
tour_manager._register_all(observe).then(function () {
|
||||
if (observe) {
|
||||
observer.observe(document.body, observerOptions);
|
||||
|
||||
// If an iframe is added during the tour, its DOM
|
||||
// mutations should also be observed to update the
|
||||
// tour manager.
|
||||
const findIframe = mutations => {
|
||||
for (const mutation of mutations) {
|
||||
for (const addedNode of Array.from(mutation.addedNodes)) {
|
||||
if (addedNode.nodeType === Node.ELEMENT_NODE) {
|
||||
if (addedNode.classList.contains('o_iframe')) {
|
||||
return addedNode;
|
||||
}
|
||||
const iframeChildEl = addedNode.querySelector('.o_iframe');
|
||||
if (iframeChildEl) {
|
||||
return iframeChildEl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const iframeObserver = new MutationObserver(mutations => {
|
||||
const iframeEl = findIframe(mutations);
|
||||
if (iframeEl) {
|
||||
iframeEl.addEventListener('load', () => {
|
||||
observer.observe(iframeEl.contentDocument, observerOptions);
|
||||
});
|
||||
// If the iframe was added without a src,
|
||||
// its load event was immediately fired and
|
||||
// will not fire again unless another src is
|
||||
// set. Unfortunately, the case of this
|
||||
// happening and the iframe content being
|
||||
// altered programmaticaly may happen.
|
||||
// (E.g. at the moment this was written,
|
||||
// the mass mailing editor iframe is added
|
||||
// without src and its content rewritten
|
||||
// immediately afterwards).
|
||||
if (!iframeEl.src) {
|
||||
observer.observe(iframeEl.contentDocument, observerOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
iframeObserver.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
// Enable the MutationObserver for the admin or if a tour is running, when the DOM is ready
|
||||
start_service(session.is_admin || tour_manager.running_tour);
|
||||
|
||||
// Override the TourManager so that it enables/disables the observer when necessary
|
||||
if (!session.is_admin) {
|
||||
var run = tour_manager.run;
|
||||
tour_manager.run = function () {
|
||||
var self = this;
|
||||
var args = arguments;
|
||||
|
||||
start_service(true).then(function () {
|
||||
run.apply(self, args);
|
||||
if (!self.running_tour) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
};
|
||||
var _consume_tour = tour_manager._consume_tour;
|
||||
tour_manager._consume_tour = function () {
|
||||
_consume_tour.apply(this, arguments);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
// helper to start a tour manually (or from a python test with its counterpart start_tour function)
|
||||
odoo.startTour = tour_manager.run.bind(tour_manager);
|
||||
return tour_manager;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
registry.category("services").add("tour_service", tourService);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
const CURRENT_TOUR_LOCAL_STORAGE = "current_tour";
|
||||
const CURRENT_TOUR_CONFIG_LOCAL_STORAGE = "current_tour.config";
|
||||
const CURRENT_TOUR_INDEX_LOCAL_STORAGE = "current_tour.index";
|
||||
const CURRENT_TOUR_ON_ERROR_LOCAL_STORAGE = "current_tour.on_error";
|
||||
|
||||
/**
|
||||
* Wrapper around localStorage for persistence of the running tours.
|
||||
* Useful for resuming running tours when the page refreshed.
|
||||
*/
|
||||
export const tourState = {
|
||||
getCurrentTour() {
|
||||
return browser.localStorage.getItem(CURRENT_TOUR_LOCAL_STORAGE);
|
||||
},
|
||||
setCurrentTour(tourName) {
|
||||
browser.localStorage.setItem(CURRENT_TOUR_LOCAL_STORAGE, tourName);
|
||||
},
|
||||
getCurrentIndex() {
|
||||
const index = browser.localStorage.getItem(CURRENT_TOUR_INDEX_LOCAL_STORAGE, "0");
|
||||
return parseInt(index, 10);
|
||||
},
|
||||
setCurrentIndex(index) {
|
||||
browser.localStorage.setItem(CURRENT_TOUR_INDEX_LOCAL_STORAGE, index.toString());
|
||||
},
|
||||
getCurrentConfig() {
|
||||
const config = browser.localStorage.getItem(CURRENT_TOUR_CONFIG_LOCAL_STORAGE, "{}");
|
||||
return JSON.parse(config);
|
||||
},
|
||||
setCurrentConfig(config) {
|
||||
config = JSON.stringify(config);
|
||||
browser.localStorage.setItem(CURRENT_TOUR_CONFIG_LOCAL_STORAGE, config);
|
||||
},
|
||||
getCurrentTourOnError() {
|
||||
return browser.localStorage.getItem(CURRENT_TOUR_ON_ERROR_LOCAL_STORAGE);
|
||||
},
|
||||
setCurrentTourOnError() {
|
||||
browser.localStorage.setItem(CURRENT_TOUR_ON_ERROR_LOCAL_STORAGE, "1");
|
||||
},
|
||||
clear() {
|
||||
browser.localStorage.removeItem(CURRENT_TOUR_ON_ERROR_LOCAL_STORAGE);
|
||||
browser.localStorage.removeItem(CURRENT_TOUR_CONFIG_LOCAL_STORAGE);
|
||||
browser.localStorage.removeItem(CURRENT_TOUR_INDEX_LOCAL_STORAGE);
|
||||
browser.localStorage.removeItem(CURRENT_TOUR_LOCAL_STORAGE);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { session } from "@web/session";
|
||||
import { utils } from "@web/core/ui/ui_service";
|
||||
import * as hoot from "@odoo/hoot-dom";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
/**
|
||||
* @typedef TourStep
|
||||
* @property {"enterprise"|"community"|"mobile"|"desktop"|HootSelector[][]} isActive Active the step following {@link isActiveStep} filter
|
||||
* @property {string} [id]
|
||||
* @property {HootSelector} trigger The node on which the action will be executed.
|
||||
* @property {string} [content] Description of the step.
|
||||
* @property {"top" | "bottom" | "left" | "right"} [position] The position where the UI helper is shown.
|
||||
* @property {RunCommand} [run] The action to perform when trigger conditions are verified.
|
||||
* @property {number} [timeout] By default, when the trigger node isn't found after 10000 milliseconds, it throws an error.
|
||||
* You can change this value to lengthen or shorten the time before the error occurs [ms].
|
||||
*/
|
||||
export class TourStep {
|
||||
constructor(data, tour) {
|
||||
Object.assign(this, data);
|
||||
this.tour = tour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a step is active dependant on step.isActive property
|
||||
* Note that when step.isActive is not defined, the step is active by default.
|
||||
* When a step is not active, it's just skipped and the tour continues to the next step.
|
||||
*/
|
||||
get active() {
|
||||
this.checkHasTour();
|
||||
const mode = this.tour.mode;
|
||||
const isSmall = utils.isSmall();
|
||||
const standardKeyWords = ["enterprise", "community", "mobile", "desktop", "auto", "manual"];
|
||||
const isActiveArray = Array.isArray(this.isActive) ? this.isActive : [];
|
||||
if (isActiveArray.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const selectors = isActiveArray.filter((key) => !standardKeyWords.includes(key));
|
||||
if (selectors.length) {
|
||||
// if one of selectors is not found, step is skipped
|
||||
for (const selector of selectors) {
|
||||
const el = hoot.queryFirst(selector);
|
||||
if (!el) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const checkMode =
|
||||
isActiveArray.includes(mode) ||
|
||||
(!isActiveArray.includes("manual") && !isActiveArray.includes("auto"));
|
||||
const edition =
|
||||
(session.server_version_info || "").at(-1) === "e" ? "enterprise" : "community";
|
||||
const checkEdition =
|
||||
isActiveArray.includes(edition) ||
|
||||
(!isActiveArray.includes("enterprise") && !isActiveArray.includes("community"));
|
||||
const onlyForMobile = isActiveArray.includes("mobile") && isSmall;
|
||||
const onlyForDesktop = isActiveArray.includes("desktop") && !isSmall;
|
||||
const checkDevice =
|
||||
onlyForMobile ||
|
||||
onlyForDesktop ||
|
||||
(!isActiveArray.includes("mobile") && !isActiveArray.includes("desktop"));
|
||||
return checkEdition && checkDevice && checkMode;
|
||||
}
|
||||
|
||||
checkHasTour() {
|
||||
if (!this.tour) {
|
||||
throw new Error(`TourStep instance must have a tour`);
|
||||
}
|
||||
}
|
||||
|
||||
get describeMe() {
|
||||
this.checkHasTour();
|
||||
return (
|
||||
`[${this.index + 1}/${this.tour.steps.length}] Tour ${this.tour.name} → Step ` +
|
||||
(this.content ? `${this.content} (trigger: ${this.trigger})` : this.trigger)
|
||||
);
|
||||
}
|
||||
|
||||
get stringify() {
|
||||
return (
|
||||
JSON.stringify(
|
||||
pick(
|
||||
this,
|
||||
"isActive",
|
||||
"content",
|
||||
"trigger",
|
||||
"run",
|
||||
"tooltipPosition",
|
||||
"timeout",
|
||||
"expectUnloadPage"
|
||||
),
|
||||
(_key, value) => {
|
||||
if (typeof value === "function") {
|
||||
return "[function]";
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
2
|
||||
) + ","
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
odoo.define('web_tour.TourStepUtils', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {_t, Class} = require('web.core');
|
||||
const {Markup} = require('web.utils');
|
||||
|
||||
return Class.extend({
|
||||
_getHelpMessage: (functionName, ...args) => `Generated by function tour utils ${functionName}(${args.join(', ')})`,
|
||||
|
||||
addDebugHelp: helpMessage => step => {
|
||||
if (typeof step.debugHelp === 'string') {
|
||||
step.debugHelp = step.debugHelp + '\n' + helpMessage;
|
||||
} else {
|
||||
step.debugHelp = helpMessage;
|
||||
}
|
||||
return step;
|
||||
},
|
||||
|
||||
editionEnterpriseModifier(step) {
|
||||
step.edition = 'enterprise';
|
||||
return step;
|
||||
},
|
||||
|
||||
mobileModifier(step) {
|
||||
step.mobile = true;
|
||||
return step;
|
||||
},
|
||||
|
||||
showAppsMenuItem() {
|
||||
return {
|
||||
edition: 'community',
|
||||
trigger: '.o_navbar_apps_menu button',
|
||||
auto: true,
|
||||
position: 'bottom',
|
||||
};
|
||||
},
|
||||
|
||||
toggleHomeMenu() {
|
||||
return {
|
||||
edition: 'enterprise',
|
||||
trigger: '.o_main_navbar .o_menu_toggle',
|
||||
content: Markup(_t('Click on the <i>Home icon</i> to navigate across apps.')),
|
||||
position: 'bottom',
|
||||
};
|
||||
},
|
||||
|
||||
autoExpandMoreButtons(extra_trigger) {
|
||||
return {
|
||||
trigger: '.oe_button_box',
|
||||
extra_trigger: extra_trigger,
|
||||
auto: true,
|
||||
run: actions => {
|
||||
const $more = $('.oe_button_box .o_button_more');
|
||||
if ($more.length) {
|
||||
actions.click($more);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
goBackBreadcrumbsMobile(description, ...extraTrigger) {
|
||||
return extraTrigger.map(element => ({
|
||||
mobile: true,
|
||||
trigger: '.breadcrumb-item.o_back_button',
|
||||
extra_trigger: element,
|
||||
content: description,
|
||||
position: 'bottom',
|
||||
debugHelp: this._getHelpMessage('goBackBreadcrumbsMobile', description, ...extraTrigger),
|
||||
}));
|
||||
},
|
||||
|
||||
goToAppSteps(dataMenuXmlid, description) {
|
||||
return [
|
||||
this.showAppsMenuItem(),
|
||||
{
|
||||
trigger: `.o_app[data-menu-xmlid="${dataMenuXmlid}"]`,
|
||||
content: description,
|
||||
position: 'right',
|
||||
edition: 'community',
|
||||
},
|
||||
{
|
||||
trigger: `.o_app[data-menu-xmlid="${dataMenuXmlid}"]`,
|
||||
content: description,
|
||||
position: 'bottom',
|
||||
edition: 'enterprise',
|
||||
},
|
||||
].map(this.addDebugHelp(this._getHelpMessage('goToApp', dataMenuXmlid, description)));
|
||||
},
|
||||
|
||||
openBuggerMenu(extraTrigger) {
|
||||
return {
|
||||
mobile: true,
|
||||
trigger: '.o_mobile_menu_toggle',
|
||||
extra_trigger: extraTrigger,
|
||||
content: _t('Open bugger menu.'),
|
||||
position: 'bottom',
|
||||
debugHelp: this._getHelpMessage('openBuggerMenu', extraTrigger),
|
||||
};
|
||||
},
|
||||
|
||||
statusbarButtonsSteps(innerTextButton, description, extraTrigger) {
|
||||
return [
|
||||
{
|
||||
mobile: true,
|
||||
auto: true,
|
||||
trigger: '.o_statusbar_buttons',
|
||||
extra_trigger: extraTrigger,
|
||||
run: actions => {
|
||||
const $action = $('.o_statusbar_buttons .btn.dropdown-toggle:contains(Action)');
|
||||
if ($action.length) {
|
||||
actions.click($action);
|
||||
}
|
||||
},
|
||||
}, {
|
||||
trigger: `.o_statusbar_buttons button:enabled:contains('${innerTextButton}')`,
|
||||
content: description,
|
||||
position: 'bottom',
|
||||
},
|
||||
].map(this.addDebugHelp(this._getHelpMessage('statusbarButtonsSteps', innerTextButton, description, extraTrigger)));
|
||||
},
|
||||
|
||||
simulateEnterKeyboardInSearchModal() {
|
||||
return {
|
||||
mobile: true,
|
||||
trigger: '.o_searchview_input',
|
||||
extra_trigger: '.modal:not(.o_inactive_modal) .dropdown-menu.o_searchview_autocomplete',
|
||||
position: 'bottom',
|
||||
run: action => {
|
||||
const keyEventEnter = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
which: 13,
|
||||
keyCode: 13,
|
||||
});
|
||||
action.tip_widget.$anchor[0].dispatchEvent(keyEventEnter);
|
||||
},
|
||||
debugHelp: this._getHelpMessage('simulateEnterKeyboardInSearchModal'),
|
||||
};
|
||||
},
|
||||
|
||||
mobileKanbanSearchMany2X(modalTitle, valueSearched) {
|
||||
return [
|
||||
{
|
||||
mobile: true,
|
||||
trigger: '.o_searchview_input',
|
||||
extra_trigger: `.modal:not(.o_inactive_modal) .modal-title:contains('${modalTitle}')`,
|
||||
position: 'bottom',
|
||||
run: `text ${valueSearched}`,
|
||||
},
|
||||
this.simulateEnterKeyboardInSearchModal(),
|
||||
{
|
||||
mobile: true,
|
||||
trigger: `.o_kanban_record .o_kanban_record_title :contains('${valueSearched}')`,
|
||||
position: 'bottom',
|
||||
},
|
||||
].map(this.addDebugHelp(this._getHelpMessage('mobileKanbanSearchMany2X', modalTitle, valueSearched)));
|
||||
},
|
||||
/**
|
||||
* Utility steps to save a form and wait for the save to complete
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.content]
|
||||
* @param {string} [options.extra_trigger] additional save-condition selector
|
||||
*/
|
||||
saveForm(options = {}) {
|
||||
return [{
|
||||
content: options.content || "save form",
|
||||
trigger: ".o_form_button_save",
|
||||
extra_trigger: options.extra_trigger,
|
||||
run: "click",
|
||||
auto: true,
|
||||
}, {
|
||||
content: "wait for save completion",
|
||||
trigger: '.o_form_readonly, .o_form_saved',
|
||||
run() {},
|
||||
auto: true,
|
||||
}];
|
||||
},
|
||||
/**
|
||||
* Utility steps to cancel a form creation or edition.
|
||||
*
|
||||
* Supports creation/edition from either a form or a list view (so checks
|
||||
* for both states).
|
||||
*/
|
||||
discardForm(options = {}) {
|
||||
return [{
|
||||
content: options.content || "exit the form",
|
||||
trigger: ".o_form_button_cancel",
|
||||
extra_trigger: options.extra_trigger,
|
||||
run: "click",
|
||||
auto: true,
|
||||
}, {
|
||||
content: "wait for cancellation to complete",
|
||||
trigger: ".o_list_view, .o_form_view > div > div > .o_form_readonly, .o_form_view > div > div > .o_form_saved",
|
||||
run() {},
|
||||
auto: true,
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
odoo.define('web_tour.utils', function(require) {
|
||||
"use strict";
|
||||
|
||||
const { _legacyIsVisible } = require("@web/core/utils/ui");
|
||||
|
||||
function get_step_key(name) {
|
||||
return 'tour_' + name + '_step';
|
||||
}
|
||||
|
||||
function get_running_key() {
|
||||
return 'running_tour';
|
||||
}
|
||||
|
||||
function get_debugging_key(name) {
|
||||
return `debugging_tour_${name}`;
|
||||
}
|
||||
|
||||
function get_running_delay_key() {
|
||||
return get_running_key() + "_delay";
|
||||
}
|
||||
|
||||
function get_first_visible_element($elements) {
|
||||
for (var i = 0 ; i < $elements.length ; i++) {
|
||||
var $i = $elements.eq(i);
|
||||
if (_legacyIsVisible($i[0])) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
return $();
|
||||
}
|
||||
|
||||
function do_before_unload(if_unload_callback, if_not_unload_callback) {
|
||||
if_unload_callback = if_unload_callback || function () {};
|
||||
if_not_unload_callback = if_not_unload_callback || if_unload_callback;
|
||||
|
||||
var old_before = window.onbeforeunload;
|
||||
var reload_timeout;
|
||||
window.onbeforeunload = function () {
|
||||
clearTimeout(reload_timeout);
|
||||
window.onbeforeunload = old_before;
|
||||
if_unload_callback();
|
||||
if (old_before) return old_before.apply(this, arguments);
|
||||
};
|
||||
reload_timeout = _.defer(function () {
|
||||
window.onbeforeunload = old_before;
|
||||
if_not_unload_callback();
|
||||
});
|
||||
}
|
||||
|
||||
function get_jquery_element_from_selector(selector) {
|
||||
const iframeSplit = _.isString(selector) && selector.match(/(.*\biframe[^ ]*)(.*)/);
|
||||
if (iframeSplit && iframeSplit[2]) {
|
||||
var $iframe = $(`${iframeSplit[1]}:not(.o_ignore_in_tour)`);
|
||||
if ($iframe.is('[is-ready="false"]')) {
|
||||
return $();
|
||||
}
|
||||
var $el = $iframe.contents()
|
||||
.find(iframeSplit[2]);
|
||||
$el.iframeContainer = $iframe[0];
|
||||
return $el;
|
||||
} else {
|
||||
return $(selector);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
get_debugging_key: get_debugging_key,
|
||||
'get_step_key': get_step_key,
|
||||
'get_running_key': get_running_key,
|
||||
'get_running_delay_key': get_running_delay_key,
|
||||
'get_first_visible_element': get_first_visible_element,
|
||||
'do_before_unload': do_before_unload,
|
||||
'get_jquery_element_from_selector' : get_jquery_element_from_selector,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Calls the given `func` then returns/resolves to `true`
|
||||
* if it will result to unloading of the page.
|
||||
* @param {(...args: any[]) => void} func
|
||||
* @param {any[]} args
|
||||
* @returns {boolean | Promise<boolean>}
|
||||
*/
|
||||
export function callWithUnloadCheck(func, ...args) {
|
||||
let willUnload = false;
|
||||
const beforeunload = () => (willUnload = true);
|
||||
window.addEventListener("beforeunload", beforeunload);
|
||||
const result = func(...args);
|
||||
if (result instanceof Promise) {
|
||||
return result.then(() => {
|
||||
window.removeEventListener("beforeunload", beforeunload);
|
||||
return willUnload;
|
||||
});
|
||||
} else {
|
||||
window.removeEventListener("beforeunload", beforeunload);
|
||||
return willUnload;
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(key, value, maxLength = 200) {
|
||||
if (!value) {
|
||||
return "(empty)";
|
||||
}
|
||||
return value.length > maxLength ? value.slice(0, maxLength) + "..." : value;
|
||||
}
|
||||
|
||||
function serializeNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return `"${node.nodeValue.trim()}"`;
|
||||
}
|
||||
return node.outerHTML ? formatValue("node", node.outerHTML, 500) : "[Unknown Node]";
|
||||
}
|
||||
|
||||
export function serializeChanges(snapshot, current) {
|
||||
const changes = {
|
||||
node: serializeNode(current),
|
||||
};
|
||||
function pushChanges(key, obj) {
|
||||
changes[key] = changes[key] || [];
|
||||
changes[key].push(obj);
|
||||
}
|
||||
|
||||
if (snapshot.textContent !== current.textContent) {
|
||||
pushChanges("modifiedText", { before: snapshot.textContent, after: current.textContent });
|
||||
}
|
||||
|
||||
const oldChildren = [...snapshot.childNodes].filter((node) => node.nodeType !== Node.TEXT_NODE);
|
||||
const newChildren = [...current.childNodes].filter((node) => node.nodeType !== Node.TEXT_NODE);
|
||||
oldChildren.forEach((oldNode, index) => {
|
||||
if (!newChildren[index] || !oldNode.isEqualNode(newChildren[index])) {
|
||||
pushChanges("removedNodes", { oldNode: serializeNode(oldNode) });
|
||||
}
|
||||
});
|
||||
newChildren.forEach((newNode, index) => {
|
||||
if (!oldChildren[index] || !newNode.isEqualNode(oldChildren[index])) {
|
||||
pushChanges("addedNodes", { newNode: serializeNode(newNode) });
|
||||
}
|
||||
});
|
||||
|
||||
const oldAttrNames = new Set([...snapshot.attributes].map((attr) => attr.name));
|
||||
const newAttrNames = new Set([...current.attributes].map((attr) => attr.name));
|
||||
new Set([...oldAttrNames, ...newAttrNames]).forEach((attributeName) => {
|
||||
const oldValue = snapshot.getAttribute(attributeName);
|
||||
const newValue = current.getAttribute(attributeName);
|
||||
const before = oldValue !== newValue || !newAttrNames.has(attributeName) ? oldValue : null;
|
||||
const after = oldValue !== newValue || !oldAttrNames.has(attributeName) ? newValue : null;
|
||||
if (before || after) {
|
||||
pushChanges("modifiedAttributes", { attributeName, before, after });
|
||||
}
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function serializeMutation(mutation) {
|
||||
const { type, attributeName } = mutation;
|
||||
if (type === "attributes" && attributeName) {
|
||||
return `attribute: ${attributeName}`;
|
||||
} else {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @returns {HTMLElement | null}
|
||||
*/
|
||||
export function getScrollParent(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
// We cannot only rely on the fact that the element’s scrollHeight is
|
||||
// greater than its clientHeight. This might not be the case when a step
|
||||
// starts, and the scrollbar could appear later. For example, when clicking
|
||||
// on a "building block" in the "building block previews modal" during a
|
||||
// tour (in website edit mode). When the modal opens, not all "building
|
||||
// blocks" are loaded yet, and the scrollbar is not present initially.
|
||||
const overflowY = window.getComputedStyle(element).overflowY;
|
||||
const isScrollable =
|
||||
overflowY === "auto" ||
|
||||
overflowY === "scroll" ||
|
||||
(overflowY === "visible" && element === element.ownerDocument.scrollingElement);
|
||||
if (isScrollable) {
|
||||
return element;
|
||||
} else {
|
||||
return getScrollParent(element.parentNode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
$o-tip-width: 28px;
|
||||
$o-tip-height: 38px;
|
||||
$o-tip-anchor-space: 0;
|
||||
$o-tip-bounce-half-size: 3px;
|
||||
$o-tip-color: $o-enterprise-color;
|
||||
$o-tip-border-width: 3px;
|
||||
$o-tip-border-color: white;
|
||||
$o-tip-animation-speed: 500ms;
|
||||
$o-tip-arrow-size: 12px;
|
||||
|
||||
$o-tip-duration-in: 200ms;
|
||||
$o-tip-size-duration-in: floor($o-tip-duration-in * 3 / 4);
|
||||
$o-tip-size-delay-in: $o-tip-duration-in - $o-tip-size-duration-in;
|
||||
|
||||
@keyframes move-left-right {
|
||||
0% {
|
||||
transform: translate(-$o-tip-bounce-half-size, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate($o-tip-bounce-half-size, 0);
|
||||
}
|
||||
}
|
||||
@keyframes move-bottom-top {
|
||||
0% {
|
||||
transform: translate(0, -$o-tip-bounce-half-size);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, $o-tip-bounce-half-size);
|
||||
}
|
||||
}
|
||||
|
||||
.o_tooltip_parent {
|
||||
position: relative !important;
|
||||
|
||||
// Tooltips are placed in the <body/> element with z-index 1070 because this
|
||||
// is the only way to position them above everything else. However, for
|
||||
// scrolling performance, the tooltip is placed in its ideal location (see
|
||||
// Tip._get_ideal_location). When in this location, the tooltip were
|
||||
// sometimes overlapping unwanted elements (e.g. chat windows).
|
||||
//
|
||||
// Changing the opacity of the tooltip parents forces the creation of a
|
||||
// stacking context; the home menu tooltips are thus now considered to be
|
||||
// root-level z-index auto (or the default home menu one) and should so
|
||||
// act like their parent (e.g. the home menu is below the chat windows so
|
||||
// the inner tooltips will be too). The tips will be above all elements of
|
||||
// the home menu as they still have a high z-index, but relative to the
|
||||
// home menu (this is especially useful in the website where most tooltips
|
||||
// are placed in the body and need to be placed above elements with z-index
|
||||
// like the navbar).
|
||||
opacity: 0.999 !important;
|
||||
}
|
||||
|
||||
.o_tooltip {
|
||||
/*rtl:begin:ignore*/
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
/*rtl:end:ignore*/
|
||||
z-index: $zindex-tooltip !important; // See comment on 'o_tooltip_parent' class
|
||||
opacity: 0 !important;
|
||||
width: $o-tip-width !important;
|
||||
height: $o-tip-width !important; // the shape must be done using transform
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
|
||||
transition: opacity 400ms ease 0ms !important;
|
||||
|
||||
&.o_animated {
|
||||
animation: move-bottom-top $o-tip-animation-speed ease-in 0ms infinite alternate !important;
|
||||
|
||||
&.right, &.left {
|
||||
animation-name: move-left-right !important;
|
||||
}
|
||||
}
|
||||
&.o_tooltip_visible {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&.o_tooltip_fixed {
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
// Use the ::before element to make the tip shape: a simple filled and
|
||||
// bordered square with one corner and 3 rounded corners, then transformed.
|
||||
// Transform, from right to left: 1) make the arrow point up, 2) scale along
|
||||
// Y axis so that the tip reach the desired height, 3) translate along the Y
|
||||
// axis so that the arrow exactly points at the original square tip border
|
||||
// = the border that will be against the pointed element, 4) rotate the
|
||||
// the shape depending on the tip orientation.
|
||||
&::before {
|
||||
content: "";
|
||||
@include o-position-absolute(0, 0);
|
||||
width: $o-tip-width; // Not 100% need to stay small and square for close transition
|
||||
height: $o-tip-width;
|
||||
border: $o-tip-border-width solid $o-tip-border-color;
|
||||
border-radius: 0 50% 50% 50%;
|
||||
background: radial-gradient(lighten($o-tip-color, 7%), $o-tip-color);
|
||||
box-shadow: 0 0 40px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
$-sqrt-2: 1.4142;
|
||||
$-tip-scale: $o-tip-height / ((1 + $-sqrt-2) * $o-tip-width / 2);
|
||||
$-tip-overflow: ($-sqrt-2 * $-tip-scale - 1) * $o-tip-width / 2;
|
||||
$-tip-translate: $o-tip-anchor-space + $-tip-overflow;
|
||||
&.top::before {
|
||||
transform: rotate(180deg) translateY($-tip-translate) scaleY($-tip-scale) rotate(45deg);
|
||||
}
|
||||
&.right::before {
|
||||
transform: rotate(270deg) translateY($-tip-translate) scaleY($-tip-scale) rotate(45deg);
|
||||
}
|
||||
&.bottom::before {
|
||||
transform: rotate(0deg) translateY($-tip-translate) scaleY($-tip-scale) rotate(45deg);
|
||||
}
|
||||
&.left::before {
|
||||
transform: rotate(90deg) translateY($-tip-translate) scaleY($-tip-scale) rotate(45deg);
|
||||
}
|
||||
|
||||
> .o_tooltip_overlay {
|
||||
display: none;
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
z-index: -1;
|
||||
}
|
||||
> .o_tooltip_content {
|
||||
overflow: hidden;
|
||||
direction: ltr;
|
||||
position: relative;
|
||||
padding: 7px 14px;
|
||||
background-color: inherit;
|
||||
color: transparent;
|
||||
visibility: hidden;
|
||||
|
||||
// Force style so that it does not depend on where the tooltip is attached
|
||||
line-height: $line-height-base;
|
||||
font-size: $font-size-base;
|
||||
font-family: $font-family-sans-serif;
|
||||
font-weight: normal;
|
||||
|
||||
.o_skip_tour {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
color: gray;
|
||||
&:hover {
|
||||
color: darken(gray, 20%);
|
||||
}
|
||||
}
|
||||
> p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border: $o-tip-border-width solid $o-tip-color !important;
|
||||
background-color: white !important;
|
||||
|
||||
transition:
|
||||
width $o-tip-size-duration-in ease $o-tip-size-delay-in,
|
||||
height $o-tip-size-duration-in ease $o-tip-size-delay-in,
|
||||
margin $o-tip-size-duration-in ease $o-tip-size-delay-in !important;
|
||||
|
||||
&::before {
|
||||
width: $o-tip-arrow-size;
|
||||
height: $o-tip-arrow-size;
|
||||
border-color: $o-tip-color;
|
||||
border-radius: 0;
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
> .o_tooltip_overlay {
|
||||
display: block;
|
||||
}
|
||||
> .o_tooltip_content {
|
||||
// Content background must appear immediately to hide the bottom of
|
||||
// the square present to shape the bubble arrow. But text must
|
||||
// appear at the very end.
|
||||
color: black;
|
||||
visibility: visible;
|
||||
transition: color 0ms ease $o-tip-duration-in;
|
||||
}
|
||||
|
||||
$-arrow-offset: ($o-tip-width - $o-tip-arrow-size) / 2 - $o-tip-border-width;
|
||||
$-tip-translate: $o-tip-anchor-space + $o-tip-arrow-size / 2;
|
||||
&.right {
|
||||
transform: translateX($-tip-translate) !important;
|
||||
|
||||
&::before {
|
||||
@include o-position-absolute($left: -$o-tip-arrow-size, $top: $-arrow-offset);
|
||||
transform: translateX(50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
&.top {
|
||||
transform: translateY(-$-tip-translate) !important;
|
||||
|
||||
&::before {
|
||||
/*rtl:begin:ignore*/
|
||||
@include o-position-absolute($bottom: -$o-tip-arrow-size, $left: $-arrow-offset);
|
||||
/*rtl:end:ignore*/
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
&.left {
|
||||
transform: translateX(-$-tip-translate) !important;
|
||||
|
||||
&::before {
|
||||
@include o-position-absolute($right: -$o-tip-arrow-size, $top: $-arrow-offset);
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
&.bottom {
|
||||
transform: translateY($-tip-translate) !important;
|
||||
|
||||
&::before {
|
||||
/*rtl:begin:ignore*/
|
||||
@include o-position-absolute($top: -$o-tip-arrow-size, $left: $-arrow-offset);
|
||||
/*rtl:end:ignore*/
|
||||
transform: translateY(50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
&.inverse {
|
||||
&.left, &.right {
|
||||
&::before {
|
||||
top: auto;
|
||||
bottom: $-arrow-offset;
|
||||
}
|
||||
}
|
||||
&.top, &.bottom {
|
||||
&::before {
|
||||
left: auto#{"/*rtl:ignore*/"};
|
||||
right: $-arrow-offset#{"/*rtl:ignore*/"};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.o_tooltip {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-pane.active .o_list_renderer.o_tooltip_parent {
|
||||
z-index: $zindex-dropdown - 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
@keyframes o-tour-pointer-bounce-horizontal {
|
||||
from {
|
||||
transform: translateX(calc(var(--TourPointer__bounce-offset) * -1));
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--TourPointer__bounce-offset));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes o-tour-pointer-bounce-vertical {
|
||||
from {
|
||||
transform: translateY(calc(var(--TourPointer__bounce-offset) * -1));
|
||||
}
|
||||
to {
|
||||
transform: translateY(var(--TourPointer__bounce-offset));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes o-tour-pointer-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes o-tour-pointer-info-expand {
|
||||
from {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_tour_pointer {
|
||||
--TourPointer__anchor-space: 0;
|
||||
--TourPointer__bounce-offset: 3px;
|
||||
--TourPointer__offset: 8px;
|
||||
--TourPointer__scale: 1.12;
|
||||
--TourPointer__color: #{$o-enterprise-color};
|
||||
--TourPointer__color-accent: #{lighten($o-enterprise-color, 7%)};
|
||||
--TourPointer__border-width: 1px;
|
||||
--TourPointer__border-color-rgb: 255, 255, 255;
|
||||
--TourPointer__border-color: rgba(var(--TourPointer__border-color-rgb), 1);
|
||||
--TourPointer__arrow-size: 1rem;
|
||||
--TourPointer__animation-duration: 500ms;
|
||||
--TourPointer__expand-duration: 200ms;
|
||||
--TourPointer__text-color: black;
|
||||
--TourPointer__reveal-animation: o-tour-pointer-fade-in 400ms ease;
|
||||
|
||||
--TourPointer__translate-x: 0;
|
||||
--TourPointer__translate-y: 0;
|
||||
|
||||
z-index: $zindex-tooltip;
|
||||
max-width: 270px;
|
||||
border: var(--TourPointer__border-width) solid transparent;
|
||||
transform: translate(var(--TourPointer__translate-x), var(--TourPointer__translate-y));
|
||||
transition: width var(--TourPointer__expand-duration),
|
||||
height var(--TourPointer__expand-duration);
|
||||
|
||||
&.o_bouncing {
|
||||
&.o_left,
|
||||
&.o_right {
|
||||
animation: o-tour-pointer-bounce-horizontal var(--TourPointer__animation-duration)
|
||||
ease-in infinite alternate,
|
||||
var(--TourPointer__reveal-animation);
|
||||
}
|
||||
|
||||
&.o_top,
|
||||
&.o_bottom {
|
||||
animation: o-tour-pointer-bounce-vertical var(--TourPointer__animation-duration) ease-in
|
||||
infinite alternate,
|
||||
var(--TourPointer__reveal-animation);
|
||||
}
|
||||
}
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
width: var(--TourPointer__width);
|
||||
height: var(--TourPointer__height);
|
||||
border: var(--TourPointer__border-width) solid white;
|
||||
border-radius: 0 50% 50% 50%;
|
||||
background-image: radial-gradient(
|
||||
var(--TourPointer__color-accent),
|
||||
var(--TourPointer__color)
|
||||
);
|
||||
box-shadow: 0 0 40px 2px rgba(var(--TourPointer__border-color-rgb), 0.5);
|
||||
}
|
||||
|
||||
.o_tour_pointer_content {
|
||||
background-color: $light;
|
||||
color: transparent;
|
||||
transition: color 0s ease var(--TourPointer__expand-duration);
|
||||
|
||||
// Force style so that it does not depend on where the tooltip is attached
|
||||
line-height: $line-height-base;
|
||||
@include font-size($font-size-base);
|
||||
font-family: $font-family-sans-serif;
|
||||
font-weight: normal;
|
||||
|
||||
.o_skip_tour {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: gray;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: darken(gray, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_left .o_tour_pointer_tip {
|
||||
transform: rotate(90deg) translateY(var(--TourPointer__offset))
|
||||
scaleY(var(--TourPointer__scale)) rotate(45deg);
|
||||
}
|
||||
|
||||
&.o_right .o_tour_pointer_tip {
|
||||
transform: rotate(270deg) translateY(var(--TourPointer__offset))
|
||||
scaleY(var(--TourPointer__scale)) rotate(45deg);
|
||||
}
|
||||
|
||||
&.o_top .o_tour_pointer_tip {
|
||||
transform: rotate(180deg) translateY(var(--TourPointer__offset))
|
||||
scaleY(var(--TourPointer__scale)) rotate(45deg);
|
||||
}
|
||||
|
||||
&.o_bottom .o_tour_pointer_tip {
|
||||
transform: rotate(0deg) translateY(var(--TourPointer__offset))
|
||||
scaleY(var(--TourPointer__scale)) rotate(45deg);
|
||||
}
|
||||
|
||||
&.o_open {
|
||||
border-color: #{$o-gray-400};
|
||||
background-color: #{$o-gray-300};
|
||||
animation: var(--TourPointer__reveal-animation);
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
width: var(--TourPointer__arrow-size);
|
||||
height: var(--TourPointer__arrow-size);
|
||||
border-color: #{$o-gray-400};
|
||||
border-radius: 0;
|
||||
background: $light;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.o_tour_pointer_content {
|
||||
color: $o-black;
|
||||
}
|
||||
|
||||
&.o_left {
|
||||
--TourPointer__translate-x: calc(var(--TourPointer__arrow-size) / -2);
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
/*rtl:begin:ignore*/
|
||||
right: calc(var(--TourPointer__arrow-size) * -1);
|
||||
top: calc(var(--TourPointer__arrow-size) / 2);
|
||||
/*rtl:end:ignore*/
|
||||
}
|
||||
}
|
||||
|
||||
&.o_right {
|
||||
--TourPointer__translate-x: calc(var(--TourPointer__arrow-size) / 2);
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
transform: translateX(50%) rotate(45deg);
|
||||
/*rtl:begin:ignore*/
|
||||
left: calc(var(--TourPointer__arrow-size) * -1);
|
||||
top: calc(var(--TourPointer__arrow-size) / 2);
|
||||
/*rtl:end:ignore*/
|
||||
}
|
||||
}
|
||||
|
||||
&.o_top {
|
||||
--TourPointer__translate-y: calc(var(--TourPointer__arrow-size) / -2);
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
/*rtl:begin:ignore*/
|
||||
bottom: calc(var(--TourPointer__arrow-size) * -1);
|
||||
left: calc(var(--TourPointer__arrow-size) / 2);
|
||||
/*rtl:end:ignore*/
|
||||
}
|
||||
}
|
||||
|
||||
&.o_bottom {
|
||||
--TourPointer__translate-y: calc(var(--TourPointer__arrow-size) / 2);
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
transform: translateY(50%) rotate(45deg);
|
||||
/*rtl:begin:ignore*/
|
||||
top: calc(var(--TourPointer__arrow-size) * -1);
|
||||
left: calc(var(--TourPointer__arrow-size) / 2);
|
||||
/*rtl:end:ignore*/
|
||||
}
|
||||
}
|
||||
|
||||
// Exception for when the info bubble would overflow to the right:
|
||||
// we offset the content to the left
|
||||
&.o_expand_left {
|
||||
&.o_top,
|
||||
&.o_bottom {
|
||||
--TourPointer__translate-x: calc(
|
||||
var(--TourPointer__width) + var(--TourPointer__border-width) - 100%
|
||||
);
|
||||
|
||||
.o_tour_pointer_tip {
|
||||
/*rtl:begin:ignore*/
|
||||
left: initial;
|
||||
right: calc(var(--TourPointer__arrow-size) / 2);
|
||||
/*rtl:end:ignore*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.o_tour_pointer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import tourManager from "web_tour.tour";
|
||||
|
||||
export const tourService = {
|
||||
start() {
|
||||
/**
|
||||
* @private
|
||||
* @returns {Object} All the tours as a map
|
||||
*/
|
||||
function _getAllTourMap() {
|
||||
return tourManager.tours;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Object} All the active tours as a map
|
||||
*/
|
||||
function _getActiveTourMap() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(_getAllTourMap()).filter(
|
||||
([key, value]) => !tourManager.consumed_tours.includes(key)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Array} Takes an Object (map) of tours and returns all the values
|
||||
*/
|
||||
function _fromTourMapToArray(tourMap) {
|
||||
return Object.values(tourMap).sort((t1, t2) => {
|
||||
return t1.sequence - t2.sequence || (t1.name < t2.name ? -1 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array} All the tours
|
||||
*/
|
||||
function getAllTours() {
|
||||
return _fromTourMapToArray(_getAllTourMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array} All the active tours
|
||||
*/
|
||||
function getActiveTours() {
|
||||
return _fromTourMapToArray(_getActiveTourMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array} The onboarding tours
|
||||
*/
|
||||
function getOnboardingTours() {
|
||||
return getAllTours().filter((t) => !t.test);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array} The testing tours
|
||||
*/
|
||||
function getTestingTours() {
|
||||
return getAllTours().filter((t) => t.test);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tourName
|
||||
* Run a tour
|
||||
*/
|
||||
function run(tourName) {
|
||||
return tourManager.run(tourName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tourName
|
||||
* Reset a tour
|
||||
*/
|
||||
function reset(tourName) {
|
||||
return tourManager.reset(tourName);
|
||||
}
|
||||
|
||||
return {
|
||||
getAllTours,
|
||||
getActiveTours,
|
||||
getOnboardingTours,
|
||||
getTestingTours,
|
||||
run,
|
||||
reset,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("tour", tourService);
|
||||
232
odoo-bringout-oca-ocb-web_tour/web_tour/static/src/tour_utils.js
Normal file
232
odoo-bringout-oca-ocb-web_tour/web_tour/static/src/tour_utils.js
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export const stepUtils = {
|
||||
_getHelpMessage(functionName, ...args) {
|
||||
return `Generated by function tour utils ${functionName}(${args.join(", ")})`;
|
||||
},
|
||||
|
||||
addDebugHelp(helpMessage, step) {
|
||||
if (typeof step.debugHelp === "string") {
|
||||
step.debugHelp = step.debugHelp + "\n" + helpMessage;
|
||||
} else {
|
||||
step.debugHelp = helpMessage;
|
||||
}
|
||||
return step;
|
||||
},
|
||||
|
||||
editSelectMenuInput(trigger, value) {
|
||||
return [
|
||||
{
|
||||
content: "Make sure a SelectMenu has been opened",
|
||||
trigger: `.o_select_menu_menu`,
|
||||
},
|
||||
{
|
||||
trigger,
|
||||
async run({ queryFirst }) {
|
||||
const input = queryFirst(trigger);
|
||||
input.focus();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
showAppsMenuItem() {
|
||||
return {
|
||||
isActive: ["auto", "community", "desktop"],
|
||||
trigger: ".o_navbar_apps_menu button:enabled",
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
};
|
||||
},
|
||||
|
||||
toggleHomeMenu() {
|
||||
return [
|
||||
{
|
||||
isActive: [".o_main_navbar .o_menu_toggle"],
|
||||
trigger: ".o_main_navbar .o_menu_toggle",
|
||||
content: _t("Click the top left corner to navigate across apps."),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
isActive: ["mobile"],
|
||||
trigger: ".o_sidebar_topbar a.btn-primary",
|
||||
tooltipPosition: "right",
|
||||
run: "click",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
autoExpandMoreButtons(isActiveMobile = false) {
|
||||
const isActive = ["auto"];
|
||||
if (isActiveMobile) {
|
||||
isActive.push("mobile");
|
||||
}
|
||||
return {
|
||||
isActive,
|
||||
content: `autoExpandMoreButtons`,
|
||||
trigger: ".o-form-buttonbox",
|
||||
async run({ queryFirst, click }) {
|
||||
const more = queryFirst(".o-form-buttonbox .o_button_more");
|
||||
if (more) {
|
||||
await click(more);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
goToAppSteps(dataMenuXmlid, description) {
|
||||
return [
|
||||
this.showAppsMenuItem(),
|
||||
{
|
||||
isActive: ["community"],
|
||||
trigger: `.o_app[data-menu-xmlid="${dataMenuXmlid}"]`,
|
||||
content: description,
|
||||
tooltipPosition: "right",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
isActive: ["enterprise"],
|
||||
trigger: `.o_app[data-menu-xmlid="${dataMenuXmlid}"]`,
|
||||
content: description,
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
].map((step) =>
|
||||
this.addDebugHelp(this._getHelpMessage("goToApp", dataMenuXmlid, description), step)
|
||||
);
|
||||
},
|
||||
|
||||
statusbarButtonsSteps(innerTextButton, description, trigger) {
|
||||
const steps = [];
|
||||
if (trigger) {
|
||||
steps.push({
|
||||
isActive: ["auto", "mobile"],
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
steps.push(
|
||||
{
|
||||
isActive: ["auto", "mobile"],
|
||||
trigger: ".o_statusbar_buttons",
|
||||
async run({ queryFirst, click }) {
|
||||
const buttonOutSideDropdownMenu = queryFirst(
|
||||
`.o_statusbar_buttons button:enabled:contains('${innerTextButton}')`
|
||||
);
|
||||
const node = queryFirst(".o_statusbar_buttons button:has(.oi-ellipsis-v)");
|
||||
if (!buttonOutSideDropdownMenu && node) {
|
||||
await click(node);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: `.o_statusbar_buttons button:enabled:contains('${innerTextButton}'), .dropdown-item button:enabled:contains('${innerTextButton}')`,
|
||||
content: description,
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
}
|
||||
);
|
||||
return steps.map((step) =>
|
||||
this.addDebugHelp(
|
||||
this._getHelpMessage("statusbarButtonsSteps", innerTextButton, description),
|
||||
step
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
mobileKanbanSearchMany2X(modalTitle, valueSearched) {
|
||||
return [
|
||||
{
|
||||
isActive: ["mobile"],
|
||||
trigger: `.modal:not(.o_inactive_modal) .o_control_panel_navigation .btn .fa-search`,
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
isActive: ["mobile"],
|
||||
trigger: ".o_searchview_input",
|
||||
tooltipPosition: "bottom",
|
||||
run: `edit ${valueSearched}`,
|
||||
},
|
||||
{
|
||||
isActive: ["mobile"],
|
||||
trigger: ".dropdown-menu.o_searchview_autocomplete",
|
||||
},
|
||||
{
|
||||
isActive: ["mobile"],
|
||||
trigger: ".o_searchview_input",
|
||||
tooltipPosition: "bottom",
|
||||
run: "press Enter",
|
||||
},
|
||||
{
|
||||
isActive: ["mobile"],
|
||||
trigger: `.o_kanban_record:contains('${valueSearched}')`,
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
].map((step) =>
|
||||
this.addDebugHelp(
|
||||
this._getHelpMessage("mobileKanbanSearchMany2X", modalTitle, valueSearched),
|
||||
step
|
||||
)
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Utility steps to save a form and wait for the save to complete
|
||||
*/
|
||||
saveForm() {
|
||||
return [
|
||||
{
|
||||
isActive: ["auto"],
|
||||
content: "save form",
|
||||
trigger: ".o_form_button_save:enabled",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "wait for save completion",
|
||||
trigger: ".o_form_readonly, .o_form_saved",
|
||||
},
|
||||
];
|
||||
},
|
||||
/**
|
||||
* Utility steps to cancel a form creation or edition.
|
||||
*
|
||||
* Supports creation/edition from either a form or a list view (so checks
|
||||
* for both states).
|
||||
*/
|
||||
discardForm() {
|
||||
return [
|
||||
{
|
||||
isActive: ["auto"],
|
||||
content: "discard the form",
|
||||
trigger: ".o_form_button_cancel",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "wait for cancellation to complete",
|
||||
trigger:
|
||||
".o_view_controller.o_list_view, .o_form_view > div > main > .o_form_readonly, .o_form_view > div > main > .o_form_saved",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
waitIframeIsReady() {
|
||||
return {
|
||||
content: "Wait until the iframe is ready",
|
||||
trigger: `:iframe body[is-ready=true]`,
|
||||
};
|
||||
},
|
||||
|
||||
goToUrl(url) {
|
||||
return {
|
||||
isActive: ["auto"],
|
||||
content: `Navigate to ${url}`,
|
||||
trigger: "body",
|
||||
run: `goToUrl ${url}`,
|
||||
expectUnloadPage: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="web_tour.TourListController.Buttons" t-inherit="web.ListView.Buttons" t-inherit-mode="primary">
|
||||
<xpath expr="." position="inside">
|
||||
<button class="btn btn-primary me-1 o_button_tour_recorder" title="Record Tour"
|
||||
t-on-click="() => this.env.services.tour_service.startTourRecorder()">
|
||||
Record
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
|
||||
registry.category("views").add("tour_list", {
|
||||
...listView,
|
||||
buttonTemplate: "web_tour.TourListController.Buttons",
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { charField, CharField } from "@web/views/fields/char/char_field";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class TourStartWidget extends CharField {
|
||||
static template = "web_tour.TourStartWidget";
|
||||
static props = {
|
||||
...CharField.props,
|
||||
link: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.tour = useService("tour_service");
|
||||
}
|
||||
|
||||
get tourData() {
|
||||
return this.props.record.data;
|
||||
}
|
||||
|
||||
_onStartTour() {
|
||||
this.tour.startTour(this.tourData.name, {
|
||||
mode: "manual",
|
||||
url: this.tourData.url,
|
||||
fromDB: this.tourData.custom,
|
||||
rainbowManMessage: this.tourData.rainbow_man_message,
|
||||
});
|
||||
}
|
||||
|
||||
_onTestTour() {
|
||||
this.tour.startTour(this.tourData.name, {
|
||||
mode: "auto",
|
||||
url: this.tourData.url,
|
||||
fromDB: this.tourData.custom,
|
||||
showPointerDuration: 250,
|
||||
rainbowManMessage: this.tourData.rainbow_man_message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const tourStartWidgetField = {
|
||||
...charField,
|
||||
component: TourStartWidget,
|
||||
extractProps: ({ options }) => ({
|
||||
link: options.link,
|
||||
}),
|
||||
};
|
||||
|
||||
registry.category("fields").add("tour_start_widget", tourStartWidgetField);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="web_tour.TourStartWidget">
|
||||
<button title="Start Tour"
|
||||
t-on-click.prevent.stop="_onStartTour"
|
||||
t-att-class="'o_start_tour btn ' + (this.props.link ? 'btn-link py-0' : 'btn-primary')">
|
||||
Onboarding
|
||||
</button>
|
||||
<button title="Test Tour"
|
||||
t-on-click.prevent.stop="_onTestTour"
|
||||
t-att-class="'o_test_tour btn ' + (this.props.link ? 'btn-link py-0' : 'btn-primary ms-1')">
|
||||
Testing
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<div t-name="Tip" t-attf-class="o_tooltip #{widget.info.position} #{widget.is_anchor_fixed_position ? 'o_tooltip_fixed' : ''}">
|
||||
<div class="o_tooltip_overlay"/>
|
||||
<div class="o_tooltip_content">
|
||||
<t t-out="widget.info.content"/>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { advanceTime, animationFrame, queryFirst } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Macro } from "@web/core/macro";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
const mainErrorMessage = (trigger) =>
|
||||
`Error: Potential non deterministic behavior found in 300ms for trigger ${trigger}.`;
|
||||
|
||||
let macro;
|
||||
async function waitForMacro() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await animationFrame();
|
||||
await advanceTime(265);
|
||||
if (macro.isComplete) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!macro.isComplete) {
|
||||
throw new Error(`Macro is not complete`);
|
||||
}
|
||||
}
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
registry.category("web_tour.tours").add("tour_to_check_undeterminisms", {
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".container",
|
||||
},
|
||||
{
|
||||
trigger: ".button2",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
patchWithCleanup(Macro.prototype, {
|
||||
start() {
|
||||
super.start(...arguments);
|
||||
macro = this;
|
||||
},
|
||||
});
|
||||
patchWithCleanup(browser.console, {
|
||||
log: (s) => expect.step(`log: ${s}`),
|
||||
error: (s) => {
|
||||
s = s.replace(/\n +at.*/g, ""); // strip stack trace
|
||||
expect.step(`error: ${s}`);
|
||||
},
|
||||
warn: () => {},
|
||||
dir: () => {},
|
||||
});
|
||||
await mountWithCleanup(Root);
|
||||
await odoo.startTour("tour_to_check_undeterminisms", {
|
||||
mode: "auto",
|
||||
delayToCheckUndeterminisms: 300,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
macro.stop();
|
||||
});
|
||||
|
||||
test("element is no longer visible", async () => {
|
||||
macro.onStep = ({ index }) => {
|
||||
if (index == 2) {
|
||||
setTimeout(() => {
|
||||
queryFirst(".container").classList.add("d-none");
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
await waitForMacro();
|
||||
const expectedError = `Initial element is no longer visible`;
|
||||
expect.verifySteps([
|
||||
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
|
||||
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
|
||||
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
|
||||
${mainErrorMessage(".button1")}
|
||||
${expectedError}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("change text", async () => {
|
||||
macro.onStep = ({ index }) => {
|
||||
if (index == 2) {
|
||||
setTimeout(() => {
|
||||
queryFirst(".button1").textContent = "Text has changed :)";
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
await waitForMacro();
|
||||
expect.verifySteps([
|
||||
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
|
||||
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
|
||||
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
|
||||
${mainErrorMessage(".button1")}
|
||||
Initial element has changed:
|
||||
{
|
||||
"node": "<button class=\\"button1\\">Text has changed :)</button>",
|
||||
"modifiedText": [
|
||||
{
|
||||
"before": "Button 1",
|
||||
"after": "Text has changed :)"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("change attributes", async () => {
|
||||
macro.onStep = ({ index }) => {
|
||||
if (index == 2) {
|
||||
setTimeout(() => {
|
||||
const button1 = queryFirst(".button1");
|
||||
button1.classList.add("brol");
|
||||
button1.classList.remove("button1");
|
||||
button1.setAttribute("data-value", "42");
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
await waitForMacro();
|
||||
const expectedError = `{
|
||||
"node": "<button class=\\"brol\\" data-value=\\"42\\">Button 1</button>",
|
||||
"modifiedAttributes": [
|
||||
{
|
||||
"attributeName": "class",
|
||||
"before": "button1",
|
||||
"after": "brol"
|
||||
},
|
||||
{
|
||||
"attributeName": "data-value",
|
||||
"before": null,
|
||||
"after": "42"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
expect.verifySteps([
|
||||
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
|
||||
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
|
||||
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
|
||||
${mainErrorMessage(".button1")}
|
||||
Initial element has changed:
|
||||
${expectedError}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("add child node", async () => {
|
||||
macro.onStep = ({ index }) => {
|
||||
if (index == 4) {
|
||||
setTimeout(() => {
|
||||
const addElement = document.createElement("div");
|
||||
addElement.classList.add("brol");
|
||||
addElement.textContent = "Hello world !";
|
||||
queryFirst(".container").appendChild(addElement);
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
await waitForMacro();
|
||||
const expectedError = `{
|
||||
"node": "<div class=\\"container\\"><button class=\\"button0\\">Button 0</button><button class=\\"button1\\">Button 1</button><button class=\\"button2\\">Button 2</button><div class=\\"brol\\">Hello world !</div></div>",
|
||||
"modifiedText": [
|
||||
{
|
||||
"before": "Button 0Button 1Button 2",
|
||||
"after": "Button 0Button 1Button 2Hello world !"
|
||||
}
|
||||
],
|
||||
"addedNodes": [
|
||||
{
|
||||
"newNode": "<div class=\\"brol\\">Hello world !</div>"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
expect.verifySteps([
|
||||
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
|
||||
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
|
||||
"log: [3/4] Tour tour_to_check_undeterminisms → Step .container",
|
||||
`error: FAILED: [3/4] Tour tour_to_check_undeterminisms → Step .container.
|
||||
${mainErrorMessage(".container")}
|
||||
Initial element has changed:
|
||||
${expectedError}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test.skip("snapshot is the same but has mutated", async () => {
|
||||
macro.onStep = async ({ index }) => {
|
||||
if (index === 2) {
|
||||
setTimeout(() => {
|
||||
const button1 = queryFirst(".button1");
|
||||
button1.setAttribute("data-value", "42");
|
||||
button1.classList.add("brol");
|
||||
button1.removeAttribute("data-value");
|
||||
button1.classList.remove("brol");
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
await waitForMacro();
|
||||
const expectedError = `Initial element has mutated 4 times:
|
||||
[
|
||||
"attribute: data-value",
|
||||
"attribute: class"
|
||||
]`;
|
||||
expect.verifySteps([
|
||||
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
|
||||
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
|
||||
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
|
||||
${mainErrorMessage(".button1")}
|
||||
${expectedError}`,
|
||||
]);
|
||||
});
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { disableTours } from "@web_tour/debug/debug_manager";
|
||||
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
|
||||
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { makeFakeLocalizationService, fakeCommandService } from "@web/../tests/helpers/mock_services";
|
||||
import { DebugMenuParent } from "@web/../tests/core/debug/debug_manager_tests";
|
||||
|
||||
const debugRegistry = registry.category("debug");
|
||||
let target;
|
||||
|
||||
QUnit.module("Tours", (hooks) => {
|
||||
|
||||
QUnit.module("DebugManager");
|
||||
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
registry
|
||||
.category("services")
|
||||
.add("hotkey", hotkeyService)
|
||||
.add("ui", uiService)
|
||||
.add("orm", ormService)
|
||||
.add("localization", makeFakeLocalizationService())
|
||||
.add("command", fakeCommandService);
|
||||
});
|
||||
|
||||
QUnit.test("can disable tours", async (assert) => {
|
||||
debugRegistry.category("default").add("disableTours", disableTours);
|
||||
|
||||
const fakeTourService = {
|
||||
start(env) {
|
||||
return {
|
||||
getActiveTours() {
|
||||
return [{ name: 'a' }, { name: 'b' }];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
registry.category("services").add("tour", fakeTourService);
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (args.method === "consume") {
|
||||
assert.step("consume");
|
||||
assert.deepEqual(args.args[0], ['a', 'b']);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
const env = await makeTestEnv({ mockRPC });
|
||||
|
||||
await mount(DebugMenuParent, target, { env });
|
||||
|
||||
await click(target.querySelector("button.dropdown-toggle"));
|
||||
|
||||
assert.containsOnce(target, ".dropdown-item");
|
||||
await click(target.querySelector(".dropdown-item"));
|
||||
assert.verifySteps(["consume"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { advanceTime, animationFrame, queryFirst } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
getService,
|
||||
makeMockEnv,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Macro } from "@web/core/macro";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
const tourRegistry = registry.category("web_tour.tours");
|
||||
let macro;
|
||||
async function waitForMacro() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await animationFrame();
|
||||
await advanceTime(265);
|
||||
if (macro.isComplete) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!macro.isComplete) {
|
||||
throw new Error(`Macro is not complete`);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(Macro.prototype, {
|
||||
start() {
|
||||
super.start(...arguments);
|
||||
macro = this;
|
||||
},
|
||||
});
|
||||
patchWithCleanup(console, {
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
log: () => {},
|
||||
dir: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
macro.stop();
|
||||
});
|
||||
|
||||
test("Step Tour validity", async () => {
|
||||
patchWithCleanup(console, {
|
||||
error: (msg) => expect.step(msg),
|
||||
});
|
||||
const steps = [
|
||||
{
|
||||
Belgium: true,
|
||||
wins: "of course",
|
||||
EURO2024: true,
|
||||
trigger: "button.foo",
|
||||
},
|
||||
{
|
||||
my_title: "EURO2024",
|
||||
trigger: "button.bar",
|
||||
doku: "Lukaku 10",
|
||||
},
|
||||
{
|
||||
trigger: "button.bar",
|
||||
run: ["Enjoy euro 2024"],
|
||||
},
|
||||
{
|
||||
trigger: "button.bar",
|
||||
run() {},
|
||||
},
|
||||
];
|
||||
tourRegistry.add("tour1", {
|
||||
steps: () => steps,
|
||||
});
|
||||
await makeMockEnv({});
|
||||
const waited_error1 = `Error in schema for TourStep ${JSON.stringify(
|
||||
steps[0],
|
||||
null,
|
||||
4
|
||||
)}\nInvalid object: unknown key 'Belgium', unknown key 'wins', unknown key 'EURO2024'`;
|
||||
const waited_error2 = `Error in schema for TourStep ${JSON.stringify(
|
||||
steps[1],
|
||||
null,
|
||||
4
|
||||
)}\nInvalid object: unknown key 'my_title', unknown key 'doku'`;
|
||||
const waited_error3 = `Error in schema for TourStep ${JSON.stringify(
|
||||
steps[2],
|
||||
null,
|
||||
4
|
||||
)}\nInvalid object: 'run' is not a string or function or boolean`;
|
||||
await getService("tour_service").startTour("tour1");
|
||||
await animationFrame();
|
||||
expect.verifySteps([waited_error1, waited_error2, waited_error3]);
|
||||
});
|
||||
|
||||
test("a tour with invalid step trigger", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
groupCollapsed: (s) => expect.step(`log: ${s}`),
|
||||
log: (s) => expect.step(`log: ${s}`),
|
||||
warn: (s) => {},
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
});
|
||||
tourRegistry.add("tour_invalid_trigger", {
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0:contins(brol)",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1:has(machin)",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour_invalid_trigger", { mode: "auto" }); // Use odoo to run tour from registry because this is a test tour
|
||||
await waitForMacro();
|
||||
const expectedSteps = [
|
||||
"log: [1/2] Tour tour_invalid_trigger → Step .button0:contins(brol)",
|
||||
`error: FAILED: [1/2] Tour tour_invalid_trigger → Step .button0:contins(brol).
|
||||
ERROR during find trigger:
|
||||
Failed to execute 'querySelectorAll' on 'Element': '.button0:contins(brol)' is not a valid selector.`,
|
||||
];
|
||||
expect.verifySteps(expectedSteps);
|
||||
});
|
||||
|
||||
test("a failing tour logs the step that failed in run", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
groupCollapsed: (s) => expect.step(`log: ${s}`),
|
||||
log: (s) => expect.step(`log: ${s}`),
|
||||
warn: (s) => {},
|
||||
error: (s) => {
|
||||
s = s.replace(/\n +at.*/g, ""); // strip stack trace
|
||||
expect.step(`error: ${s}`);
|
||||
},
|
||||
});
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
tourRegistry.add("tour2", {
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1",
|
||||
run() {
|
||||
const el = queryFirst(".wrong_selector");
|
||||
el.click();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour2", { mode: "auto" }); // Use odoo to run tour from registry because this is a test tour
|
||||
await waitForMacro();
|
||||
const expectedError = [
|
||||
"log: [1/2] Tour tour2 → Step .button0",
|
||||
`log: [2/2] Tour tour2 → Step .button1`,
|
||||
[
|
||||
"error: FAILED: [2/2] Tour tour2 → Step .button1.",
|
||||
`TypeError: Cannot read properties of null (reading 'click')`,
|
||||
].join("\n"),
|
||||
];
|
||||
expect.verifySteps(expectedError);
|
||||
});
|
||||
|
||||
test("a failing tour with disabled element", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
log: (s) => {},
|
||||
warn: (s) => {},
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
});
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1" disabled="">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
tourRegistry.add("tour3", {
|
||||
timeout: 500,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button2",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour3", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
const expectedError = [
|
||||
`error: FAILED: [2/3] Tour tour3 → Step .button1.
|
||||
Element has been found.
|
||||
BUT: Element is not enabled. TIP: You can use :enable to wait the element is enabled before doing action on it.
|
||||
TIMEOUT step failed to complete within 500 ms.`,
|
||||
];
|
||||
expect.verifySteps(expectedError);
|
||||
});
|
||||
|
||||
test("a failing tour logs the step that failed", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
dir: (s) => expect.step(`runbot: ${s.replace(/[\s-]*/g, "")}`),
|
||||
groupCollapsed: (s) => expect.step(`log: ${s}`),
|
||||
log: (s) => expect.step(`log: ${s}`),
|
||||
warn: (s) => expect.step(`warn: ${s.replace(/[\s-]*/gi, "")}`),
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
});
|
||||
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
<button class="button3">Button 3</button>
|
||||
<button class="button4">Button 4</button>
|
||||
<button class="button5">Button 5</button>
|
||||
<button class="button6">Button 6</button>
|
||||
<button class="button7">Button 7</button>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
tourRegistry.add("tour1", {
|
||||
steps: () => [
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button1",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button2",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button3",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".wrong_selector",
|
||||
timeout: 111,
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button4",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button5",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button6",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button7",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour1", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps([
|
||||
"log: [1/9] Tour tour1 → Step content (trigger: .button0)",
|
||||
"log: [2/9] Tour tour1 → Step content (trigger: .button1)",
|
||||
"log: [3/9] Tour tour1 → Step content (trigger: .button2)",
|
||||
"log: [4/9] Tour tour1 → Step content (trigger: .button3)",
|
||||
"log: [5/9] Tour tour1 → Step content (trigger: .wrong_selector)",
|
||||
`error: FAILED: [5/9] Tour tour1 → Step content (trigger: .wrong_selector).
|
||||
Element (.wrong_selector) has not been found.
|
||||
TIMEOUT step failed to complete within 111 ms.`,
|
||||
`runbot: {"content":"content","trigger":".button1","run":"click"},{"content":"content","trigger":".button2","run":"click"},{"content":"content","trigger":".button3","run":"click"},FAILED:[5/9]Tourtour1→Stepcontent(trigger:.wrong_selector){"content":"content","trigger":".wrong_selector","run":"click","timeout":111},{"content":"content","trigger":".button4","run":"click"},{"content":"content","trigger":".button5","run":"click"},{"content":"content","trigger":".button6","run":"click"},`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("check tour with inactive steps", async () => {
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
registry.category("web_tour.tours").add("pipu_tour", {
|
||||
steps: () => [
|
||||
{
|
||||
isActive: [".container:not(:has(.this_selector_is_not_here))"],
|
||||
trigger: ".button0",
|
||||
run() {
|
||||
expect.step("this action 1 has not been skipped");
|
||||
},
|
||||
},
|
||||
{
|
||||
isActive: [".container:not(:has(.button0))"],
|
||||
trigger: ".button1",
|
||||
run() {
|
||||
expect.step("this action 2 has been skipped");
|
||||
},
|
||||
},
|
||||
{
|
||||
isActive: [".container:not(:has(.this_selector_is_not_here))"],
|
||||
trigger: ".button2",
|
||||
run() {
|
||||
expect.step("this action 3 has not been skipped");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("pipu_tour", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps([
|
||||
"this action 1 has not been skipped",
|
||||
"this action 3 has not been skipped",
|
||||
]);
|
||||
});
|
||||
|
||||
test("automatic tour with invisible element", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
warn: (s) => {},
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1" style="display:none;">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
registry.category("web_tour.tours").add("tour_de_wallonie", {
|
||||
timeout: 777,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button2",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour_de_wallonie", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps([
|
||||
`error: FAILED: [2/3] Tour tour_de_wallonie → Step .button1.
|
||||
Element has been found.
|
||||
BUT: Element is not visible. TIP: You can use :not(:visible) to force the search for an invisible element.
|
||||
TIMEOUT step failed to complete within 777 ms.`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("automatic tour with invisible element but use :not(:visible))", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
log: (s) => {
|
||||
s.includes("tour succeeded") ? expect.step(`succeeded`) : false;
|
||||
},
|
||||
warn: (s) => {},
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1" style="display:none;">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
registry.category("web_tour.tours").add("tour_de_wallonie", {
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1:not(:visible)",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button2",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour_de_wallonie", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps(["succeeded"]);
|
||||
});
|
||||
|
||||
test("automatic tour with alternative trigger", async () => {
|
||||
let suppressLog = false;
|
||||
patchWithCleanup(browser.console, {
|
||||
groupCollapsed: (s) => {
|
||||
expect.step("on step");
|
||||
suppressLog = true;
|
||||
},
|
||||
groupEnd: () => {
|
||||
suppressLog = false;
|
||||
},
|
||||
log: (s) => {
|
||||
if (suppressLog) {
|
||||
return;
|
||||
}
|
||||
if (s.toLowerCase().includes("tour tour_des_flandres succeeded")) {
|
||||
expect.step("succeeded");
|
||||
} else if (s !== "tour succeeded") {
|
||||
expect.step("on step");
|
||||
}
|
||||
},
|
||||
});
|
||||
registry.category("web_tour.tours").add("tour_des_flandres", {
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".interval, .button1",
|
||||
},
|
||||
{
|
||||
trigger: ".interval, .button3",
|
||||
},
|
||||
{
|
||||
trigger: ".interval1, .interval2, .button4",
|
||||
},
|
||||
{
|
||||
trigger: ".button5",
|
||||
},
|
||||
],
|
||||
});
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
<button class="button3">Button 3</button>
|
||||
<button class="button4">Button 4</button>
|
||||
<button class="button5">Button 5</button>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await mountWithCleanup(Root);
|
||||
await odoo.startTour("tour_des_flandres", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps(["on step", "on step", "on step", "on step", "succeeded"]);
|
||||
});
|
||||
|
||||
test("check not possible to click below modal", async () => {
|
||||
patchWithCleanup(console, {
|
||||
warn: () => {},
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
log: (s) => expect.step(`log: ${s}`),
|
||||
dir: () => {},
|
||||
});
|
||||
class DummyDialog extends Component {
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<button class="a">A</button>
|
||||
<button class="b">B</button>
|
||||
</Dialog>
|
||||
`;
|
||||
}
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<div class="p-3"><button class="button0" t-on-click="openDialog">Button 0</button></div>
|
||||
<div class="p-3"><button class="button1">Button 1</button></div>
|
||||
<div class="p-3"><button class="button2">Button 2</button></div>
|
||||
<div class="p-3"><button class="button3">Button 3</button></div>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
openDialog() {
|
||||
this.dialogService.add(DummyDialog);
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Root);
|
||||
|
||||
registry.category("web_tour.tours").add("tour_check_modal", {
|
||||
timeout: 888,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".button1",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour_check_modal", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps([
|
||||
"log: [1/2] Tour tour_check_modal → Step .button0",
|
||||
"log: [2/2] Tour tour_check_modal → Step .button1",
|
||||
`error: FAILED: [2/2] Tour tour_check_modal → Step .button1.
|
||||
Element has been found.
|
||||
BUT: It is not allowed to do action on an element that's below a modal.
|
||||
TIMEOUT step failed to complete within 888 ms.`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("a tour where hoot trigger failed", async () => {
|
||||
patchWithCleanup(browser.console, {
|
||||
error: (s) => expect.step(`error: ${s}`),
|
||||
});
|
||||
|
||||
class Root extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<button class="button0">Button 0</button>
|
||||
<button class="button1">Button 1</button>
|
||||
<button class="button2">Button 2</button>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Root);
|
||||
tourRegistry.add("tour_hoot_failed", {
|
||||
steps: () => [
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button0",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "content",
|
||||
trigger: ".button1:brol(:machin)",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
await odoo.startTour("tour_hoot_failed", { mode: "auto" });
|
||||
await waitForMacro();
|
||||
expect.verifySteps([
|
||||
`error: FAILED: [2/2] Tour tour_hoot_failed → Step content (trigger: .button1:brol(:machin)).
|
||||
ERROR during find trigger:
|
||||
Failed to execute 'querySelectorAll' on 'Element': '.button1:brol(:machin)' is not a valid selector.`,
|
||||
]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,249 +0,0 @@
|
|||
odoo.define('web_tour.tour_manager_tests', async function (require) {
|
||||
"use strict";
|
||||
|
||||
const core = require("web.core");
|
||||
const KanbanView = require('web.KanbanView');
|
||||
const TourManager = require('web_tour.TourManager');
|
||||
const testUtils = require('web.test_utils');
|
||||
const createView = testUtils.createView;
|
||||
|
||||
/**
|
||||
* Create a widget and a TourManager instance with a list of given Tour objects.
|
||||
* @see `TourManager.register()` for more details on the Tours registry system.
|
||||
* @param {Object} params aside from the parameters defined below, passed
|
||||
* to {@see addMockEnvironment}.
|
||||
* @param {string[]} [params.consumed_tours]
|
||||
* @param {boolean} [params.debug] also passed along
|
||||
* @param {boolean} [params.disabled]
|
||||
* @param {string} params.template inner HTML content of the widget
|
||||
* @param {Object[]} params.tours { {string} name, {Object} option, {Object[]} steps }
|
||||
*/
|
||||
async function createTourManager({ consumed_tours, disabled, template, tours, ...params }) {
|
||||
const parent = await testUtils.createParent(params);
|
||||
const tourManager = new TourManager(parent, consumed_tours, disabled);
|
||||
tourManager.running_step_delay = 0;
|
||||
for (const { name, options, steps } of tours) {
|
||||
tourManager.register(name, options, steps);
|
||||
}
|
||||
const _destroy = tourManager.destroy;
|
||||
tourManager.destroy = function () {
|
||||
tourManager.destroy = _destroy;
|
||||
parent.destroy();
|
||||
};
|
||||
await parent.prependTo(testUtils.prepareTarget(params.debug));
|
||||
parent.el.innerHTML = template;
|
||||
await tourManager._register_all(true);
|
||||
// Wait for possible tooltips to be loaded and appended.
|
||||
await testUtils.nextTick();
|
||||
return tourManager;
|
||||
}
|
||||
|
||||
QUnit.module("Tours", function () {
|
||||
|
||||
QUnit.module("Tour manager");
|
||||
|
||||
QUnit.test("Tours sequence", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const tourManager = await createTourManager({
|
||||
template: `
|
||||
<button class="btn anchor">Anchor</button>`,
|
||||
tours: [
|
||||
{ name: "Tour 1", options: { sequence: 10 }, steps: [{ trigger: '.anchor' }] },
|
||||
{ name: "Tour 2", options: {}, steps: [{ trigger: '.anchor' }] },
|
||||
{ name: "Tour 3", options: { sequence: 5 }, steps: [{ trigger: '.anchor', content: "Oui" }] },
|
||||
],
|
||||
// Use this test in "debug" mode because the tips need to be in
|
||||
// the viewport to be able to test their normal content
|
||||
// (otherwise, the tips would indicate to the users that they
|
||||
// have to scroll).
|
||||
debug: true,
|
||||
});
|
||||
|
||||
assert.containsOnce(document.body, '.o_tooltip:visible');
|
||||
assert.strictEqual($('.o_tooltip_content:visible').text(), "Oui",
|
||||
"content should be that of the third tour");
|
||||
|
||||
tourManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("Displays a rainbow man by default at the end of tours", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
function onShowEffect(params) {
|
||||
assert.deepEqual(params, {
|
||||
fadeout: "medium",
|
||||
message: owl.markup("<strong><b>Good job!</b> You went through all steps of this tour.</strong>"),
|
||||
type: "rainbow_man"
|
||||
});
|
||||
}
|
||||
core.bus.on("show-effect", null, onShowEffect);
|
||||
|
||||
const tourManager = await createTourManager({
|
||||
data: { 'web_tour.tour': { fields: {}, consume() {} } },
|
||||
template: `<button class="btn anchor">Anchor</button>`,
|
||||
tours: [{
|
||||
name: "Some tour",
|
||||
options: {},
|
||||
steps: [{ trigger: '.anchor', content: "anchor" }],
|
||||
}],
|
||||
// Use this test in "debug" mode because the tips need to be in
|
||||
// the viewport to be able to test their normal content
|
||||
// (otherwise, the tips would indicate to the users that they
|
||||
// have to scroll).
|
||||
debug: true,
|
||||
});
|
||||
|
||||
assert.containsOnce(document.body, '.o_tooltip');
|
||||
await testUtils.dom.click($('.anchor'));
|
||||
assert.containsNone(document.body, '.o_tooltip');
|
||||
|
||||
tourManager.destroy();
|
||||
core.bus.off("show-effect", onShowEffect);
|
||||
});
|
||||
|
||||
QUnit.test("Click on invisible tip consumes it", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const tourManager = await createTourManager({
|
||||
data: { 'web_tour.tour': { fields: {}, consume() {} } },
|
||||
template: `
|
||||
<button class="btn anchor1">Anchor</button>
|
||||
<button class="btn anchor2">Anchor</button>
|
||||
`,
|
||||
tours: [{
|
||||
name: "Tour 1",
|
||||
options: { rainbowMan: false, sequence: 10 },
|
||||
steps: [{ trigger: '.anchor1', content: "1" }],
|
||||
}, {
|
||||
name: "Tour 2",
|
||||
options: { rainbowMan: false, sequence: 5 },
|
||||
steps: [{ trigger: '.anchor2', content: "2" }],
|
||||
}],
|
||||
// Use this test in "debug" mode because the tips need to be in
|
||||
// the viewport to be able to test their normal content
|
||||
// (otherwise, the tips would indicate to the users that they
|
||||
// have to scroll).
|
||||
debug: true,
|
||||
});
|
||||
|
||||
assert.containsN(document.body, '.o_tooltip', 2);
|
||||
assert.strictEqual($('.o_tooltip_content:visible').text(), "2");
|
||||
|
||||
await testUtils.dom.click($('.anchor1'));
|
||||
assert.containsOnce(document.body, '.o_tooltip');
|
||||
assert.strictEqual($('.o_tooltip_content:visible').text(), "2");
|
||||
|
||||
await testUtils.dom.click($('.anchor2'));
|
||||
assert.containsNone(document.body, '.o_tooltip');
|
||||
|
||||
tourManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("Step anchor replaced", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const tourManager = await createTourManager({
|
||||
observe: true,
|
||||
template: `<input class="anchor"/>`,
|
||||
tours: [{
|
||||
name: "Tour",
|
||||
options: { rainbowMan: false },
|
||||
steps: [{ trigger: "input.anchor" }],
|
||||
}],
|
||||
});
|
||||
|
||||
assert.containsOnce(document.body, '.o_tooltip:visible');
|
||||
|
||||
|
||||
const $anchor = $(".anchor");
|
||||
const $parent = $anchor.parent();
|
||||
$parent.empty();
|
||||
$parent.append($anchor);
|
||||
// Simulates the observer picking up the mutation and triggering an update
|
||||
tourManager.update();
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.containsOnce(document.body, '.o_tooltip:visible');
|
||||
|
||||
await testUtils.fields.editInput($('.anchor'), "AAA");
|
||||
|
||||
assert.containsNone(document.body, '.o_tooltip:visible');
|
||||
|
||||
tourManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("kanban quick create VS tour tooltips", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: {string: "Foo", type: "char"},
|
||||
bar: {string: "Bar", type: "boolean"},
|
||||
},
|
||||
records: [
|
||||
{id: 1, bar: true, foo: "yop"},
|
||||
]
|
||||
}
|
||||
},
|
||||
arch: `<kanban>
|
||||
<field name="bar"/>
|
||||
<templates><t t-name="kanban-box">
|
||||
<div><field name="foo"/></div>
|
||||
</t></templates>
|
||||
</kanban>`,
|
||||
groupBy: ['bar'],
|
||||
});
|
||||
|
||||
// click to add an element
|
||||
await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
|
||||
assert.containsOnce(kanban, '.o_kanban_quick_create',
|
||||
"should have open the quick create widget");
|
||||
|
||||
// create tour manager targeting the kanban quick create in its steps
|
||||
const tourManager = await createTourManager({
|
||||
observe: true,
|
||||
template: kanban.$el.html(),
|
||||
tours: [{
|
||||
name: "Tour",
|
||||
options: { rainbowMan: false },
|
||||
steps: [{ trigger: "input[name='display_name']" }],
|
||||
}],
|
||||
});
|
||||
|
||||
assert.containsOnce(document.body, '.o_tooltip:visible');
|
||||
|
||||
await testUtils.dom.click($('.o_tooltip:visible'));
|
||||
assert.containsOnce(kanban, '.o_kanban_quick_create',
|
||||
"the quick create should not have been destroyed when tooltip is clicked");
|
||||
|
||||
kanban.destroy();
|
||||
tourManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("Automatic tour disabling", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const options = {
|
||||
template: `<button class="btn anchor">Anchor</button>`,
|
||||
tours: [{ name: "Tour", options: {}, steps: [{ trigger: '.anchor' }] }],
|
||||
};
|
||||
|
||||
const enabledTM = await createTourManager({ disabled: false, ...options });
|
||||
|
||||
assert.containsOnce(document.body, '.o_tooltip:visible');
|
||||
|
||||
enabledTM.destroy();
|
||||
|
||||
const disabledTM = await createTourManager({ disabled: true, ...options });
|
||||
|
||||
assert.containsNone(document.body, '.o_tooltip:visible');
|
||||
|
||||
disabledTM.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { click, edit, keyDown, keyUp, press, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import {
|
||||
contains,
|
||||
defineWebModels,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { TourRecorder } from "@web_tour/js/tour_recorder/tour_recorder";
|
||||
import {
|
||||
TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY,
|
||||
tourRecorderState,
|
||||
} from "@web_tour/js/tour_recorder/tour_recorder_state";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
let tourRecorder;
|
||||
|
||||
beforeEach(async () => {
|
||||
serverState.debug = "1";
|
||||
browser.localStorage.setItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY, "1");
|
||||
patchWithCleanup(TourRecorder.prototype, {
|
||||
setup() {
|
||||
tourRecorder = this;
|
||||
return super.setup(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
});
|
||||
|
||||
const checkTourSteps = (expected) => {
|
||||
expect(tourRecorder.state.steps.map((s) => s.trigger)).toEqual(expected);
|
||||
};
|
||||
|
||||
test("Click on element with unique odoo class", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div class="o_child_1 click"></div>
|
||||
<div class="o_child_2"></div>
|
||||
<div class="o_child_3"></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_child_1"]);
|
||||
|
||||
await click(".o_child_2");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_child_1", ".o_child_2"]);
|
||||
});
|
||||
|
||||
test("Click on element with no unique odoo class", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div class="o_child_1 click"></div>
|
||||
<div class="o_child_1"></div>
|
||||
<div class="o_child_1"></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_child_1:nth-child(1)"]);
|
||||
});
|
||||
|
||||
test("Find the nearest odoo class", async () => {
|
||||
await mountWithCleanup(`<a class="click"></a>`, { noMainContainer: true });
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_web_client > a"]);
|
||||
});
|
||||
|
||||
test("Click on elements with 'data-menu-xmlid' attribute", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div>
|
||||
<div></div>
|
||||
<div data-menu-xmlid="my_menu_1" class="click_1"></div>
|
||||
<div data-menu-xmlid="my_menu_2" class="click_2 o_div"></div>
|
||||
<div></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click_1");
|
||||
await click(".click_2");
|
||||
await animationFrame();
|
||||
checkTourSteps([
|
||||
".o_web_client div[data-menu-xmlid='my_menu_1']",
|
||||
".o_div[data-menu-xmlid='my_menu_2']",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Click on elements with 'name' attribute", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div>
|
||||
<div></div>
|
||||
<div name="sale_id" class="click_1"></div>
|
||||
<div name="partner_id" class="click_2 o_div"></div>
|
||||
<div></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click_1");
|
||||
await click(".click_2");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_web_client div[name='sale_id']", ".o_div[name='partner_id']"]);
|
||||
});
|
||||
|
||||
test("Click on element that have a link or button has parent", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div>
|
||||
<button class="o_button"><i class="click_1">icon</i></button>
|
||||
<a class="o_link"><span class="click_2">This is my link</span></a>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click_1");
|
||||
await click(".click_2");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_button", ".o_link"]);
|
||||
});
|
||||
|
||||
test("Click on element with path that can be reduced", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class=".o_parent">
|
||||
<div name="field_name">
|
||||
<div class="o_div_2">
|
||||
<div class="o_div_3 click"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div name="field_partner_id">
|
||||
<div class="o_div_2">
|
||||
<div class="o_div_3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps(["div[name='field_name'] .o_div_3"]);
|
||||
});
|
||||
|
||||
test("Click on input", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class=".o_parent">
|
||||
<input type="text" class="click o_input"/>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
expect(".o_button_record").toHaveText("Record (recording keyboard)");
|
||||
checkTourSteps([".o_input"]);
|
||||
});
|
||||
|
||||
test("Click on tag that is inside a contenteditable", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class=".o_parent">
|
||||
<div class="o_editor" contenteditable="true">
|
||||
<p class="click oe-hint oe-command-temporary-hint" placeholder="My placeholder..."></p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
expect(".o_button_record").toHaveText("Record (recording keyboard)");
|
||||
checkTourSteps([".o_editor[contenteditable='true']"]);
|
||||
});
|
||||
|
||||
test("Remove step during recording", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div class="o_child click"></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_child"]);
|
||||
await click(".o_button_steps");
|
||||
await animationFrame();
|
||||
contains(".o_button_delete_step").click();
|
||||
await click(".o_button_steps");
|
||||
await animationFrame();
|
||||
checkTourSteps([]);
|
||||
});
|
||||
|
||||
test("Edit input", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<input type="text" class="click o_input"/>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
expect(".o_button_record").toHaveText("Record (recording keyboard)");
|
||||
expect(".click").toBeFocused();
|
||||
await edit("Bismillah");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_input"]);
|
||||
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["edit Bismillah"]);
|
||||
});
|
||||
|
||||
test("Save custom tour", async () => {
|
||||
onRpc("web_tour.tour", "create", ({ args }) => {
|
||||
const tour = args[0][0];
|
||||
expect.step(tour.name);
|
||||
expect.step(tour.url);
|
||||
expect.step(tour.step_ids.length);
|
||||
return true;
|
||||
});
|
||||
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div class="click"></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_parent > div"]);
|
||||
|
||||
await click(".o_button_save");
|
||||
await animationFrame();
|
||||
await contains("input[name='name']").click();
|
||||
await edit("tour_name");
|
||||
await animationFrame();
|
||||
await click(".o_button_save_confirm");
|
||||
await runAllTimers(); // Wait that the save notification disappear
|
||||
|
||||
expect.verifySteps(["tour_name", "/", 1]);
|
||||
});
|
||||
|
||||
test("Drag and drop", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div>
|
||||
<div class="o_drag">drag me</div>
|
||||
</div>
|
||||
<div class="o_drop"></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await contains(".o_drag").dragAndDrop(".o_drop");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_drag"]);
|
||||
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["drag_and_drop .o_drop"]);
|
||||
});
|
||||
|
||||
test("Edit contenteditable", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div class="o_editor click" contenteditable="true" style="width: 50px; height: 50px">
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
expect(".o_editor").toBeFocused();
|
||||
expect(".o_button_record").toHaveText("Record (recording keyboard)");
|
||||
await keyDown("B");
|
||||
await animationFrame();
|
||||
queryOne(".o_editor").appendChild(document.createTextNode("Bismillah"));
|
||||
await keyUp("B");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_editor[contenteditable='true']"]);
|
||||
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["editor Bismillah"]);
|
||||
});
|
||||
|
||||
test("Selecting item in autocomplete field through Enter", async () => {
|
||||
class Dummy extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete id="'autocomplete'" value="'World'" sources="sources"/>`;
|
||||
static props = ["*"];
|
||||
|
||||
sources = [
|
||||
{
|
||||
options: [
|
||||
{ label: "World", onSelect() {} },
|
||||
{ label: "Hello", onSelect() {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
|
||||
await mountWithCleanup(Dummy);
|
||||
await click("#autocomplete");
|
||||
await animationFrame();
|
||||
await press("Enter");
|
||||
checkTourSteps([
|
||||
".o-autocomplete--input",
|
||||
".o-autocomplete--dropdown-item > a:contains('World'), .fa-circle-o-notch",
|
||||
]);
|
||||
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["click", "click"]);
|
||||
});
|
||||
|
||||
test("Edit input after autofocus", async () => {
|
||||
class Dummy extends Component {
|
||||
static components = {};
|
||||
static template = xml/*html*/ `
|
||||
<t>
|
||||
<div class="container">
|
||||
<input type="text" class="o_input" t-ref="input"/>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
useAutofocus({ refName: "input" });
|
||||
}
|
||||
}
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
|
||||
await mountWithCleanup(Dummy);
|
||||
|
||||
expect(".o_input").toBeFocused();
|
||||
expect(".o_button_record").toHaveText("Record");
|
||||
await edit("Bismillah");
|
||||
await animationFrame();
|
||||
expect(".o_button_record").toHaveText("Record (recording keyboard)");
|
||||
checkTourSteps([".o_input"]);
|
||||
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["edit Bismillah"]);
|
||||
});
|
||||
|
||||
test("Check Tour Recorder State", async () => {
|
||||
await mountWithCleanup(
|
||||
`
|
||||
<div class="o_parent">
|
||||
<div class="o_child_1 click"></div>
|
||||
<div class="o_child_2"></div>
|
||||
<div class="o_child_3"></div>
|
||||
</div>
|
||||
`,
|
||||
{ noMainContainer: true }
|
||||
);
|
||||
|
||||
expect(".o_tour_recorder").toHaveCount(1);
|
||||
expect(tourRecorderState.isRecording()).toBe("0");
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
expect(tourRecorderState.isRecording()).toBe("1");
|
||||
expect(tourRecorderState.getCurrentTourRecorder()).toEqual([]);
|
||||
await click(".click");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_child_1"]);
|
||||
|
||||
await click(".o_child_2");
|
||||
await animationFrame();
|
||||
checkTourSteps([".o_child_1", ".o_child_2"]);
|
||||
expect(tourRecorderState.getCurrentTourRecorder()).toEqual([
|
||||
{ trigger: ".o_child_1", run: "click" },
|
||||
{ trigger: ".o_child_2", run: "click" },
|
||||
]);
|
||||
|
||||
await click(".o_button_record");
|
||||
await animationFrame();
|
||||
await click(".o_tour_recorder_close_button");
|
||||
await animationFrame();
|
||||
expect(tourRecorderState.getCurrentTourRecorder()).toEqual([]);
|
||||
expect(tourRecorderState.isRecording()).toBe("0");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue