[ADD] openerp,web,web_tour: new web_tour addon

Join work with ged
This commit is contained in:
Aaron Bohy
2016-06-16 14:59:40 +02:00
parent 6416c46d85
commit 38bb6a7f81
17 changed files with 585 additions and 1 deletions
+2 -1
View File
@@ -291,7 +291,7 @@
<t t-foreach="menu_data['children']" t-as="menu">
<div style="display: none" class="oe_secondary_menu" t-att-data-menu-parent="menu['id']">
<t t-foreach="menu['children']" t-as="menu">
<div class="oe_secondary_menu_section">
<div class="oe_secondary_menu_section" t-att-data-menu-xmlid="menu.get('xmlid')">
<t t-if="menu['children']"><t t-esc="menu['name']"/></t>
<t t-if="not menu['children']"><t t-call="web.menu_link"/></t>
</div>
@@ -317,6 +317,7 @@
<a t-att-href="'/web%s#menu_id=%s&amp;action=%s' % (debug_param, menu['id'], menu['action'] and menu['action'].split(',')[1] or '')"
t-att-class="'oe_menu_toggler' if menu.get('children') else 'oe_menu_leaf'"
t-att-data-menu="menu['id']"
t-att-data-menu-xmlid="menu.get('xmlid')"
t-att-data-action-model="menu['action'] and menu['action'].split(',')[0] or None"
t-att-data-action-id="menu['action'] and menu['action'].split(',')[1] or None">
<span class="oe_menu_text">
+1
View File
@@ -0,0 +1 @@
import models
+20
View File
@@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Tours',
'category': 'Usability',
'description': """
Odoo Web tours.
========================
""",
'version': '0.1',
'depends': ['web'],
'data': [
'assets.xml',
'views/tour.xml'
],
'qweb': [
"static/src/xml/*.xml",
],
'auto_install': True
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<template id="assets_common" name="tours assets" inherit_id="web.assets_common">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/web_tour/static/src/less/tip.less"/>
<link rel="stylesheet" href="/web_tour/static/src/less/keyframes.less"/>
<script type="text/javascript" src="/web_tour/static/src/js/tip.js"></script>
<script type="text/javascript" src="/web_tour/static/src/js/tour_manager.js"></script>
<script type="text/javascript" src="/web_tour/static/src/js/tour_service.js"></script>
</xpath>
</template>
</data>
</openerp>
+3
View File
@@ -0,0 +1,3 @@
import ir_http
import ir_ui_menu
import tour
+13
View File
@@ -0,0 +1,13 @@
from openerp import models
from openerp.http import request
class Http(models.Model):
_inherit = 'ir.http'
def session_info(self):
result = super(Http, self).session_info()
if result['is_admin']:
consumed_tours = request.env['web_tour.tour'].search([('user_id', '=', request.env.uid)])
result['web_tours'] = [t.name for t in consumed_tours]
return result
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
class IrUiMenu(models.Model):
_inherit = 'ir.ui.menu'
load_xmlid = fields.Boolean(default=False)
@api.model
@tools.ormcache_context('self._uid', 'debug', keys=('lang',))
def load_menus(self, debug):
""" Extends load_menus to include requested xmlids """
menu_root = super(IrUiMenu, self).load_menus(debug)
menu_ids = [menu.id for menu in self.browse(menu_root['all_menu_ids']) if menu.load_xmlid]
xmlids = {
d.res_id: d.module + "." + d.name
for d in self.env['ir.model.data'].search([('res_id', 'in', menu_ids), ('model', '=', 'ir.ui.menu')])
}
def _find_subtree(tree, node_id):
# Returns the subtree whose id is node_id
if tree['id'] == node_id:
return tree
else:
for child in tree['children']:
subtree = _find_subtree(child, node_id)
if subtree:
return subtree
for menu_id, menu_xmlid in xmlids.iteritems():
_find_subtree(menu_root, menu_id)['xmlid'] = menu_xmlid
return menu_root
+19
View File
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class Tour(models.Model):
_name = "web_tour.tour"
_description = "Tours"
name = fields.Char(string="Tour name", required=True)
user_id = fields.Many2one('res.users', string='Consumed by')
@api.model
def consume(self, name):
""" Sets tour 'name' as consumed for the current user, meaning that
this tour won't be active anymore for that user """
self.create({'name': name, 'user_id': self.env.uid})
+77
View File
@@ -0,0 +1,77 @@
odoo.define('web_tour.Tip', function(require) {
"use strict";
var Widget = require('web.Widget');
return Widget.extend({
template: "Tip",
init: function(parent, $anchor, info) {
this._super(parent);
this.$anchor = $anchor;
this.info = _.defaults(info, {
position: 'right',
});
this.consumed = false;
},
start: function() {
this.$breathing = this.$('.o_breathing');
this.$breathing.on('mouseenter', this._to_info_mode.bind(this));
this._bind_anchor_events();
this._reposition();
},
update: function($anchor) {
if (!$anchor.is(this.$anchor)) {
this.$anchor.off();
this.$anchor = $anchor;
this._bind_anchor_events();
}
this._reposition();
},
_reposition: function() {
this.$breathing.position({
my: this._invert_position(this.info.position),
at: this.info.position,
of: this.$anchor,
collision: 'fit',
});
},
_bind_anchor_events: function () {
var self = this;
this.$anchor.on('mouseenter', this._to_info_mode.bind(this));
this.$anchor.on('mouseleave', this._to_bubble_mode.bind(this));
this.$anchor.on(this.$anchor.is('input,textarea') ? 'change' : 'mousedown', function () {
if (self.consumed) return;
self.consumed = true;
self.trigger('tip_consumed');
});
},
_invert_position: function (position) {
if (position === "right") return "left";
if (position === "left") return "right";
if (position === "bottom") return "top";
return "bottom";
},
_to_info_mode: function() {
this.$breathing.fadeOut(300);
this.$popover = this.$popover || this.$anchor.popover({
content: this.info.content + (this.info.extra_content || ''),
html: true,
animation: false,
container: this.$el,
placement: this.info.position,
});
this.$popover.popover('show');
this.$('.popover').on('mouseleave.to_bubble_mode', this._to_bubble_mode.bind(this));
this.$('.popover').on('click', this.trigger.bind(this, 'popover_clicked'));
},
_to_bubble_mode: function () {
this.$breathing.fadeIn(300);
if (this.$popover) {
this.$popover.popover('hide');
this.$('.popover').off('mouseleave.to_bubble_mode');
this.$('.popover').off('click');
}
},
});
});
@@ -0,0 +1,174 @@
odoo.define('web_tour.TourManager', function(require) {
"use strict";
var core = require('web.core');
var local_storage = require('web.local_storage');
var Model = require('web.Model');
var Tip = require('web_tour.Tip');
var _t = core._t;
var RUNNING_TOUR_TIMEOUT = 3000;
function getStepKey(name) {
return 'tour_' + name + '_step';
}
function getRunningKey() {
return 'running_tour';
}
return core.Class.extend({
init: function(consumed_tours) {
this.active_tooltips = {};
this.tours = {};
this.consumed_tours = consumed_tours;
this.running_tour = local_storage.getItem(getRunningKey());
this.TourModel = new Model('web_tour.tour');
},
/**
* Registers a tour described by the following arguments (in order)
* @param [String] tour's name
* @param [Object] dict of options (optional), available options are:
* test [Boolean] true if the tour is dedicated to tests (it won't be enabled by default)
* skip_enabled [Boolean] true to add a link to consume the whole tour in its tips
* url [String] the url to load when manually running the tour
* @param [Array] dict of steps, each step being a dict containing a tip description
*/
register: function() {
var args = Array.prototype.slice.call(arguments);
var last_arg = args[args.length - 1];
var name = args[0];
if (this.tours[name]) {
console.warn(_.str.sprintf(_t("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: name,
current_step: parseInt(local_storage.getItem(getStepKey(name))) || 0,
skip_enabled: options.skip_enabled,
steps: steps,
url: options.url,
test: options.test,
};
this.tours[name] = tour;
if (name === this.running_tour || (!tour.test && !_.contains(this.consumed_tours, name))) {
this.active_tooltips[name] = steps[tour.current_step];
}
},
run: function(tour_name) {
if (this.running_tour) {
console.warn(_.str.sprintf(_t("Killing tour %s"), tour_name));
this._deactivate_tip(this.active_tooltips[tour_name]);
this._consume_tour(tour_name);
return;
}
var tour = this.tours[tour_name];
if (!tour) {
console.warn(_.str.sprintf(_t("Unknown Tour %s"), name));
return;
}
console.log(_.str.sprintf(_t("Running tour %s"), tour_name));
local_storage.setItem(getRunningKey(), tour_name);
if (tour.url) {
window.location = tour.url;
}
this.running_tour = tour_name;
this.active_tooltips[tour_name] = tour.steps[0];
this._set_running_tour_timeout(tour_name, tour.steps[0]);
this.update();
},
/**
* Checks for tooltips to activate (only from the running tour if there is one, from all
* active tours otherwise). Should be called each time the DOM changes.
*/
update: function() {
if (this.running_tour) {
this._check_for_tooltip(this.active_tooltips[this.running_tour], this.running_tour);
} else {
_.each(this.active_tooltips, this._check_for_tooltip.bind(this));
}
},
_check_for_tooltip: function (tip, tour_name) {
var $trigger = $(tip.trigger).filter(':visible').first();
var extra_trigger = tip.extra_trigger ? $(tip.extra_trigger).filter(':visible').length : true;
var triggered = $trigger.length && extra_trigger;
if (triggered) {
if (!tip.widget) {
this._activate_tip(tip, tour_name, $trigger);
} else {
tip.widget.update($trigger);
}
} else {
this._deactivate_tip(tip);
}
},
_activate_tip: function(tip, tour_name, $anchor) {
var skip_enabled = this.tours[tour_name].skip_enabled;
if (skip_enabled && !tip.extra_content) {
// FIXME: not pretty but a cleaner solution will be easy when we'll stop using jquery's popover
tip.extra_content = '<br/><span class="o_skip_tour">' + _t('Skip tour') + '</span>';
}
tip.widget = new Tip(this, $anchor, tip);
tip.widget.appendTo(document.body);
tip.widget.on('tip_consumed', this, this._consume_tip.bind(this, tip, tour_name));
if (skip_enabled) {
tip.widget.on('popover_clicked', this, function (event) {
if (event.target.className === 'o_skip_tour') {
this._deactivate_tip(tip);
this._consume_tour(tour_name);
}
});
}
if (this.running_tour === tour_name) {
clearTimeout(this.running_tour_timeout);
if (tip.run) {
this._consume_tip(tip, tour_name);
tip.run.apply(tip);
}
}
},
_deactivate_tip: function(tip) {
if (tip.widget) {
tip.widget.destroy();
delete tip.widget;
}
},
_consume_tip: function(tip, tour_name) {
this._deactivate_tip(tip);
var tour = this.tours[tour_name];
if (tour.current_step < tour.steps.length - 1) {
tour.current_step = tour.current_step + 1;
this.active_tooltips[tour_name] = tour.steps[tour.current_step];
local_storage.setItem(getStepKey(tour_name), tour.current_step);
if (this.running_tour === tour_name) {
this._set_running_tour_timeout(tour_name, this.active_tooltips[tour_name]);
}
} else {
this._consume_tour(tour_name);
}
},
_consume_tour: function(tour_name) {
delete this.active_tooltips[tour_name];
this.tours[tour_name].current_step = 0;
local_storage.removeItem(getStepKey(tour_name));
if (this.running_tour === tour_name) {
local_storage.removeItem(getRunningKey());
this.running_tour = undefined;
clearTimeout(this.running_tour_timeout);
} else {
this.TourModel.call('consume', [tour_name]);
}
},
_set_running_tour_timeout: function(tour_name, step) {
if (!step.run) { return; } // don't set a timeout if the current step requires a manual action
var self = this;
this.running_tour_timeout = setTimeout(function() {
console.error(_.str.sprintf(_t("Tour %s failed at step %s"), tour_name, step.trigger));
self._consume_tour(tour_name);
}, RUNNING_TOUR_TIMEOUT);
},
});
});
@@ -0,0 +1,53 @@
odoo.define('web_tour.tour', function(require) {
"use strict";
var config = require('web.config');
var session = require('web.session');
var TourManager = require('web_tour.TourManager');
if (config.device.size_class <= config.device.SIZES.XS) { return; }
return session.is_bound.then(function () {
var tour = new TourManager(session.web_tours);
// Use a MutationObserver to detect DOM changes
var untracked_classnames = ['o_tooltip', 'o_breathing'];
var check_tooltip = _.throttle(function (records) {
var update = _.find(records, function (record) {
var record_class = record.target.className;
return !_.isString(record_class) ||
_.intersection(record_class.split(' '), untracked_classnames).length === 0;
});
if (update) { // ignore mutations in the tooltip itself
tour.update();
}
}, 500, {leading: false});
var observer = new MutationObserver(check_tooltip);
var observe = observer.observe.bind(observer, document.body, {
attributes: true,
childList: true,
subtree: true,
});
// Enable the MutationObserver for the admin or if a tour is running, when the DOM is ready
if (session.is_admin || tour.running_tour) {
$(observe);
}
// Override the TourManager so that it enables/disables the observer when necessary
if (!session.is_admin) {
var run = tour.run;
tour.run = function () {
run.apply(this, arguments);
if (this.running_tour) { observe(); }
};
var _consume_tour = tour._consume_tour;
tour._consume_tour = function () {
_consume_tour.apply(this, arguments);
observer.disconnect();
};
}
return tour;
});
});
@@ -0,0 +1,33 @@
@-webkit-keyframes move-left-right {
0% {
.translate(0, 0);
}
100% {
.translate(5px, 0);
}
}
@-moz-keyframes move-left-right {
0% {
.translate(0px, 0px);
}
100% {
.translate(5px, 0px);
}
}
@-webkit-keyframes move-bottom-top {
0% {
.translate(0, 0);
}
100% {
.translate(0, 5px);
}
}
@-moz-keyframes move-bottom-top {
0% {
.translate(0px, 0px);
}
100% {
.translate(0, 5px);
}
}
+92
View File
@@ -0,0 +1,92 @@
@o-tip-background-color: #875a7b;
.o_tooltip {
.o_breathing {
position: absolute;
width: 30px;
height: 30px;
border-radius: 50%;
z-index: @zindex-modal+1;
background-color: @o-tip-background-color;
.animation-duration(0.5s);
.animation-timing-function(ease-in);
.animation-iteration-count(infinite);
.animation-direction(alternate);
&.right, &.left {
.animation-name(move-left-right);
}
&.top, &.bottom {
.animation-name(move-bottom-top);
}
&::after {
content: '';
width: 0;
height: 0;
}
&.right::after {
.o-position-absolute(@left: -4px, @top: 5px);
border-right: 10px solid @o-tip-background-color;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
}
&.top::after {
.o-position-absolute(@bottom: -4px, @left: 5px);
border-top: 10px solid @o-tip-background-color;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
}
&.left::after {
.o-position-absolute(@right: -4px, @bottom: 5px);
border-left: 10px solid @o-tip-background-color;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
}
&.bottom::after {
.o-position-absolute(@top: -4px, @right: 5px);
border-bottom: 10px solid @o-tip-background-color;
border-right: 10px solid transparent;
border-left: 10px solid transparent;
}
&::before {
.o-position-absolute(@top: 10px, @left: 10px);
content: '?';
width: 10px;
height: 10px;
font-size: 18px;
font-weight: 800;
line-height: 0.6;
color: lightgray;
border-radius: 50%;
}
}
.popover {
border: 2px solid @odoo-brand-primary;
&.right > .arrow {
border-right-color: @odoo-brand-primary;
left: -13px;
&::after {
border-left-width: 2px;
}
}
&.bottom > .arrow {
border-bottom-color: @odoo-brand-primary;
top: -13px;
&::after {
border-top-width: 2px;
}
}
.popover-content {
color: black;
text-align: justify;
.o_skip_tour {
display: inline-block;
margin-top: 5px;
cursor: pointer;
color: gray;
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Tip">
<div class="o_tooltip">
<div t-attf-class="o_breathing #{widget.info.position}"/>
</div>
</t>
</templates>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<record id="edit_tour_list" model="ir.ui.view">
<field name="model">web_tour.tour</field>
<field name="arch" type="xml">
<tree string="Menu" editable="bottom">
<field name="name"/>
<field name="user_id"/>
</tree>
</field>
</record>
<record id="edit_tour_search" model="ir.ui.view">
<field name="name">tour.search</field>
<field name="model">web_tour.tour</field>
<field name="arch" type="xml">
<search string="Tip">
<field name="name"/>
</search>
</field>
</record>
<record id="edit_tour_action" model="ir.actions.act_window">
<field name="name">Tours</field>
<field name="res_model">web_tour.tour</field>
<field name="view_type">form</field>
<field name="view_id" ref="edit_tour_list"/>
<field name="search_view_id" ref="edit_tour_search"/>
</record>
<menuitem action="edit_tour_action" id="menu_tour_action" parent="base.next_id_2" sequence="5"/>
</data>
</openerp>
+3
View File
@@ -242,6 +242,9 @@
<rng:optional> <rng:attribute name="action"/> </rng:optional>
<rng:optional> <rng:attribute name="sequence"/> </rng:optional>
<rng:optional> <rng:attribute name="groups"/> </rng:optional>
<rng:optional>
<rng:attribute name="load_xmlid"><rng:value>True</rng:value></rng:attribute>
</rng:optional>
<!-- TODO: remove icon stuff -->
<rng:optional> <rng:attribute name="icon"/> </rng:optional>
<rng:optional> <rng:attribute name="web_icon"/> </rng:optional>
+2
View File
@@ -532,6 +532,8 @@ form: module.record_id""" % (xml_id,)
# ensure menu has a name
values['name'] = rec_id or '?'
if rec.get('load_xmlid'):
values['load_xmlid'] = True
if rec.get('sequence'):
values['sequence'] = int(rec.get('sequence'))