summaryrefslogtreecommitdiff
path: root/addons/web_tour/static
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_tour/static
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_tour/static')
-rw-r--r--addons/web_tour/static/src/js/debug_manager.js81
-rw-r--r--addons/web_tour/static/src/js/public/tour_manager.js21
-rw-r--r--addons/web_tour/static/src/js/running_tour_action_helper.js175
-rw-r--r--addons/web_tour/static/src/js/tip.js611
-rw-r--r--addons/web_tour/static/src/js/tour_disable.js25
-rw-r--r--addons/web_tour/static/src/js/tour_manager.js510
-rw-r--r--addons/web_tour/static/src/js/tour_service.js189
-rw-r--r--addons/web_tour/static/src/js/tour_step_utils.js161
-rw-r--r--addons/web_tour/static/src/js/tour_utils.js72
-rw-r--r--addons/web_tour/static/src/scss/keyframes.scss1
-rw-r--r--addons/web_tour/static/src/scss/tip.scss240
-rw-r--r--addons/web_tour/static/src/xml/debug_manager.xml54
-rw-r--r--addons/web_tour/static/src/xml/tip.xml9
-rw-r--r--addons/web_tour/static/tests/tour_manager_tests.js205
14 files changed, 2354 insertions, 0 deletions
diff --git a/addons/web_tour/static/src/js/debug_manager.js b/addons/web_tour/static/src/js/debug_manager.js
new file mode 100644
index 00000000..f7bf46ba
--- /dev/null
+++ b/addons/web_tour/static/src/js/debug_manager.js
@@ -0,0 +1,81 @@
+odoo.define('web_tour.DebugManager.Backend', function (require) {
+"use strict";
+
+var core = require("web.core");
+var DebugManager = require('web.DebugManager.Backend');
+var Dialog = require("web.Dialog");
+var local_storage = require('web.local_storage');
+
+var tour = require('web_tour.tour');
+var utils = require('web_tour.utils');
+
+var get_debugging_key = utils.get_debugging_key;
+
+function get_active_tours () {
+ return _.difference(_.keys(tour.tours), tour.consumed_tours);
+}
+
+DebugManager.include({
+ start: function () {
+ this.consume_tours_enabled = get_active_tours().length > 0;
+ return this._super.apply(this, arguments);
+ },
+ consume_tours: function () {
+ var active_tours = get_active_tours();
+ if (active_tours.length > 0) { // tours might have been consumed meanwhile
+ this._rpc({
+ model: 'web_tour.tour',
+ method: 'consume',
+ args: [active_tours],
+ })
+ .then(function () {
+ for (const tourName of active_tours) {
+ local_storage.removeItem(get_debugging_key(tourName));
+ }
+ window.location.reload();
+ });
+ }
+ },
+ start_tour: async function () {
+ const tours = Object.values(tour.tours).sort((t1, t2) => {
+ return (t1.sequence - t2.sequence) || (t1.name < t2.name ? -1 : 1);
+ });
+ const dialog = new Dialog(this, {
+ title: 'Tours',
+ $content: core.qweb.render('web_tour.ToursDialog', {
+ onboardingTours: tours.filter(t => !t.test),
+ testingTours: tours.filter(t => t.test),
+ }),
+ });
+ await dialog.open().opened();
+ dialog.$('.o_start_tour').on('click', this._onStartTour.bind(this));
+ dialog.$('.o_test_tour').on('click', this._onTestTour.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Resets the given tour to its initial step, in onboarding mode.
+ *
+ * @private
+ * @param {MouseEvent}
+ */
+ _onStartTour(ev) {
+ ev.preventDefault();
+ tour.reset($(ev.target).data('name'));
+ },
+ /**
+ * Starts the given tour in test mode.
+ *
+ * @private
+ * @param {MouseEvent}
+ */
+ _onTestTour(ev) {
+ ev.preventDefault();
+ tour.run($(ev.target).data('name'));
+ },
+});
+
+});
diff --git a/addons/web_tour/static/src/js/public/tour_manager.js b/addons/web_tour/static/src/js/public/tour_manager.js
new file mode 100644
index 00000000..a5b4c588
--- /dev/null
+++ b/addons/web_tour/static/src/js/public/tour_manager.js
@@ -0,0 +1,21 @@
+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);
+ });
+ });
+ },
+});
+});
diff --git a/addons/web_tour/static/src/js/running_tour_action_helper.js b/addons/web_tour/static/src/js/running_tour_action_helper.js
new file mode 100644
index 00000000..a034d506
--- /dev/null
+++ b/addons/web_tour/static/src/js/running_tour_action_helper.js
@@ -0,0 +1,175 @@
+
+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);
+ },
+ text_blur: function (text, element) {
+ this._text_blur(this._get_action_values(element), text);
+ },
+ drag_and_drop: function (to, element) {
+ this._drag_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.children("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; });
+ }
+ $selectedOption.prop("selected", true);
+ this._click(values);
+ } 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.trigger("change");
+ },
+ _text_blur: function (values, text) {
+ this._text(values, text);
+ values.$element.trigger('focusout');
+ values.$element.trigger('blur');
+ },
+ _drag_and_drop: function (values, to) {
+ var $to;
+ if (to) {
+ $to = get_jquery_element_from_selector(to);
+ } else {
+ $to = $(document.body);
+ }
+ var elementCenter = values.$element.offset();
+ elementCenter.left += values.$element.outerWidth()/2;
+ elementCenter.top += values.$element.outerHeight()/2;
+
+ var toCenter = $to.offset();
+
+ if (to && to.indexOf('iframe') !== -1) {
+ var iFrameOffset = $('iframe').offset();
+ toCenter.left += iFrameOffset.left;
+ toCenter.top += iFrameOffset.top;
+ }
+ toCenter.left += $to.outerWidth()/2;
+ toCenter.top += $to.outerHeight()/2;
+
+ values.$element.trigger($.Event("mouseenter"));
+ values.$element.trigger($.Event("mousedown", {which: 1, pageX: elementCenter.left, pageY: elementCenter.top}));
+ values.$element.trigger($.Event("mousemove", {which: 1, pageX: toCenter.left, pageY: toCenter.top}));
+ 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) {
+ document.execCommand("insertText", 0, insertedText);
+ }
+ values.$element.trigger(Object.assign({ type: "keyup" }, eventOptions));
+ }
+ },
+});
+
+return RunningTourActionHelper;
+});
diff --git a/addons/web_tour/static/src/js/tip.js b/addons/web_tour/static/src/js/tip.js
new file mode 100644
index 00000000..f768f03f
--- /dev/null
+++ b/addons/web_tour/static/src/js/tip.js
@@ -0,0 +1,611 @@
+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",
+ xmlDependencies: ['/web_tour/static/src/xml/tip.xml'],
+ events: {
+ click: '_onTipClicked',
+ mouseenter: '_onMouseEnter',
+ mouseleave: '_onMouseLeave',
+ transitionend: '_onTransitionEnd',
+ },
+
+ /**
+ * @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,
+ },
+ 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, 50);
+ },
+ /**
+ * 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.double_border_width = 0; // TODO remove me in master
+ 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);
+
+ 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.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();
+ this._delegateEvents();
+ 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._updatePosition(true);
+ },
+
+ //--------------------------------------------------------------------------
+ // 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;
+ }
+ 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') ||
+ (
+ (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 => {
+ this.el.style.setProperty('top', `${props.top}px`, 'important');
+ this.el.style.setProperty('left', `${props.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
+ 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();
+ }
+ },
+ /**
+ * @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.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';
+ }
+ }
+ return "click";
+};
+
+return Tip;
+
+});
diff --git a/addons/web_tour/static/src/js/tour_disable.js b/addons/web_tour/static/src/js/tour_disable.js
new file mode 100644
index 00000000..fad7a14d
--- /dev/null
+++ b/addons/web_tour/static/src/js/tour_disable.js
@@ -0,0 +1,25 @@
+odoo.define('web_tour.DisableTour', function (require) {
+"use strict";
+
+var local_storage = require('web.local_storage');
+var TourManager = require('web_tour.TourManager');
+var utils = require('web_tour.utils');
+
+var get_debugging_key = utils.get_debugging_key;
+
+TourManager.include({
+ /**
+ * Disables tours if Odoo installed with demo data.
+ *
+ * @override
+ */
+ _register: function (do_update, tour, name) {
+ // Consuming tours which are not run by test case nor currently being debugged
+ if (!this.running_tour && !local_storage.getItem(get_debugging_key(name))) {
+ this.consumed_tours.push(name);
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+});
diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js
new file mode 100644
index 00000000..398e8732
--- /dev/null
+++ b/addons/web_tour/static/src/js/tour_manager.js
@@ -0,0 +1,510 @@
+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 RainbowMan = require('web.RainbowMan');
+var RunningTourActionHelper = require('web_tour.RunningTourActionHelper');
+var ServicesMixin = require('web.ServicesMixin');
+var session = require('web.session');
+var Tip = require('web_tour.Tip');
+
+var _t = core._t;
+
+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) {
+ 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.running_tour = local_storage.getItem(get_running_key());
+ 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 {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 {string} [options.rainbowManFadeout]
+ * @param {Object[]} steps - steps' descriptions, each step being an object
+ * containing a tip description
+ */
+ register() {
+ 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,
+ rainbowManFadeout: options.rainbowManFadeout,
+ sequence: options.sequence || 1000,
+ test: options.test,
+ wait_for: options.wait_for || Promise.resolve(),
+ };
+ if (options.skip_enabled) {
+ tour.skip_link = '<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) {
+ 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;
+
+ const debuggingTour = local_storage.getItem(get_debugging_key(name));
+ 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, by the include in tour_disable.js
+ *
+ * @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: 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;
+
+ this.$modal_displayed = $('.modal:visible').last();
+
+ tour_name = this.running_tour || tour_name;
+ if (tour_name) {
+ var tour = this.tours[tour_name];
+ if (!tour || !tour.ready) return;
+
+ if (this.running_tour && this.running_tour_timeout === undefined) {
+ this._set_running_tour_timeout(this.running_tour, this.active_tooltips[this.running_tour]);
+ }
+ var self = this;
+ 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];
+ tip.hidden = visibleTip;
+ visibleTip = this._check_for_tooltip(tip, tourName) || visibleTip;
+ }
+ }
+ },
+ /**
+ * 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;
+ }
+
+ 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: 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 = _t('<strong><b>Good job!</b> You went through all steps of this tour.</strong>');
+ }
+ new RainbowMan({
+ message: message,
+ fadeout: this.tours[tour_name].rainbowManFadeout || 'medium',
+ }).appendTo(this.$body);
+ }
+ 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);
+ });
+ console.log(document.body.parentElement.outerHTML);
+ 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() {
+ 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") {
+ tip.run.call(tip.widget, action_helper);
+ } else if (tip.run !== undefined) {
+ var m = tip.run.match(/^([a-zA-Z0-9_]+) *(?:\(? *(.+?) *\)?)?$/);
+ action_helper[m[1]](m[2]);
+ } 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)
+});
+});
diff --git a/addons/web_tour/static/src/js/tour_service.js b/addons/web_tour/static/src/js/tour_service.js
new file mode 100644
index 00000000..825b275c
--- /dev/null
+++ b/addons/web_tour/static/src/js/tour_service.js
@@ -0,0 +1,189 @@
+odoo.define('web_tour.tour', function (require) {
+"use strict";
+
+var rootWidget = require('root.widget');
+var rpc = require('web.rpc');
+var session = require('web.session');
+var TourManager = require('web_tour.TourManager');
+
+const untrackedClassnames = ["o_tooltip", "o_tooltip_content", "o_tooltip_overlay"];
+
+/**
+ * @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;
+ var tour_manager = new TourManager(rootWidget, consumed_tours);
+
+ function _isTrackedNode(node) {
+ if (node.classList) {
+ return !untrackedClassnames
+ .some(className => node.classList.contains(className));
+ }
+ return true;
+ }
+
+ 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;
+ }
+
+ if (mutation.type === 'characterData') {
+ return true;
+ }
+
+ 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();
+
+ // 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();
+ }
+ }
+
+ // 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);
+ });
+
+ // Now that the observer is configured, we have to start it when needed.
+ 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, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ attributeOldValue: true,
+ characterData: 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;
+ });
+});
+
+});
diff --git a/addons/web_tour/static/src/js/tour_step_utils.js b/addons/web_tour/static/src/js/tour_step_utils.js
new file mode 100644
index 00000000..dc69e896
--- /dev/null
+++ b/addons/web_tour/static/src/js/tour_step_utils.js
@@ -0,0 +1,161 @@
+odoo.define('web_tour.TourStepUtils', function (require) {
+'use strict';
+
+const core = require('web.core');
+const _t = core._t;
+
+return core.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_menu_apps a',
+ auto: true,
+ position: 'bottom',
+ };
+ },
+
+ toggleHomeMenu() {
+ return {
+ edition: 'enterprise',
+ trigger: '.o_main_navbar .o_menu_toggle',
+ content: _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:not(.d-none):first',
+ 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)));
+ },
+});
+});
diff --git a/addons/web_tour/static/src/js/tour_utils.js b/addons/web_tour/static/src/js/tour_utils.js
new file mode 100644
index 00000000..ca6f11f8
--- /dev/null
+++ b/addons/web_tour/static/src/js/tour_utils.js
@@ -0,0 +1,72 @@
+odoo.define('web_tour.utils', function(require) {
+"use strict";
+
+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 ($i.is(':visible:hasVisibility')) {
+ 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) {
+ if (_.isString(selector) && selector.indexOf('iframe') !== -1) {
+ var $iframe = $(selector.split('iframe')[0] + ' iframe');
+ var $el = $iframe.contents()
+ .find(selector.split('iframe')[1]);
+ $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,
+};
+
+});
+
diff --git a/addons/web_tour/static/src/scss/keyframes.scss b/addons/web_tour/static/src/scss/keyframes.scss
new file mode 100644
index 00000000..74f3bbf0
--- /dev/null
+++ b/addons/web_tour/static/src/scss/keyframes.scss
@@ -0,0 +1 @@
+// TODO remove in master
diff --git a/addons/web_tour/static/src/scss/tip.scss b/addons/web_tour/static/src/scss/tip.scss
new file mode 100644
index 00000000..468e82da
--- /dev/null
+++ b/addons/web_tour/static/src/scss/tip.scss
@@ -0,0 +1,240 @@
+$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;
+ }
+}
diff --git a/addons/web_tour/static/src/xml/debug_manager.xml b/addons/web_tour/static/src/xml/debug_manager.xml
new file mode 100644
index 00000000..2585c1f6
--- /dev/null
+++ b/addons/web_tour/static/src/xml/debug_manager.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+<t t-extend="WebClient.DebugManager.Backend">
+ <t t-jquery="a[data-action='select_view']" t-operation="after">
+ <t t-if="manager._is_admin">
+ <a t-if="manager.consume_tours_enabled" href="#" class="dropdown-item" data-action="consume_tours">Disable Tours</a>
+ <a href="#" class="dropdown-item" data-action="start_tour">Start Tour</a>
+ </t>
+ </t>
+</t>
+
+<t t-name="web_tour.ToursDialog">
+ <div>
+ <t t-call="web_tour.ToursDialog.Table">
+ <t t-set="caption" t-value="'Onboarding tours'"/>
+ <t t-set="tours" t-value="onboardingTours"/>
+ </t>
+ <t t-if="testingTours.length" t-call="web_tour.ToursDialog.Table">
+ <t t-set="caption" t-value="'Testing tours'"/>
+ <t t-set="tours" t-value="testingTours"/>
+ </t>
+ </div>
+</t>
+
+<t t-name="web_tour.ToursDialog.Table">
+ <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">
+ <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-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-att-data-name="tour.name" aria-label="Test tour" title="Test tour"/></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</t>
+
+</templates>
diff --git a/addons/web_tour/static/src/xml/tip.xml b/addons/web_tour/static/src/xml/tip.xml
new file mode 100644
index 00000000..eba661f6
--- /dev/null
+++ b/addons/web_tour/static/src/xml/tip.xml
@@ -0,0 +1,9 @@
+<?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-raw="widget.info.content"/>
+ </div>
+ </div>
+</templates>
diff --git a/addons/web_tour/static/tests/tour_manager_tests.js b/addons/web_tour/static/tests/tour_manager_tests.js
new file mode 100644
index 00000000..aa2dff5f
--- /dev/null
+++ b/addons/web_tour/static/tests/tour_manager_tests.js
@@ -0,0 +1,205 @@
+odoo.define('web_tour.tour_manager_tests', async function (require) {
+ "use strict";
+
+ const KanbanView = require('web.KanbanView');
+ const TourManager = require('web_tour.TourManager');
+ const testUtils = require('web.test_utils');
+ const createView = testUtils.createView;
+
+ const ajax = require('web.ajax');
+ const { qweb } = require('web.core');
+
+ // Pre-load the Tip widget template
+ await ajax.loadXML('/web_tour/static/src/xml/tip.xml', qweb);
+
+ /**
+ * 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
+ * @param {string[]} [params.consumed_tours]
+ * @param {boolean} [params.debug]
+ * @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, debug, template, tours }) {
+ const parent = await testUtils.createParent({ debug });
+ const tourManager = new TourManager(parent, consumed_tours);
+ 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(debug));
+ parent.el.innerHTML = template;
+ testUtils.mock.patch(TourManager, {
+ // Since the `tour_disable.js` script automatically sets tours as consumed
+ // as soon as they are registered, we override the "is consumed" to
+ // assert that the tour is in the `consumed_tours` param key.
+ _isTourConsumed: name => (consumed_tours || []).includes(name),
+ });
+ await tourManager._register_all(true);
+ // Wait for possible tooltips to be loaded and appended.
+ await testUtils.nextTick();
+ return tourManager;
+ }
+
+ QUnit.module("Tours", {
+ afterEach() {
+ testUtils.mock.unpatch(TourManager);
+ },
+ }, 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("Click on invisible tip consumes it", async function (assert) {
+ assert.expect(5);
+
+ const tourManager = await createTourManager({
+ 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();
+ });
+ });
+});