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/src/js/tour_manager.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web_tour/static/src/js/tour_manager.js')
| -rw-r--r-- | addons/web_tour/static/src/js/tour_manager.js | 510 |
1 files changed, 510 insertions, 0 deletions
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) +}); +}); |
