diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index 9988e7eae74..35dfd21adef 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -3450,6 +3450,39 @@ input[type="radio"], input[type="checkbox"] { opacity: 0.6; } +/* ---- EDITOR TOUR ---- {{{ */ +div.tour-backdrop { + z-index: 2009; +} + +.popover.tour.orphan .arrow { + display: none; +} +.popover.tour .popover-navigation { + padding: 9px 14px; +} +.popover.tour .popover-navigation *[data-role="end"] { + float: right; +} +.popover.tour .popover-navigation *[data-role="next"], .popover.tour .popover-navigation *[data-role="end"] { + cursor: pointer; +} + +.popover.fixed { + position: fixed; +} + +.tour-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1100; + background-color: black; + opacity: 0.8; +} + body { overflow: auto; } diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index b85346a2c8f..abeed36b038 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -2801,6 +2801,33 @@ input[type="radio"], input[type="checkbox"] background-color: black opacity: 0.6000000238418579 +/* ---- EDITOR TOUR ---- {{{ */ + +div.tour-backdrop + z-index: 2009 +.popover.tour + &.orphan .arrow + display: none + .popover-navigation + padding: 9px 14px + *[data-role="end"] + float: right + *[data-role="next"],*[data-role="end"] + cursor: pointer +.popover.fixed + position: fixed +.tour-backdrop + position: fixed + top: 0 + right: 0 + bottom: 0 + left: 0 + z-index: 1100 + background-color: #000 + opacity: 0.8 + + +// }}} body overflow: auto diff --git a/addons/web/static/src/js/tour.js b/addons/web/static/src/js/tour.js new file mode 100644 index 00000000000..08cbd9d9460 --- /dev/null +++ b/addons/web/static/src/js/tour.js @@ -0,0 +1,543 @@ +(function () { + 'use strict'; + +// raise an error in test mode if openerp don't exist +if (typeof openerp === "undefined") { + var error = "openerp is undefined" + + "\nhref: " + window.location.href + + "\nreferrer: " + document.referrer + + "\nlocalStorage: " + window.localStorage.getItem("tour"); + if (typeof $ !== "undefined") { + error += '\n\n' + $("body").html(); + } + throw new Error(error); +} + +var website = openerp.website; + +// don't rewrite T in test mode +if (typeof openerp.Tour !== "undefined") { + return; +} + +///////////////////////////////////////////////// + + +/* jQuery selector to match exact text inside an element + * :containsExact() - case insensitive + * :containsExactCase() - case sensitive + * :containsRegex() - set by user ( use: $(el).find(':containsRegex(/(red|blue|yellow)/gi)') ) + */ +$.extend($.expr[':'],{ + containsExact: function(a,i,m){ + return $.trim(a.innerHTML.toLowerCase()) === m[3].toLowerCase(); + }, + containsExactCase: function(a,i,m){ + return $.trim(a.innerHTML) === m[3]; + }, + // Note all escaped characters need to be double escaped + // inside of the containsRegex, so "\(" needs to be "\\(" + containsRegex: function(a,i,m){ + var regreg = /^\/((?:\\\/|[^\/])+)\/([mig]{0,3})$/, + reg = regreg.exec(m[3]); + return reg ? new RegExp(reg[1], reg[2]).test($.trim(a.innerHTML)) : false; + } +}); +$.ajaxSetup({ + beforeSend:function(){ + $.ajaxBusy = ($.ajaxBusy|0) + 1; + }, + complete:function(){ + $.ajaxBusy--; + } +}); + +///////////////////////////////////////////////// + +var localStorage = window.localStorage; + +var Tour = { + tours: {}, + defaultDelay: 50, + retryRunningDelay: 1000, + errorDelay: 5000, + state: null, + $element: null, + timer: null, + testtimer: null, + currentTimer: null, + register: function (tour) { + if (tour.mode !== "test") tour.mode = "tutorial"; + Tour.tours[tour.id] = tour; + }, + run: function (tour_id, mode) { + var tour = Tour.tours[tour_id]; + if (!tour) { + Tour.error(null, "Can't run '"+tour_id+"' (tour undefined)"); + } + this.time = new Date().getTime(); + if (tour.path && !window.location.href.match(new RegExp("("+Tour.getLang()+")?"+tour.path+"#?$", "i"))) { + var href = Tour.getLang()+tour.path; + console.log("Tour Begin from run method (redirection to "+href+")"); + Tour.saveState(tour.id, mode || tour.mode, -1, 0); + $(document).one("ajaxStop", Tour.running); + window.location.href = href; + } else { + console.log("Tour Begin from run method"); + Tour.saveState(tour.id, mode || tour.mode, 0, 0); + Tour.running(); + } + }, + registerSteps: function (tour, mode) { + if (tour.register) { + return; + } + tour.register = true; + + for (var index=0, len=tour.steps.length; index 0 && tour.steps[index-1] && + tour.steps[index-1].popover && tour.steps[index-1].popover.next) { + step.waitNot = '.popover.tour.fade.in:visible'; + } + if (!step.waitFor && index > 0 && tour.steps[index-1].snippet) { + step.waitFor = '.oe_overlay_options .oe_options:visible'; + } + + + var snippet = step.element && step.element.match(/#oe_snippets (.*) \.oe_snippet_thumbnail/); + if (snippet) { + step.snippet = snippet[1]; + } else if (step.snippet) { + step.element = '#oe_snippets '+step.snippet+' .oe_snippet_thumbnail'; + } + + if (!step.element) { + step.element = "body"; + step.orphan = true; + step.backdrop = true; + } else { + step.popover = step.popover || {}; + step.popover.arrow = true; + } + } + if (tour.steps[index-1] && + tour.steps[index-1].popover && tour.steps[index-1].popover.next) { + var step = { + _title: "close popover and finish", + id: index, + waitNot: '.popover.tour.fade.in:visible' + }; + tour.steps.push(step); + } + + // rendering bootstrap tour and popover + if (mode !== "test") { + for (var index=0, len=tour.steps.length; index'); + } + + if (step.backdrop || $element.parents("#website-top-navbar, .oe_navbar, .modal").size()) { + $tip.css("z-index", 2010); + } + + // button click event + $tip.find("button") + .one("click", function () { + step.busy = true; + if (!$(this).is("[data-role='next']")) { + clearTimeout(Tour.timer); + Tour.endTour(); + } + Tour.closePopover(); + }); + + Tour.repositionPopover(); + }, + repositionPopover: function() { + var popover = Tour.$element.data("bs.popover"); + var $tip = Tour.$element.data("bs.popover").tip(); + + if (popover.options.orphan) { + return $tip.css("top", $(window).outerHeight() / 2 - $tip.outerHeight() / 2); + } + + var offsetBottom, offsetHeight, offsetRight, offsetWidth, originalLeft, originalTop, tipOffset; + offsetWidth = $tip[0].offsetWidth; + offsetHeight = $tip[0].offsetHeight; + tipOffset = $tip.offset(); + originalLeft = tipOffset.left; + originalTop = tipOffset.top; + offsetBottom = $(document).outerHeight() - tipOffset.top - $tip.outerHeight(); + if (offsetBottom < 0) { + tipOffset.top = tipOffset.top + offsetBottom; + } + offsetRight = $("html").outerWidth() - tipOffset.left - $tip.outerWidth(); + if (offsetRight < 0) { + tipOffset.left = tipOffset.left + offsetRight; + } + if (tipOffset.top < 0) { + tipOffset.top = 0; + } + if (tipOffset.left < 0) { + tipOffset.left = 0; + } + $tip.offset(tipOffset); + if (popover.options.placement === "bottom" || popover.options.placement === "top") { + var left = Tour.$element.offset().left + Tour.$element.outerWidth()/2 - tipOffset.left; + $tip.find(".arrow").css("left", left ? left + "px" : ""); + } else if (popover.options.placement !== "auto") { + var top = Tour.$element.offset().top + Tour.$element.outerHeight()/2 - tipOffset.top; + $tip.find(".arrow").css("top", top ? top + "px" : ""); + } + }, + _load_template: false, + load_template: function () { + // don't need template to use bootstrap Tour in automatic mode + Tour._load_template = true; + if (typeof QWeb2 === "undefined") return $.when(); + var def = $.Deferred(); + openerp.qweb.add_template('/web/static/src/xml/website.tour.xml', function(err) { + if (err) { + def.reject(err); + } else { + def.resolve(); + } + }); + return def; + }, + popoverTitle: function (tour, options) { + return typeof QWeb2 !== "undefined" ? openerp.qweb.render('tour.popover_title', options) : options.title; + }, + popover: function (options) { + return typeof QWeb2 !== "undefined" ? openerp.qweb.render('tour.popover', options) : options.title; + }, + getLang: function () { + return $("html").attr("lang") ? "/" + $("html").attr("lang").replace(/-/, '_') : ""; + }, + getState: function () { + var state = JSON.parse(localStorage.getItem("tour") || 'false') || {}; + if (state) { this.time = state.time; } + var tour_id,mode,step_id; + if (!state.id && window.location.href.indexOf("#tutorial.") > -1) { + state = { + "id": window.location.href.match(/#tutorial\.(.*)=true/)[1], + "mode": "tutorial", + "step_id": 0 + }; + window.location.hash = ""; + console.log("Tour Begin from url hash"); + Tour.saveState(state.id, state.mode, state.step_id, 0); + } + if (!state.id) { + return; + } + state.tour = Tour.tours[state.id]; + state.step = state.tour && state.tour.steps[state.step_id === -1 ? 0 : state.step_id]; + return state; + }, + error: function (step, message) { + var state = Tour.getState(); + message += '\n tour: ' + state.id + + (step ? '\n step: ' + step.id + ": '" + (step._title || step.title) + "'" : '' ) + + '\n href: ' + window.location.href + + '\n referrer: ' + document.referrer + + (step ? '\n element: ' + Boolean(!step.element || ($(step.element).size() && $(step.element).is(":visible") && !$(step.element).is(":hidden"))) : '' ) + + (step ? '\n waitNot: ' + Boolean(!step.waitNot || !$(step.waitNot).size()) : '' ) + + (step ? '\n waitFor: ' + Boolean(!step.waitFor || $(step.waitFor).size()) : '' ) + + "\n localStorage: " + JSON.stringify(localStorage) + + '\n\n' + $("body").html(); + Tour.reset(); + if (state.mode === "test") { + throw new Error(message); + } + }, + lists: function () { + var tour_ids = []; + for (var k in Tour.tours) { + tour_ids.push(k); + } + return tour_ids; + }, + saveState: function (tour_id, mode, step_id, number, wait) { + localStorage.setItem("tour", JSON.stringify({ + "id":tour_id, + "mode":mode, + "step_id":step_id || 0, + "time": this.time, + "number": number+1, + "wait": wait || 0 + })); + }, + reset: function () { + var state = Tour.getState(); + if (state && state.tour) { + for (var k in state.tour.steps) { + state.tour.steps[k].busy = false; + } + } + localStorage.removeItem("tour"); + clearTimeout(Tour.timer); + clearTimeout(Tour.testtimer); + Tour.closePopover(); + }, + running: function () { + var state = Tour.getState(); + if (!state) return; + else if (state.tour) { + if (!Tour._load_template) { + Tour.load_template().then(Tour.running); + return; + } + console.log("Tour '"+state.id+"' is running"); + Tour.registerSteps(state.tour, state.mode); + Tour.nextStep(); + } else { + if (state.mode === "test" && state.wait >= 10) { + Tour.error(state.step, "Tour '"+state.id+"' undefined"); + } + Tour.saveState(state.id, state.mode, state.step_id, state.number-1, state.wait+1); + console.log("Tour '"+state.id+"' wait for running (tour undefined)"); + setTimeout(Tour.running, state.mode === "test" ? Tour.defaultDelay : Tour.retryRunningDelay); + } + }, + check: function (step) { + return (step && + (!step.element || ($(step.element).size() && $(step.element).is(":visible") && !$(step.element).is(":hidden"))) && + (!step.waitNot || !$(step.waitNot).size()) && + (!step.waitFor || $(step.waitFor).size())); + }, + waitNextStep: function () { + var state = Tour.getState(); + var time = new Date().getTime(); + var timer; + var next = state.tour.steps[state.step.id+1]; + var overlaps = state.mode === "test" ? Tour.errorDelay : 0; + + window.onbeforeunload = function () { + clearTimeout(Tour.timer); + clearTimeout(Tour.testtimer); + }; + + function checkNext () { + Tour.autoTogglePopover(); + + clearTimeout(Tour.timer); + if (Tour.check(next)) { + clearTimeout(Tour.currentTimer); + // use an other timeout for cke dom loading + Tour.saveState(state.id, state.mode, state.step.id, 0); + setTimeout(function () { + Tour.nextStep(next); + }, Tour.defaultDelay); + } else if (!overlaps || new Date().getTime() - time < overlaps) { + Tour.timer = setTimeout(checkNext, Tour.defaultDelay); + } else { + Tour.error(next, "Can't reach the next step"); + } + } + checkNext(); + }, + nextStep: function (step) { + var state = Tour.getState(); + + if (!state) { + return; + } + + step = step || state.step; + var next = state.tour.steps[step.id+1]; + + if (state.mode === "test" && state.number > 3) { + Tour.error(next, "Cycling. Can't reach the next step"); + } + + Tour.saveState(state.id, state.mode, step.id, state.number); + + if (step.id !== state.step_id) { + console.log("Tour Step: '" + (step._title || step.title) + "' (" + (new Date().getTime() - this.time) + "ms)"); + } + + Tour.autoTogglePopover(true); + + if (step.onload) { + step.onload(); + } + + if (next) { + setTimeout(function () { + if (Tour.getState()) { + Tour.waitNextStep(); + } + if (state.mode === "test") { + setTimeout(function(){ + Tour.autoNextStep(state.tour, step); + }, Tour.defaultDelay); + } + }, next.wait || 0); + } else { + setTimeout(function(){ + Tour.autoNextStep(state.tour, step); + }, Tour.defaultDelay); + Tour.endTour(); + } + }, + endTour: function () { + var state = Tour.getState(); + var test = state.step.id >= state.tour.steps.length-1; + Tour.reset(); + if (test) { + console.log('ok'); + } else { + console.log('error'); + } + }, + autoNextStep: function (tour, step) { + clearTimeout(Tour.testtimer); + + function autoStep () { + if (!step) return; + + if (step.autoComplete) { + step.autoComplete(tour); + } + + $(".popover.tour [data-role='next']").click(); + + var $element = $(step.element); + if (!$element.size()) return; + + if (step.snippet) { + + Tour.autoDragAndDropSnippet($element); + + } else if ($element.is(":visible")) { + + $element.trigger($.Event("mouseenter", { srcElement: $element[0] })); + $element.trigger($.Event("mousedown", { srcElement: $element[0] })); + + var evt = document.createEvent("MouseEvents"); + evt.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); + $element[0].dispatchEvent(evt); + + // trigger after for step like: mouseenter, next step click on button display with mouseenter + setTimeout(function () { + $element.trigger($.Event("mouseup", { srcElement: $element[0] })); + $element.trigger($.Event("mouseleave", { srcElement: $element[0] })); + }, 1000); + } + if (step.sampleText) { + + $element.trigger($.Event("keydown", { srcElement: $element })); + if ($element.is("input") ) { + $element.val(step.sampleText); + } if ($element.is("select")) { + $element.find("[value='"+step.sampleText+"'], option:contains('"+step.sampleText+"')").attr("selected", true); + $element.val(step.sampleText); + } else { + $element.html(step.sampleText); + } + setTimeout(function () { + $element.trigger($.Event("keyup", { srcElement: $element })); + $element.trigger($.Event("change", { srcElement: $element })); + }, self.defaultDelay<<1); + + } + } + Tour.testtimer = setTimeout(autoStep, 100); + }, + autoDragAndDropSnippet: function (selector) { + var $thumbnail = $(selector).first(); + var thumbnailPosition = $thumbnail.position(); + $thumbnail.trigger($.Event("mousedown", { which: 1, pageX: thumbnailPosition.left, pageY: thumbnailPosition.top })); + $thumbnail.trigger($.Event("mousemove", { which: 1, pageX: document.body.scrollWidth/2, pageY: document.body.scrollHeight/2 })); + var $dropZone = $(".oe_drop_zone").first(); + var dropPosition = $dropZone.position(); + $dropZone.trigger($.Event("mouseup", { which: 1, pageX: dropPosition.left, pageY: dropPosition.top })); + } +}; +openerp.Tour = Tour; + +///////////////////////////////////////////////// + +$(document).ready(Tour.running); + +}()); diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 913783a2396..fb2fcbf303e 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -2038,4 +2038,5 @@
+ diff --git a/addons/website/static/src/xml/website.tour.xml b/addons/web/static/src/xml/website.tour.xml similarity index 90% rename from addons/website/static/src/xml/website.tour.xml rename to addons/web/static/src/xml/website.tour.xml index dcecef021bb..cae823bd04e 100644 --- a/addons/website/static/src/xml/website.tour.xml +++ b/addons/web/static/src/xml/website.tour.xml @@ -1,8 +1,8 @@ - +
-
+

@@ -21,7 +21,7 @@
- +
diff --git a/addons/web/views/database_manager.html b/addons/web/views/database_manager.html index 5734f60bc62..19ae6a23bb2 100644 --- a/addons/web/views/database_manager.html +++ b/addons/web/views/database_manager.html @@ -51,6 +51,7 @@ + diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 2b022d86f30..08fc3be483d 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -22,6 +22,7 @@ + @@ -246,6 +247,7 @@ - - diff --git a/addons/website_sale/static/src/js/website.tour.sale.js b/addons/website_sale/static/src/js/website.tour.sale.js index 8cfb4e2cdad..57365a826a6 100644 --- a/addons/website_sale/static/src/js/website.tour.sale.js +++ b/addons/website_sale/static/src/js/website.tour.sale.js @@ -1,9 +1,6 @@ (function () { 'use strict'; - - var website = openerp.website; - - website.Tour.register({ + openerp.Tour.register({ id: 'shop_customize', name: "Customize the page and search a product", path: '/shop', @@ -37,7 +34,7 @@ ] }); - website.Tour.register({ + openerp.Tour.register({ id: 'shop_buy_product', name: "Try to buy products", path: '/shop', diff --git a/addons/website_sale/static/src/js/website.tour.shop.js b/addons/website_sale/static/src/js/website.tour.shop.js index 3dd5b54b357..c39a8b2f9e8 100644 --- a/addons/website_sale/static/src/js/website.tour.shop.js +++ b/addons/website_sale/static/src/js/website.tour.shop.js @@ -1,10 +1,9 @@ (function () { 'use strict'; - var website = openerp.website; var _t = openerp._t; - website.Tour.register({ + openerp.Tour.register({ id: 'shop', name: _t("Create a product"), steps: [ diff --git a/addons/website_sale/tests/test_sale_process.py b/addons/website_sale/tests/test_sale_process.py index b37b514a825..a57a2d64bca 100644 --- a/addons/website_sale/tests/test_sale_process.py +++ b/addons/website_sale/tests/test_sale_process.py @@ -3,22 +3,22 @@ import os import openerp.tests inject = [ - ("openerp.website.Tour", os.path.join(os.path.dirname(__file__), '../../website/static/src/js/website.tour.js')), - ("openerp.website.Tour.ShopTest", os.path.join(os.path.dirname(__file__), "../static/src/js/website.tour.sale.js")), + ("openerp.Tour", os.path.join(os.path.dirname(__file__), '../../web/static/src/js/tour.js')), + ("openerp.Tour.ShopTest", os.path.join(os.path.dirname(__file__), "../static/src/js/website.tour.sale.js")), ] @openerp.tests.common.at_install(False) @openerp.tests.common.post_install(True) class TestUi(openerp.tests.HttpCase): def test_01_admin_shop_tour(self): - self.phantom_js("/", "openerp.website.Tour.run('shop', 'test')", "openerp.website.Tour.tours.shop", login="admin") + self.phantom_js("/", "openerp.Tour.run('shop', 'test')", "openerp.Tour.tours.shop", login="admin") def test_02_admin_checkout(self): - self.phantom_js("/", "openerp.website.Tour.run('shop_customize', 'test')", "openerp.website.Tour.tours.shop_customize", login="admin", inject=inject) - self.phantom_js("/", "openerp.website.Tour.run('shop_buy_product', 'test')", "openerp.website.Tour.tours.shop_buy_product", login="admin", inject=inject) + self.phantom_js("/", "openerp.Tour.run('shop_customize', 'test')", "openerp.Tour.tours.shop_customize", login="admin", inject=inject) + self.phantom_js("/", "openerp.Tour.run('shop_buy_product', 'test')", "openerp.Tour.tours.shop_buy_product", login="admin", inject=inject) def test_03_demo_checkout(self): - self.phantom_js("/", "openerp.website.Tour.run('shop_buy_product', 'test')", "openerp.website.Tour.tours.shop_buy_product", login="demo", inject=inject) + self.phantom_js("/", "openerp.Tour.run('shop_buy_product', 'test')", "openerp.Tour.tours.shop_buy_product", login="demo", inject=inject) def test_04_public_checkout(self): - self.phantom_js("/", "openerp.website.Tour.run('shop_buy_product', 'test')", "openerp.website.Tour.tours.shop_buy_product", inject=inject) + self.phantom_js("/", "openerp.Tour.run('shop_buy_product', 'test')", "openerp.Tour.tours.shop_buy_product", inject=inject)