diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web_tour/static | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web_tour/static')
| -rw-r--r-- | addons/web_tour/static/src/js/debug_manager.js | 81 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/public/tour_manager.js | 21 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/running_tour_action_helper.js | 175 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/tip.js | 611 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/tour_disable.js | 25 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/tour_manager.js | 510 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/tour_service.js | 189 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/tour_step_utils.js | 161 | ||||
| -rw-r--r-- | addons/web_tour/static/src/js/tour_utils.js | 72 | ||||
| -rw-r--r-- | addons/web_tour/static/src/scss/keyframes.scss | 1 | ||||
| -rw-r--r-- | addons/web_tour/static/src/scss/tip.scss | 240 | ||||
| -rw-r--r-- | addons/web_tour/static/src/xml/debug_manager.xml | 54 | ||||
| -rw-r--r-- | addons/web_tour/static/src/xml/tip.xml | 9 | ||||
| -rw-r--r-- | addons/web_tour/static/tests/tour_manager_tests.js | 205 |
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(); + }); + }); +}); |
