[MERGE] forward port of branch 8.0 up to ed92589
[odoo/odoo.git] / addons / web / static / src / js / core.js
1
2 (function() {
3
4 if (typeof(console) === "undefined") {
5     // Even IE9 only exposes console object if debug window opened
6     window.console = {};
7     ('log error debug info warn assert clear dir dirxml trace group'
8         + ' groupCollapsed groupEnd time timeEnd profile profileEnd count'
9         + ' exception').split(/\s+/).forEach(function(property) {
10             console[property] = _.identity;
11     });
12 }
13
14 var instance = openerp;
15 openerp.web.core = {};
16
17 var ControllerMixin = {
18     /**
19      * Informs the action manager to do an action. This supposes that
20      * the action manager can be found amongst the ancestors of the current widget.
21      * If that's not the case this method will simply return `false`.
22      */
23     do_action: function() {
24         var parent = this.getParent();
25         if (parent) {
26             return parent.do_action.apply(parent, arguments);
27         }
28         return false;
29     },
30     do_notify: function() {
31         if (this.getParent()) {
32             return this.getParent().do_notify.apply(this,arguments);
33         }
34         return false;
35     },
36     do_warn: function() {
37         if (this.getParent()) {
38             return this.getParent().do_warn.apply(this,arguments);
39         }
40         return false;
41     },
42     rpc: function(url, data, options) {
43         return this.alive(openerp.session.rpc(url, data, options));
44     }
45 };
46
47 /**
48     A class containing common utility methods useful when working with OpenERP as well as the PropertiesMixin.
49 */
50 openerp.web.Controller = openerp.web.Class.extend(openerp.web.PropertiesMixin, ControllerMixin, {
51     /**
52      * Constructs the object and sets its parent if a parent is given.
53      *
54      * @param {openerp.web.Controller} parent Binds the current instance to the given Controller instance.
55      * When that controller is destroyed by calling destroy(), the current instance will be
56      * destroyed too. Can be null.
57      */
58     init: function(parent) {
59         openerp.web.PropertiesMixin.init.call(this);
60         this.setParent(parent);
61         this.session = openerp.session;
62     },
63 });
64
65 openerp.web.Widget.include(_.extend({}, ControllerMixin, {
66     init: function() {
67         this._super.apply(this, arguments);
68         this.session = openerp.session;
69     },
70 }));
71
72 instance.web.Registry = instance.web.Class.extend({
73     /**
74      * Stores a mapping of arbitrary key (strings) to object paths (as strings
75      * as well).
76      *
77      * Resolves those paths at query time in order to always fetch the correct
78      * object, even if those objects have been overloaded/replaced after the
79      * registry was created.
80      *
81      * An object path is simply a dotted name from the instance root to the
82      * object pointed to (e.g. ``"instance.web.Session"`` for an OpenERP
83      * session object).
84      *
85      * @constructs instance.web.Registry
86      * @param {Object} mapping a mapping of keys to object-paths
87      */
88     init: function (mapping) {
89         this.parent = null;
90         this.map = mapping || {};
91     },
92     /**
93      * Retrieves the object matching the provided key string.
94      *
95      * @param {String} key the key to fetch the object for
96      * @param {Boolean} [silent_error=false] returns undefined if the key or object is not found, rather than throwing an exception
97      * @returns {Class} the stored class, to initialize or null if not found
98      */
99     get_object: function (key, silent_error) {
100         var path_string = this.map[key];
101         if (path_string === undefined) {
102             if (this.parent) {
103                 return this.parent.get_object(key, silent_error);
104             }
105             if (silent_error) { return void 'nooo'; }
106             return null;
107         }
108
109         var object_match = instance;
110         var path = path_string.split('.');
111         // ignore first section
112         for(var i=1; i<path.length; ++i) {
113             object_match = object_match[path[i]];
114
115             if (object_match === undefined) {
116                 if (silent_error) { return void 'noooooo'; }
117                 return null;
118             }
119         }
120         return object_match;
121     },
122     /**
123      * Checks if the registry contains an object mapping for this key.
124      *
125      * @param {String} key key to look for
126      */
127     contains: function (key) {
128         if (key === undefined) { return false; }
129         if (key in this.map) {
130             return true;
131         }
132         if (this.parent) {
133             return this.parent.contains(key);
134         }
135         return false;
136     },
137     /**
138      * Tries a number of keys, and returns the first object matching one of
139      * the keys.
140      *
141      * @param {Array} keys a sequence of keys to fetch the object for
142      * @returns {Class} the first class found matching an object
143      */
144     get_any: function (keys) {
145         for (var i=0; i<keys.length; ++i) {
146             var key = keys[i];
147             if (!this.contains(key)) {
148                 continue;
149             }
150
151             return this.get_object(key);
152         }
153         return null;
154     },
155     /**
156      * Adds a new key and value to the registry.
157      *
158      * This method can be chained.
159      *
160      * @param {String} key
161      * @param {String} object_path fully qualified dotted object path
162      * @returns {instance.web.Registry} itself
163      */
164     add: function (key, object_path) {
165         this.map[key] = object_path;
166         return this;
167     },
168     /**
169      * Creates and returns a copy of the current mapping, with the provided
170      * mapping argument added in (replacing existing keys if needed)
171      *
172      * Parent and child remain linked, a new key in the parent (which is not
173      * overwritten by the child) will appear in the child.
174      *
175      * @param {Object} [mapping={}] a mapping of keys to object-paths
176      */
177     extend: function (mapping) {
178         var child = new instance.web.Registry(mapping);
179         child.parent = this;
180         return child;
181     },
182     /**
183      * @deprecated use Registry#extend
184      */
185     clone: function (mapping) {
186         console.warn('Registry#clone is deprecated, use Registry#extend');
187         return this.extend(mapping);
188     }
189 });
190
191 instance.web.py_eval = function(expr, context) {
192     return py.eval(expr, _.extend({}, context || {}, {"true": true, "false": false, "null": null}));
193 };
194
195 /*
196     Some retro-compatibility.
197 */
198 instance.web.JsonRPC = instance.web.Session;
199
200 /** Session openerp specific RPC class */
201 instance.web.Session.include( /** @lends instance.web.Session# */{
202     init: function() {
203         this._super.apply(this, arguments);
204         this.debug = ($.deparam($.param.querystring()).debug !== undefined);
205         // TODO: session store in cookie should be optional
206         this.name = instance._session_id;
207         this.qweb_mutex = new $.Mutex();
208     },
209     /**
210      * Setup a sessionm
211      */
212     session_bind: function(origin) {
213         var self = this;
214         this.setup(origin);
215         instance.web.qweb.default_dict['_s'] = this.origin;
216         this.uid = null;
217         this.username = null;
218         this.user_context= {};
219         this.db = null;
220         this.module_list = instance._modules.slice();
221         this.module_loaded = {};
222         _(this.module_list).each(function (mod) {
223             self.module_loaded[mod] = true;
224         });
225         this.active_id = null;
226         return this.session_init();
227     },
228     /**
229      * Init a session, reloads from cookie, if it exists
230      */
231     session_init: function () {
232         var self = this;
233         return this.session_reload().then(function(result) {
234             var modules = instance._modules.join(',');
235             var deferred = self.load_qweb(modules);
236             if(self.session_is_valid()) {
237                 return deferred.then(function() { return self.load_modules(); });
238             }
239             return $.when(
240                     deferred,
241                     self.rpc('/web/webclient/bootstrap_translations', {mods: instance._modules}).then(function(trans) {
242                         instance.web._t.database.set_bundle(trans);
243                     })
244             );
245         });
246     },
247     session_is_valid: function() {
248         var db = $.deparam.querystring().db;
249         if (db && this.db !== db) {
250             return false;
251         }
252         return !!this.uid;
253     },
254     /**
255      * The session is validated by restoration of a previous session
256      */
257     session_authenticate: function() {
258         var self = this;
259         return $.when(this._super.apply(this, arguments)).then(function() {
260             return self.load_modules();
261         });
262     },
263     session_logout: function() {
264         $.bbq.removeState();
265         return this.rpc("/web/session/destroy", {});
266     },
267     get_cookie: function (name) {
268         if (!this.name) { return null; }
269         var nameEQ = this.name + '|' + name + '=';
270         var cookies = document.cookie.split(';');
271         for(var i=0; i<cookies.length; ++i) {
272             var cookie = cookies[i].replace(/^\s*/, '');
273             if(cookie.indexOf(nameEQ) === 0) {
274                 try {
275                     return JSON.parse(decodeURIComponent(cookie.substring(nameEQ.length)));
276                 } catch(err) {
277                     // wrong cookie, delete it
278                     this.set_cookie(name, '', -1);
279                 }
280             }
281         }
282         return null;
283     },
284     /**
285      * Create a new cookie with the provided name and value
286      *
287      * @private
288      * @param name the cookie's name
289      * @param value the cookie's value
290      * @param ttl the cookie's time to live, 1 year by default, set to -1 to delete
291      */
292     set_cookie: function (name, value, ttl) {
293         if (!this.name) { return; }
294         ttl = ttl || 24*60*60*365;
295         document.cookie = [
296             this.name + '|' + name + '=' + encodeURIComponent(JSON.stringify(value)),
297             'path=/',
298             'max-age=' + ttl,
299             'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
300         ].join(';');
301     },
302     /**
303      * Load additional web addons of that instance and init them
304      *
305      */
306     load_modules: function() {
307         var self = this;
308         return this.rpc('/web/session/modules', {}).then(function(result) {
309             var all_modules = _.uniq(self.module_list.concat(result));
310             var to_load = _.difference(result, self.module_list).join(',');
311             self.module_list = all_modules;
312
313             var loaded = self.load_translations();
314             var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
315             var file_list = [ locale ];
316             if(to_load.length) {
317                 loaded = $.when(
318                     loaded,
319                     self.rpc('/web/webclient/csslist', {mods: to_load}).done(self.load_css.bind(self)),
320                     self.load_qweb(to_load),
321                     self.rpc('/web/webclient/jslist', {mods: to_load}).done(function(files) {
322                         file_list = file_list.concat(files);
323                     })
324                 );
325             }
326             return loaded.then(function () {
327                 return self.load_js(file_list);
328             }).done(function() {
329                 self.on_modules_loaded();
330                 self.trigger('module_loaded');
331                 if (!Date.CultureInfo.pmDesignator) {
332                     // If no am/pm designator is specified but the openerp
333                     // datetime format uses %i, date.js won't be able to
334                     // correctly format a date. See bug#938497.
335                     Date.CultureInfo.amDesignator = 'AM';
336                     Date.CultureInfo.pmDesignator = 'PM';
337                 }
338             });
339         });
340     },
341     load_translations: function() {
342         return instance.web._t.database.load_translations(this, this.module_list, this.user_context.lang);
343     },
344     load_css: function (files) {
345         var self = this;
346         _.each(files, function (file) {
347             openerp.loadCSS(self.url(file, null));
348         });
349     },
350     load_js: function(files) {
351         var self = this;
352         var d = $.Deferred();
353         if (files.length !== 0) {
354             var file = files.shift();
355             var url = self.url(file, null);
356             openerp.loadJS(url).done(d.resolve);
357         } else {
358             d.resolve();
359         }
360         return d;
361     },
362     load_qweb: function(mods) {
363         var self = this;
364         self.qweb_mutex.exec(function() {
365             return self.rpc('/web/proxy/load', {path: '/web/webclient/qweb?mods=' + mods}).then(function(xml) {
366                 if (!xml) { return; }
367                 instance.web.qweb.add_template(_.str.trim(xml));
368             });
369         });
370         return self.qweb_mutex.def;
371     },
372     on_modules_loaded: function() {
373         for(var j=0; j<this.module_list.length; j++) {
374             var mod = this.module_list[j];
375             if(this.module_loaded[mod])
376                 continue;
377             instance[mod] = {};
378             // init module mod
379             var fct = instance._openerp[mod];
380             if(typeof(fct) === "function") {
381                 instance._openerp[mod] = {};
382                 for (var k in fct) {
383                     instance._openerp[mod][k] = fct[k];
384                 }
385                 fct(instance, instance._openerp[mod]);
386             }
387             this.module_loaded[mod] = true;
388         }
389     },
390     /**
391      * Cooperative file download implementation, for ajaxy APIs.
392      *
393      * Requires that the server side implements an httprequest correctly
394      * setting the `fileToken` cookie to the value provided as the `token`
395      * parameter. The cookie *must* be set on the `/` path and *must not* be
396      * `httpOnly`.
397      *
398      * It would probably also be a good idea for the response to use a
399      * `Content-Disposition: attachment` header, especially if the MIME is a
400      * "known" type (e.g. text/plain, or for some browsers application/json
401      *
402      * @param {Object} options
403      * @param {String} [options.url] used to dynamically create a form
404      * @param {Object} [options.data] data to add to the form submission. If can be used without a form, in which case a form is created from scratch. Otherwise, added to form data
405      * @param {HTMLFormElement} [options.form] the form to submit in order to fetch the file
406      * @param {Function} [options.success] callback in case of download success
407      * @param {Function} [options.error] callback in case of request error, provided with the error body
408      * @param {Function} [options.complete] called after both ``success`` and ``error` callbacks have executed
409      */
410     get_file: function (options) {
411         // need to detect when the file is done downloading (not used
412         // yet, but we'll need it to fix the UI e.g. with a throbber
413         // while dump is being generated), iframe load event only fires
414         // when the iframe content loads, so we need to go smarter:
415         // http://geekswithblogs.net/GruffCode/archive/2010/10/28/detecting-the-file-download-dialog-in-the-browser.aspx
416         var timer, token = new Date().getTime(),
417             cookie_name = 'fileToken', cookie_length = cookie_name.length,
418             CHECK_INTERVAL = 1000, id = _.uniqueId('get_file_frame'),
419             remove_form = false;
420
421
422         // iOS devices doesn't allow iframe use the way we do it,
423         // opening a new window seems the best way to workaround
424         if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
425             var params = _.extend({}, options.data || {}, {token: token});
426             var url = this.url(options.url, params);
427             instance.web.unblockUI();
428             return window.open(url);
429         }
430
431         var $form, $form_data = $('<div>');
432
433         var complete = function () {
434             if (options.complete) { options.complete(); }
435             clearTimeout(timer);
436             $form_data.remove();
437             $target.remove();
438             if (remove_form && $form) { $form.remove(); }
439         };
440         var $target = $('<iframe style="display: none;">')
441             .attr({id: id, name: id})
442             .appendTo(document.body)
443             .load(function () {
444                 try {
445                     if (options.error) {
446                         var body = this.contentDocument.body;
447                         var node = body.childNodes[1] || body.childNodes[0];
448                         options.error(JSON.parse(node.textContent));
449                     }
450                 } finally {
451                     complete();
452                 }
453             });
454
455         if (options.form) {
456             $form = $(options.form);
457         } else {
458             remove_form = true;
459             $form = $('<form>', {
460                 action: options.url,
461                 method: 'POST'
462             }).appendTo(document.body);
463         }
464
465         var hparams = _.extend({}, options.data || {}, {token: token});
466         if (this.override_session)
467             hparams.session_id = this.session_id;
468         _.each(hparams, function (value, key) {
469                 var $input = $form.find('[name=' + key +']');
470                 if (!$input.length) {
471                     $input = $('<input type="hidden" name="' + key + '">')
472                         .appendTo($form_data);
473                 }
474                 $input.val(value);
475             });
476
477         $form
478             .append($form_data)
479             .attr('target', id)
480             .get(0).submit();
481
482         var waitLoop = function () {
483             var cookies = document.cookie.split(';');
484             // setup next check
485             timer = setTimeout(waitLoop, CHECK_INTERVAL);
486             for (var i=0; i<cookies.length; ++i) {
487                 var cookie = cookies[i].replace(/^\s*/, '');
488                 if (!cookie.indexOf(cookie_name === 0)) { continue; }
489                 var cookie_val = cookie.substring(cookie_length + 1);
490                 if (parseInt(cookie_val, 10) !== token) { continue; }
491
492                 // clear cookie
493                 document.cookie = _.str.sprintf("%s=;expires=%s;path=/",
494                     cookie_name, new Date().toGMTString());
495                 if (options.success) { options.success(); }
496                 complete();
497                 return;
498             }
499         };
500         timer = setTimeout(waitLoop, CHECK_INTERVAL);
501     },
502     synchronized_mode: function(to_execute) {
503         var synch = this.synch;
504         this.synch = true;
505         try {
506             return to_execute();
507         } finally {
508             this.synch = synch;
509         }
510     }
511 });
512
513
514 /**
515  * Event Bus used to bind events scoped in the current instance
516  */
517 instance.web.Bus = instance.web.Class.extend(instance.web.EventDispatcherMixin, {
518     init: function() {
519         instance.web.EventDispatcherMixin.init.call(this, parent);
520         var self = this;
521         // TODO fme: allow user to bind keys for some global actions.
522         //           check gtk bindings
523         // http://unixpapa.com/js/key.html
524         _.each('click,dblclick,keydown,keypress,keyup'.split(','), function(evtype) {
525             $('html').on(evtype, function(ev) {
526                 self.trigger(evtype, ev);
527             });
528         });
529         _.each('resize,scroll'.split(','), function(evtype) {
530             $(window).on(evtype, function(ev) {
531                 self.trigger(evtype, ev);
532             });
533         });
534     }
535 });
536 instance.web.bus = new instance.web.Bus();
537
538 instance.web.TranslationDataBase.include({
539     set_bundle: function(translation_bundle) {
540         this._super(translation_bundle);
541         if (translation_bundle.lang_parameters) {
542             this.parameters.grouping = py.eval(this.parameters.grouping);
543         }
544     },
545 });
546
547 /** Custom jQuery plugins */
548 $.browser = $.browser || {};
549 if(navigator.appVersion.indexOf("MSIE") !== -1) {
550     $.browser.msie = 1;
551 }
552 $.fn.getAttributes = function() {
553     var o = {};
554     if (this.length) {
555         for (var attr, i = 0, attrs = this[0].attributes, l = attrs.length; i < l; i++) {
556             attr = attrs.item(i);
557             o[attr.nodeName] = attr.value;
558         }
559     }
560     return o;
561 };
562 $.fn.openerpClass = function(additionalClass) {
563     // This plugin should be applied on top level elements
564     additionalClass = additionalClass || '';
565     if (!!$.browser.msie) {
566         additionalClass += ' openerp_ie';
567     }
568     return this.each(function() {
569         $(this).addClass('openerp ' + additionalClass);
570     });
571 };
572 $.fn.openerpBounce = function() {
573     return this.each(function() {
574         $(this).css('box-sizing', 'content-box').effect('bounce', {distance: 18, times: 5}, 250);
575     });
576 };
577
578 /** Jquery extentions */
579 $.Mutex = openerp.Mutex;
580
581 $.async_when = function() {
582     var async = false;
583     var def = $.Deferred();
584     $.when.apply($, arguments).done(function() {
585         var args = arguments;
586         var action = function() {
587             def.resolve.apply(def, args);
588         };
589         if (async)
590             action();
591         else
592             setTimeout(action, 0);
593     }).fail(function() {
594         var args = arguments;
595         var action = function() {
596             def.reject.apply(def, args);
597         };
598         if (async)
599             action();
600         else
601             setTimeout(action, 0);
602     });
603     async = true;
604     return def;
605 };
606
607 // special tweak for the web client
608 var old_async_when = $.async_when;
609 $.async_when = function() {
610     if (instance.session.synch)
611         return $.when.apply(this, arguments);
612     else
613         return old_async_when.apply(this, arguments);
614 };
615
616 /** Setup default session */
617 instance.session = new instance.web.Session();
618
619 /**
620  * Lazy translation function, only performs the translation when actually
621  * printed (e.g. inserted into a template)
622  *
623  * Useful when defining translatable strings in code evaluated before the
624  * translation database is loaded, as class attributes or at the top-level of
625  * an OpenERP Web module
626  *
627  * @param {String} s string to translate
628  * @returns {Object} lazy translation object
629  */
630 instance.web._lt = function (s) {
631     return {toString: function () { return instance.web._t(s); }};
632 };
633 instance.web.qweb.debug = instance.session.debug;
634 _.extend(instance.web.qweb.default_dict, {
635     '__debug__': instance.session.debug,
636 });
637 instance.web.qweb.preprocess_node = function() {
638     // Note that 'this' is the Qweb Node
639     switch (this.node.nodeType) {
640         case Node.TEXT_NODE:
641         case Node.CDATA_SECTION_NODE:
642             // Text and CDATAs
643             var translation = this.node.parentNode.attributes['t-translation'];
644             if (translation && translation.value === 'off') {
645                 return;
646             }
647             var match = /^(\s*)([\s\S]+?)(\s*)$/.exec(this.node.data);
648             if (match) {
649                 this.node.data = match[1] + instance.web._t(match[2]) + match[3];
650             }
651             break;
652         case Node.ELEMENT_NODE:
653             // Element
654             var attr, attrs = ['label', 'title', 'alt', 'placeholder'];
655             while ((attr = attrs.pop())) {
656                 if (this.attributes[attr]) {
657                     this.attributes[attr] = instance.web._t(this.attributes[attr]);
658                 }
659             }
660     }
661 };
662
663 /** Setup jQuery timeago */
664 var _t = instance.web._t;
665 /*
666  * Strings in timeago are "composed" with prefixes, words and suffixes. This
667  * makes their detection by our translating system impossible. Use all literal
668  * strings we're using with a translation mark here so the extractor can do its
669  * job.
670  */
671 {
672     _t('less than a minute ago');
673     _t('about a minute ago');
674     _t('%d minutes ago');
675     _t('about an hour ago');
676     _t('%d hours ago');
677     _t('a day ago');
678     _t('%d days ago');
679     _t('about a month ago');
680     _t('%d months ago');
681     _t('about a year ago');
682     _t('%d years ago');
683 }
684
685 instance.session.on('module_loaded', this, function () {
686     // provide timeago.js with our own translator method
687     $.timeago.settings.translator = instance.web._t;
688 });
689
690 /** Setup blockui */
691 if ($.blockUI) {
692     $.blockUI.defaults.baseZ = 1100;
693     $.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
694     $.blockUI.defaults.css.border = '0';
695     $.blockUI.defaults.css["background-color"] = '';
696 }
697
698 var messages_by_seconds = function() {
699     return [
700         [0, _t("Loading...")],
701         [20, _t("Still loading...")],
702         [60, _t("Still loading...<br />Please be patient.")],
703         [120, _t("Don't leave yet,<br />it's still loading...")],
704         [300, _t("You may not believe it,<br />but the application is actually loading...")],
705         [420, _t("Take a minute to get a coffee,<br />because it's loading...")],
706         [3600, _t("Maybe you should consider reloading the application by pressing F5...")]
707     ];
708 };
709
710 instance.web.Throbber = instance.web.Widget.extend({
711     template: "Throbber",
712     start: function() {
713         var opts = {
714           lines: 13, // The number of lines to draw
715           length: 7, // The length of each line
716           width: 4, // The line thickness
717           radius: 10, // The radius of the inner circle
718           rotate: 0, // The rotation offset
719           color: '#FFF', // #rgb or #rrggbb
720           speed: 1, // Rounds per second
721           trail: 60, // Afterglow percentage
722           shadow: false, // Whether to render a shadow
723           hwaccel: false, // Whether to use hardware acceleration
724           className: 'spinner', // The CSS class to assign to the spinner
725           zIndex: 2e9, // The z-index (defaults to 2000000000)
726           top: 'auto', // Top position relative to parent in px
727           left: 'auto' // Left position relative to parent in px
728         };
729         this.spin = new Spinner(opts).spin(this.$el[0]);
730         this.start_time = new Date().getTime();
731         this.act_message();
732     },
733     act_message: function() {
734         var self = this;
735         setTimeout(function() {
736             if (self.isDestroyed())
737                 return;
738             var seconds = (new Date().getTime() - self.start_time) / 1000;
739             var mes;
740             _.each(messages_by_seconds(), function(el) {
741                 if (seconds >= el[0])
742                     mes = el[1];
743             });
744             self.$(".oe_throbber_message").html(mes);
745             self.act_message();
746         }, 1000);
747     },
748     destroy: function() {
749         if (this.spin)
750             this.spin.stop();
751         this._super();
752     },
753 });
754 instance.web.Throbber.throbbers = [];
755
756 instance.web.blockUI = function() {
757     var tmp = $.blockUI.apply($, arguments);
758     var throbber = new instance.web.Throbber();
759     instance.web.Throbber.throbbers.push(throbber);
760     throbber.appendTo($(".oe_blockui_spin_container"));
761     return tmp;
762 };
763 instance.web.unblockUI = function() {
764     _.each(instance.web.Throbber.throbbers, function(el) {
765         el.destroy();
766     });
767     return $.unblockUI.apply($, arguments);
768 };
769
770
771 /* Bootstrap defaults overwrite */
772 $.fn.tooltip.Constructor.DEFAULTS.placement = 'auto top';
773 $.fn.tooltip.Constructor.DEFAULTS.html = true;
774 $.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover focus click';
775 $.fn.tooltip.Constructor.DEFAULTS.container = 'body';
776 //overwrite bootstrap tooltip method to prevent showing 2 tooltip at the same time
777 var bootstrap_show_function = $.fn.tooltip.Constructor.prototype.show;
778 $.fn.modal.Constructor.prototype.enforceFocus = function () { };
779 $.fn.tooltip.Constructor.prototype.show = function () {
780     $('.tooltip').remove();
781     //the following fix the bug when using placement
782     //auto and the parent element does not exist anymore resulting in
783     //an error. This should be remove once we updade bootstrap to a version that fix the bug
784     //edit: bug has been fixed here : https://github.com/twbs/bootstrap/pull/13752
785     var e = $.Event('show.bs.' + this.type);
786     var inDom = $.contains(document.documentElement, this.$element[0]);
787     if (e.isDefaultPrevented() || !inDom) return;
788     return bootstrap_show_function.call(this);
789 };
790
791 /**
792  * Registry for all the client actions key: tag value: widget
793  */
794 instance.web.client_actions = new instance.web.Registry();
795
796 })();
797
798 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: