diff options
| author | Géry Debongnie <ged@odoo.com> | 2017-03-05 10:05:56 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-11 19:44:38 +0200 |
| commit | 905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch) | |
| tree | 265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/core | |
| parent | 54d4c73af4ad05c7640d28c1892247c9538b8017 (diff) | |
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started
basically from scratch. The goal was to unify all the various views
under a common framework, to make them testable, to make then usable in
different conditions (in studio, or in the frontend), and to make our
lives easier.
Some important points are:
- we introduced new coding guidelines (camelCase, 80 chars width, ...)
- we have a brand new testing framework (still QUnit based)
- kanban view moved to the web addon
- calendar view (formerly web_calender) moved to web as well
- the tree view was removed
- all new code should be documented
We hope that this code is the start of a new era for the Odoo web
client, we want to have a high quality codebase, well documented, well
tested, well designed.
Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/core')
20 files changed, 5000 insertions, 0 deletions
diff --git a/addons/web/static/src/js/core/abstract_service.js b/addons/web/static/src/js/core/abstract_service.js new file mode 100644 index 00000000000..6ae0c3183da --- /dev/null +++ b/addons/web/static/src/js/core/abstract_service.js @@ -0,0 +1,20 @@ +odoo.define('web.AbstractService', function (require) { +"use strict"; + +var Class = require('web.Class'); + +var AbstractService = Class.extend({ + Services: [], + name: null, +}); + +var realExtend = AbstractService.extend; + +AbstractService.extend = function() { + var Service = realExtend.apply(this, arguments); + AbstractService.prototype.Services.push(Service); + return Service; +}; + +return AbstractService; +}); diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js new file mode 100644 index 00000000000..dee6f4599b4 --- /dev/null +++ b/addons/web/static/src/js/core/ajax.js @@ -0,0 +1,399 @@ +odoo.define('web.ajax', function (require) { +"use strict"; + +var core = require('web.core'); +var utils = require('web.utils'); +var time = require('web.time'); + +function genericJsonRpc (fct_name, params, fct) { + var data = { + jsonrpc: "2.0", + method: fct_name, + params: params, + id: Math.floor(Math.random() * 1000 * 1000 * 1000) + }; + var xhr = fct(data); + var result = xhr.pipe(function(result) { + core.bus.trigger('rpc:result', data, result); + if (result.error !== undefined) { + if (result.error.data.arguments[0] !== "bus.Bus not available in test mode") { + console.error("Server application error", JSON.stringify(result.error)); + } + return $.Deferred().reject("server", result.error); + } else { + return result.result; + } + }, function() { + //console.error("JsonRPC communication error", _.toArray(arguments)); + var def = $.Deferred(); + return def.reject.apply(def, ["communication"].concat(_.toArray(arguments))); + }); + // FIXME: jsonp? + result.abort = function () { if (xhr.abort) xhr.abort(); }; + return result; +} + +function jsonRpc(url, fct_name, params, settings) { + return genericJsonRpc(fct_name, params, function(data) { + return $.ajax(url, _.extend({}, settings, { + url: url, + dataType: 'json', + type: 'POST', + data: JSON.stringify(data, time.date_to_utc), + contentType: 'application/json' + })); + }); +} + +function jsonpRpc(url, fct_name, params, settings) { + settings = settings || {}; + return genericJsonRpc(fct_name, params, function(data) { + var payload_str = JSON.stringify(data, time.date_to_utc); + var payload_url = $.param({r:payload_str}); + var force2step = settings.force2step || false; + delete settings.force2step; + var session_id = settings.session_id || null; + delete settings.session_id; + if (payload_url.length < 2000 && ! force2step) { + return $.ajax(url, _.extend({}, settings, { + url: url, + dataType: 'jsonp', + jsonp: 'jsonp', + type: 'GET', + cache: false, + data: {r: payload_str, session_id: session_id} + })); + } else { + var args = {session_id: session_id, id: data.id}; + var ifid = _.uniqueId('oe_rpc_iframe'); + var html = "<iframe src='javascript:false;' name='" + ifid + "' id='" + ifid + "' style='display:none'></iframe>"; + var $iframe = $(html); + var nurl = 'jsonp=1&' + $.param(args); + nurl = url.indexOf("?") !== -1 ? url + "&" + nurl : url + "?" + nurl; + var $form = $('<form>') + .attr('method', 'POST') + .attr('target', ifid) + .attr('enctype', "multipart/form-data") + .attr('action', nurl) + .append($('<input type="hidden" name="r" />').attr('value', payload_str)) + .hide() + .appendTo($('body')); + var cleanUp = function() { + if ($iframe) { + $iframe.unbind("load").remove(); + } + $form.remove(); + }; + var deferred = $.Deferred(); + // the first bind is fired up when the iframe is added to the DOM + $iframe.bind('load', function() { + // the second bind is fired up when the result of the form submission is received + $iframe.unbind('load').bind('load', function() { + $.ajax({ + url: url, + dataType: 'jsonp', + jsonp: 'jsonp', + type: 'GET', + cache: false, + data: {session_id: session_id, id: data.id} + }).always(function() { + cleanUp(); + }).done(function() { + deferred.resolve.apply(deferred, arguments); + }).fail(function() { + deferred.reject.apply(deferred, arguments); + }); + }); + // now that the iframe can receive data, we fill and submit the form + $form.submit(); + }); + // append the iframe to the DOM (will trigger the first load) + $form.after($iframe); + if (settings.timeout) { + realSetTimeout(function() { + deferred.reject({}); + }, settings.timeout); + } + return deferred; + } + }); +} + +// helper function to make a rpc with a function name hardcoded to 'call' +function rpc(url, params, settings) { + return jsonRpc(url, 'call', params, settings); +} + +// helper +function realSetTimeout (fct, millis) { + var finished = new Date().getTime() + millis; + var wait = function() { + var current = new Date().getTime(); + if (current < finished) { + setTimeout(wait, finished - current); + } else { + fct(); + } + }; + setTimeout(wait, millis); +} + +function loadCSS(url) { + if (!$('link[href="' + url + '"]').length) { + $('head').append($('<link>', { + 'href': url, + 'rel': 'stylesheet', + 'type': 'text/css' + })); + } +} + +var loadJS = (function () { + var urls = []; + var defs = []; + + var load = function loadJS(url) { + // Check the DOM to see if a script with the specified url is already there + var alreadyRequired = ($('script[src="' + url + '"]').length > 0); + + // If loadJS was already called with the same URL, it will have a registered deferred indicating if + // the script has been fully loaded. If not, the deferred has to be initialized. This is initialized + // as already resolved if the script was already there without the need of loadJS. + var index = _.indexOf(urls, url); + if (index < 0) { + urls.push(url); + index = defs.push(alreadyRequired ? $.when() : $.Deferred()) - 1; + } + + // Get the script associated deferred and returns it after initializing the script if needed. The + // deferred is marked to be resolved on script load and rejected on script error. + var def = defs[index]; + if (!alreadyRequired) { + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = url; + script.onload = script.onreadystatechange = function() { + if ((script.readyState && script.readyState !== "loaded" && script.readyState !== "complete") || script.onload_done) { + return; + } + script.onload_done = true; + def.resolve(url); + }; + script.onerror = function () { + console.error("Error loading file", script.src); + def.reject(url); + }; + var head = document.head || document.getElementsByTagName('head')[0]; + head.appendChild(script); + } + return def; + }; + + return load; +})(); + + +/** + * Cooperative file download implementation, for ajaxy APIs. + * + * Requires that the server side implements an httprequest correctly + * setting the `fileToken` cookie to the value provided as the `token` + * parameter. The cookie *must* be set on the `/` path and *must not* be + * `httpOnly`. + * + * It would probably also be a good idea for the response to use a + * `Content-Disposition: attachment` header, especially if the MIME is a + * "known" type (e.g. text/plain, or for some browsers application/json + * + * @param {Object} options + * @param {String} [options.url] used to dynamically create a form + * @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 + * @param {HTMLFormElement} [options.form] the form to submit in order to fetch the file + * @param {Function} [options.success] callback in case of download success + * @param {Function} [options.error] callback in case of request error, provided with the error body + * @param {Function} [options.complete] called after both ``success`` and ``error` callbacks have executed + */ +function get_file(options) { + // need to detect when the file is done downloading (not used + // yet, but we'll need it to fix the UI e.g. with a throbber + // while dump is being generated), iframe load event only fires + // when the iframe content loads, so we need to go smarter: + // http://geekswithblogs.net/GruffCode/archive/2010/10/28/detecting-the-file-download-dialog-in-the-browser.aspx + var timer, token = new Date().getTime(), + cookie_name = 'fileToken', cookie_length = cookie_name.length, + CHECK_INTERVAL = 1000, id = _.uniqueId('get_file_frame'), + remove_form = false; + + + // iOS devices doesn't allow iframe use the way we do it, + // opening a new window seems the best way to workaround + if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) { + var params = _.extend({}, options.data || {}, {token: token}); + var url = options.session.url(options.url, params); + if (options.complete) { options.complete(); } + + return window.open(url); + } + + var $form, $form_data = $('<div>'); + + var complete = function () { + if (options.complete) { options.complete(); } + clearTimeout(timer); + $form_data.remove(); + $target.remove(); + if (remove_form && $form) { $form.remove(); } + }; + var $target = $('<iframe style="display: none;">') + .attr({id: id, name: id}) + .appendTo(document.body) + .load(function () { + try { + if (options.error) { + var body = this.contentDocument.body; + var nodes = body.children.length === 0 ? body.childNodes : body.children; + var node = nodes[1] || nodes[0]; + options.error(JSON.parse(node.textContent)); + } + } finally { + complete(); + } + }); + + if (options.form) { + $form = $(options.form); + } else { + remove_form = true; + $form = $('<form>', { + action: options.url, + method: 'POST' + }).appendTo(document.body); + } + if (core.csrf_token) { + $('<input type="hidden" name="csrf_token">') + .val(core.csrf_token) + .appendTo($form_data); + } + + var hparams = _.extend({}, options.data || {}, {token: token}); + _.each(hparams, function (value, key) { + var $input = $form.find('[name=' + key +']'); + if (!$input.length) { + $input = $('<input type="hidden" name="' + key + '">') + .appendTo($form_data); + } + $input.val(value); + }); + + $form + .append($form_data) + .attr('target', id) + .get(0).submit(); + + var waitLoop = function () { + var cookies = document.cookie.split(';'); + // setup next check + timer = setTimeout(waitLoop, CHECK_INTERVAL); + for (var i=0; i<cookies.length; ++i) { + var cookie = cookies[i].replace(/^\s*/, ''); + if (!cookie.indexOf(cookie_name === 0)) { continue; } + var cookie_val = cookie.substring(cookie_length + 1); + if (parseInt(cookie_val, 10) !== token) { continue; } + + // clear cookie + document.cookie = _.str.sprintf("%s=;expires=%s;path=/", + cookie_name, new Date().toGMTString()); + if (options.success) { options.success(); } + complete(); + return; + } + }; + timer = setTimeout(waitLoop, CHECK_INTERVAL); +}; + +function post (controller_url, data) { + + var progressHandler = function (deferred) { + return function (state) { + if(state.lengthComputable) { + deferred.notify({ + h_loaded: utils.human_size(state.loaded), + h_total : utils.human_size(state.total), + loaded : state.loaded, + total : state.total, + pcent : Math.round((state.loaded/state.total)*100) + }); + } + }; + }; + + var Def = $.Deferred(); + var postData = new FormData(); + + $.each(data, function(i,val) { + postData.append(i, val); + }); + if (core.csrf_token) { + postData.append('csrf_token', core.csrf_token); + } + + var xhr = new XMLHttpRequest(); + if(xhr.upload) xhr.upload.addEventListener('progress', progressHandler(Def), false); + + var ajaxDef = $.ajax(controller_url, { + xhr: function() {return xhr;}, + data: postData, + processData: false, + contentType: false, + type: 'POST' + }).then(function (data) {Def.resolve(data);}) + .fail(function (data) {Def.reject(data);}); + + return Def; +} + +var loadXML = (function () { + var loading = false; + var urls = []; + var qwebs = []; + var templates_def = $.Deferred(); + + var load = function loadXML(url, qweb) { + if (url) { + urls.push(url); + qwebs.push(qweb); + } + + if (!loading && urls.length) { + if (templates_def.state() === "resolved") { + templates_def = $.Deferred(); + } + + loading = true; + qwebs.shift().add_template(urls.shift(), function () { + loading = false; + if (!urls.length) { + templates_def.resolve(); + } + load(null); + }); + } + + return templates_def; + }; + + return load; +})(); + +return { + jsonRpc: jsonRpc, + jsonpRpc: jsonpRpc, + rpc: rpc, + loadCSS: loadCSS, + loadJS: loadJS, + loadXML: loadXML, + get_file: get_file, + post: post, +}; + +}); diff --git a/addons/web/static/src/js/core/bus.js b/addons/web/static/src/js/core/bus.js new file mode 100644 index 00000000000..bf33c1fe01e --- /dev/null +++ b/addons/web/static/src/js/core/bus.js @@ -0,0 +1,16 @@ +odoo.define('web.Bus', function (require) { +"use strict"; + +var Class = require('web.Class'); +var mixins = require('web.mixins'); + +/** + * Event Bus used to bind events scoped in the current instance + */ +return Class.extend(mixins.EventDispatcherMixin, { + init: function() { + mixins.EventDispatcherMixin.init.call(this, parent); + }, +}); + +}); diff --git a/addons/web/static/src/js/core/class.js b/addons/web/static/src/js/core/class.js new file mode 100644 index 00000000000..8e53f0ba06d --- /dev/null +++ b/addons/web/static/src/js/core/class.js @@ -0,0 +1,154 @@ +odoo.define('web.Class', function () { +"use strict"; +/** + * Improved John Resig's inheritance, based on: + * + * Simple JavaScript Inheritance + * By John Resig http://ejohn.org/ + * MIT Licensed. + * + * Adds "include()" + * + * Defines The Class object. That object can be used to define and inherit classes using + * the extend() method. + * + * Example: + * + * var Person = Class.extend({ + * init: function(isDancing){ + * this.dancing = isDancing; + * }, + * dance: function(){ + * return this.dancing; + * } + * }); + * + * The init() method act as a constructor. This class can be instancied this way: + * + * var person = new Person(true); + * person.dance(); + * + * The Person class can also be extended again: + * + * var Ninja = Person.extend({ + * init: function(){ + * this._super( false ); + * }, + * dance: function(){ + * // Call the inherited version of dance() + * return this._super(); + * }, + * swingSword: function(){ + * return true; + * } + * }); + * + * When extending a class, each re-defined method can use this._super() to call the previous + * implementation of that method. + */ +var initializing = false; +var fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/; + +// The web Class implementation (does nothing) +function OdooClass(){}; + +/** + * Subclass an existing class + * + * @param {Object} prop class-level properties (class attributes and instance methods) to set on the new class + */ +OdooClass.extend = function() { + var _super = this.prototype; + // Support mixins arguments + var args = _.toArray(arguments); + args.unshift({}); + var prop = _.extend.apply(_,args); + + // Instantiate a web class (but only create the instance, + // don't run the init constructor) + initializing = true; + var This = this; + var prototype = new This(); + initializing = false; + + // Copy the properties over onto the new prototype + _.each(prop, function(val, name) { + // Check if we're overwriting an existing function + prototype[name] = typeof prop[name] == "function" && + fnTest.test(prop[name]) ? + (function(name, fn) { + return function() { + var tmp = this._super; + + // Add a new ._super() method that is the same + // method but on the super-class + this._super = _super[name]; + + // The method only need to be bound temporarily, so + // we remove it when we're done executing + var ret = fn.apply(this, arguments); + this._super = tmp; + + return ret; + }; + })(name, prop[name]) : + prop[name]; + }); + + // The dummy class constructor + function Class() { + if(this.constructor !== OdooClass){ + throw new Error("You can only instanciate objects with the 'new' operator"); + } + // All construction is actually done in the init method + this._super = null; + if (!initializing && this.init) { + var ret = this.init.apply(this, arguments); + if (ret) { return ret; } + } + return this; + } + Class.include = function (properties) { + _.each(properties, function(val, name) { + if (typeof properties[name] !== 'function' + || !fnTest.test(properties[name])) { + prototype[name] = properties[name]; + } else if (typeof prototype[name] === 'function' + && prototype.hasOwnProperty(name)) { + prototype[name] = (function (name, fn, previous) { + return function () { + var tmp = this._super; + this._super = previous; + var ret = fn.apply(this, arguments); + this._super = tmp; + return ret; + }; + })(name, properties[name], prototype[name]); + } else if (typeof _super[name] === 'function') { + prototype[name] = (function (name, fn) { + return function () { + var tmp = this._super; + this._super = _super[name]; + var ret = fn.apply(this, arguments); + this._super = tmp; + return ret; + }; + })(name, properties[name]); + } + }); + }; + + // Populate our constructed prototype object + Class.prototype = prototype; + + // Enforce the constructor to be what we expect + Class.constructor = Class; + + // And make this class extendable + Class.extend = this.extend; + + return Class; +}; + +return OdooClass; +}); diff --git a/addons/web/static/src/js/core/collections.js b/addons/web/static/src/js/core/collections.js new file mode 100644 index 00000000000..bc27a9021cc --- /dev/null +++ b/addons/web/static/src/js/core/collections.js @@ -0,0 +1,44 @@ +odoo.define("web.collections", function (require) { + "use strict"; + + var Class = require("web.Class"); + + /** + * Allows to build a tree representation of a data. + */ + var Tree = Class.extend({ + /** + * @constructor + * @param {*} data - the data associated to the root node + */ + init: function (data) { + this._data = data; + this._children = []; + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns the root's associated data. + * + * @returns {*} + */ + getData: function () { + return this._data; + }, + /** + * Adds a child tree. + * + * @param {Tree} tree + */ + addChild: function (tree) { + this._children.push(tree); + }, + }); + + return { + Tree: Tree, + }; +}); diff --git a/addons/web/static/src/js/core/concurrency.js b/addons/web/static/src/js/core/concurrency.js new file mode 100644 index 00000000000..cab9df6529e --- /dev/null +++ b/addons/web/static/src/js/core/concurrency.js @@ -0,0 +1,237 @@ +odoo.define('web.concurrency', function (require) { +"use strict"; + +/** + * Concurrency Utils + * + * This file contains a short collection of useful helpers designed to help with + * everything concurrency related in Odoo. + * + * The basic concurrency primitives in Odoo JS are the callback, and the + * promises. Promises (deferred) are more composable, so we usually use them + * whenever possible. We use the jQuery implementation. + * + * Those functions are really nothing special, but are simply the result of how + * we solved some concurrency issues, when we noticed that a pattern emerged. + */ + +var Class = require('web.Class'); + +return { + /** + * The jquery implementation for $.when has a (most of the time) useful + * property: it is synchronous, if the deferred is resolved immediately. + * + * This means that when we execute $.when(def), then all registered + * callbacks will be executed before the next line is executed. This is + * useful quite often, but in some rare cases, we might want to force an + * async behavior. This is the purpose of this function, which simply adds a + * setTimeout before resolving the deferred. + * + * @returns {Deferred} + */ + asyncWhen: function () { + var async = false; + var def = $.Deferred(); + $.when.apply($, arguments).done(function() { + var args = arguments; + var action = function() { + def.resolve.apply(def, args); + }; + if (async) { + action(); + } else { + setTimeout(action, 0); + } + }).fail(function() { + var args = arguments; + var action = function() { + def.reject.apply(def, args); + }; + if (async) { + action(); + } else { + setTimeout(action, 0); + } + }); + async = true; + return def; + }, + /** + * Returns a deferred resolved after 'wait' milliseconds + * + * @param {int} [wait=0] the delay in ms + * @return {Deferred} + */ + delay: function (wait) { + var def = $.Deferred(); + setTimeout(def.resolve, wait); + return def; + }, + /** + * The DropMisordered abstraction is useful for situations where you have + * a sequence of operations that you want to do, but if one of them + * completes after a subsequent operation, then its result is obsolete and + * should be ignored. + * + * Note that is is kind of similar to the DropPrevious abstraction, but + * subtly different. The DropMisordered operations will all resolves if + * they complete in the correct order. + */ + DropMisordered: Class.extend({ + /** + * @constructor + * + * @param {boolean} [failMisordered=false] whether mis-ordered responses + * should be failed or just ignored + */ + init: function (failMisordered) { + // local sequence number, for requests sent + this.lsn = 0; + // remote sequence number, seqnum of last received request + this.rsn = -1; + this.failMisordered = failMisordered || false; + }, + /** + * Adds a deferred (usually an async request) to the sequencer + * + * @param {Deferred} deferred to ensure add + * @returns {Deferred} + */ + add: function (deferred) { + var res = $.Deferred(); + + var self = this, seq = this.lsn++; + deferred.done(function () { + if (seq > self.rsn) { + self.rsn = seq; + res.resolve.apply(res, arguments); + } else if (self.failMisordered) { + res.reject(); + } + }).fail(function () { + res.reject.apply(res, arguments); + }); + + return res.promise(); + }, + }), + /** + * The DropPrevious abstraction is useful when you have a sequence of + * operations that you want to execute, but you only care of the result of + * the last operation. + * + * For example, let us say that we have a _fetch method on a widget which + * fetches data. We want to rerender the widget after. We could do this: + * + * this._fetch().then(function (result) { + * self.state = result; + * self.render(); + * }); + * + * Now, we have at least two problems: + * - if this code is called twice and the second _fetch completes before the + * first, the end state will be the result of the first _fetch, which is + * not what we expect + * - in any cases, the user interface will rerender twice, which is bad. + * + * Now, if we have a DropPrevious: + * + * this.dropPrevious = new DropPrevious(); + * + * Then we can wrap the _fetch in a DropPrevious and have the expected + * result: + * + * this.dropPrevious + * .add(this._fetch()) + * .then(function (result) { + * self.state = result; + * self.render(); + * }); + */ + DropPrevious: Class.extend({ + /** + * Registers a new deferred and rejects the previous one + * + * @param {Deferred} the new deferred + * @returns {Promise} + */ + add: function (deferred) { + if (this.current_def) { this.current_def.reject(); } + var res = $.Deferred(); + deferred.then(res.resolve, res.reject); + this.current_def = res; + return res.promise(); + } + }), + /** + * A (Odoo) mutex is a primitive for serializing computations. This is + * useful to avoid a situation where two computations modify some shared + * state and cause some corrupted state. + * + * Imagine that we have a function to fetch some data _load(), which returns + * a deferred which resolves to something useful. Now, we have some code + * looking like this: + * + * return this._load().then(function (result) { + * this.state = result; + * }); + * + * If this code is run twice, but the second execution ends before the + * first, then the final state will be the result of the first call to + * _load. However, if we have a mutex: + * + * this.mutex = new Mutex(); + * + * and if we wrap the calls to _load in a mutex: + * + * return this.mutex.exec(function() { + * return this._load().then(function (result) { + * this.state = result; + * }); + * }); + * + * Then, it is guaranteed that the final state will be the result of the + * second execution. + * + * A Mutex has to be a class, and not a function, because we have to keep + * track of some internal state. + */ + Mutex: Class.extend({ + init: function () { + this.def = $.Deferred().resolve(); + }, + /** + * Add a computation to the queue, it will be executed as soon as the + * previous computations are completed. + * + * @param {function} action a function which may return a deferred + * @returns {Deferred} + */ + exec: function (action) { + var current = this.def; + var next = this.def = $.Deferred(); + return current.then(function() { + return $.when(action()).always(function() { + next.resolve(); + }); + }); + } + }), + /** + * Rejects a deferred as soon as a reference deferred is either resolved or + * rejected + * + * @param {Deferred} [target_def] the deferred to potentially reject + * @param {Deferred} [reference_def] the reference target + * @returns {Deferred} + */ + rejectAfter: function (target_def, reference_def) { + var res = $.Deferred(); + target_def.then(res.resolve, res.reject); + reference_def.always(res.reject); + return res.promise(); + } +}; + +}); diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js new file mode 100644 index 00000000000..6214c99483e --- /dev/null +++ b/addons/web/static/src/js/core/dialog.js @@ -0,0 +1,208 @@ +odoo.define('web.Dialog', function (require) { +"use strict"; + +var core = require('web.core'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +/** + A useful class to handle dialogs. + + Attributes: + - $footer: A jQuery element targeting a dom part where buttons can be added. It always exists + during the lifecycle of the dialog. +*/ +var Dialog = Widget.extend({ + /** + Constructor. + + @param {Widget} parent + @param {dictionary} options + - title + - subtitle + - size: one of the following: 'large', 'medium', 'small' + - dialogClass: class to add to the modal-body + - buttons: It must be a list of dictionaries -> text, classes, close, click, disabled + -> If no buttons, a "ok" primary button is added with close = true + -> By default: close = false and classes = 'btn-primary' if only one button and 'btn-default' if many buttons + - $content: Some content to replace this.$el . + */ + init: function (parent, options) { + this._super(parent); + this._opened = $.Deferred(); + + options = _.defaults(options || {}, { + title: _t('Odoo'), subtitle: '', + size: 'large', + dialogClass: '', + $content: false, + buttons: [{text: _t("Ok"), close: true}] + }); + + this.$content = options.$content; + + this.title = options.title; + this.subtitle = options.subtitle; + this.$modal = $(QWeb.render('Dialog', {title: this.title, subtitle: this.subtitle})); + + switch(options.size) { + case 'large': + this.$modal.find('.modal-dialog').addClass('modal-lg'); + break; + case 'small': + this.$modal.find('.modal-dialog').addClass('modal-sm'); + break; + } + + this.dialogClass = options.dialogClass; + this.$footer = this.$modal.find(".modal-footer"); + + this.set_buttons(options.buttons); + + this.$modal.on('hidden.bs.modal', _.bind(this.destroy, this)); + }, + + renderElement: function() { + this._super(); + if(this.$content) { + this.setElement(this.$content); + } + this.$el.addClass('modal-body ' + this.dialogClass); + }, + + set_buttons: function(buttons) { + var self = this; + + self.$footer.empty(); + + _.each(buttons, function(b) { + var text = b.text || ""; + var classes = b.classes || ((buttons.length === 1)? "btn-primary" : "btn-default"); + + var $b = $(QWeb.render('WidgetButton', { + widget: { + string: text, + node: { + attrs: {'class': classes, icon: b.icon} + }, + fa_icon: true + } + })); + $b.prop('disabled', b.disabled); + $b.on('click', function(e) { + var click_def; + if(b.click) { + click_def = b.click.call(self, e); + } + if(b.close) { + $.when(click_def).always(self.close.bind(self)); + } + }); + self.$footer.append($b); + }); + }, + + set_title: function(title, subtitle) { + this.title = title || ""; + if (subtitle !== undefined) { + this.subtitle = subtitle || ""; + } + + var $title = this.$modal.find('.modal-title').first(); + var $subtitle = $title.find('.o_subtitle').detach(); + $title.html(this.title); + $subtitle.html(this.subtitle).appendTo($title); + + return this; + }, + + opened: function(handler) { + return (handler)? this._opened.then(handler) : this._opened; + }, + + open: function() { + $('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened + + var self = this; + this.replace(this.$modal.find(".modal-body")).then(function() { + self.$modal.modal('show'); + self._opened.resolve(); + }); + + return self; + }, + + close: function() { + this.$modal.modal('hide'); + }, + + destroy: function(reason) { + if (this.isDestroyed()) { + return; + } + + this.trigger("closed", reason); + + this._super(); + + $('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared + this.$modal.modal('hide'); + this.$modal.remove(); + + setTimeout(function () { // Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal + var modals = $('body > .modal').filter(':visible'); + if(modals.length) { + modals.last().focus(); + $('body').addClass('modal-open'); + } + }, 0); + } +}); + +// static method to open simple alert dialog +Dialog.alert = function (owner, message, options) { + var buttons = [{ + text: _t("Ok"), + close: true, + click: options && options.confirm_callback, + }]; + return new Dialog(owner, _.extend({ + size: 'medium', + buttons: buttons, + $content: $('<div>', { + text: message, + }), + title: _t("Alert"), + }, options)).open(); +}; + +// static method to open simple confirm dialog +Dialog.confirm = function (owner, message, options) { + var buttons = [ + { + text: _t("Ok"), + classes: 'btn-primary', + close: true, + click: options && options.confirm_callback + }, + { + text: _t("Cancel"), + close: true, + click: options && options.cancel_callback + } + ]; + return new Dialog(owner, _.extend({ + size: 'medium', + buttons: buttons, + $content: $('<div>', { + text: message, + }), + title: _t("Confirmation"), + }, options)).open(); +}; + +return Dialog; + +}); diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js new file mode 100644 index 00000000000..4333061f924 --- /dev/null +++ b/addons/web/static/src/js/core/dom.js @@ -0,0 +1,164 @@ +odoo.define('web.dom', function (require) { +"use strict"; + +/** + * DOM Utility helpers + * + * We collect in this file some helpers to help integrate various DOM + * functionalities with the odoo framework. A common theme in these functions + * is the use of the main core.bus, which helps the framework react when + * something happens in the DOM. + */ + +var core = require('web.core'); + +/** + * Private function to notify that something has been attached in the DOM + * @param {htmlString or Element or Array or jQuery} [content] the content that + * has been attached in the DOM + * @params {Array} [callbacks] array of {widget: w, callback_args: args} such + * that on_attach_callback() will be called on each w with arguments args + */ +function _notify (content, callbacks) { + _.each(callbacks, function(c) { + if (c.widget && c.widget.on_attach_callback) { + c.widget.on_attach_callback(c.callback_args); + } + }); + core.bus.trigger('DOM_updated', content); +} + +return { + /** + * Appends content in a jQuery object and optionnally triggers an event + * + * @param {jQuery} [$target] the node where content will be appended + * @param {htmlString or Element or Array or jQuery} [content] DOM element, + * array of elements, HTML string or jQuery object to append to $target + * @param {Boolean} [options.in_DOM] true if $target is in the DOM + * @param {Array} [options.callbacks] array of objects describing the + * callbacks to perform (see _notify for a complete description) + */ + append: function ($target, content, options) { + $target.append(content); + if (options && options.in_DOM) { + _notify(content, options.callbacks); + } + }, + /** + * Autoresize a $textarea node, by recomputing its height when necessary + * @param {number} [options.min_height] by default, 50. + * @param {Widget} [options.parent] if set, autoresize will listen to some + * extra events to decide when to resize itself. This is useful for + * widgets that are not in the dom when the autoresize is declared. + */ + autoresize: function ($textarea, options) { + if ($textarea.data("auto_resize")) { + return; + } + function resize () { + var heightOffset; + var style = window.getComputedStyle($textarea[0], null); + if (style.boxSizing === 'content-box') { + heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)); + } else { + heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); + } + $fixed_text_area.width($textarea.width()); + $fixed_text_area.val($textarea.val()); + var height = $fixed_text_area[0].scrollHeight; + $textarea.css({height: Math.max(height + heightOffset, min_height)}); + } + + options = options || {}; + + var $fixed_text_area; + var min_height = (options && options.min_height) || 50; + if (!$fixed_text_area) { + $fixed_text_area = $('<textarea disabled>').css({ + position: 'absolute', + opacity: 0, + height: 10, + top: -10000, + left: -10000, + }); + $fixed_text_area.addClass($textarea[0].className); + $fixed_text_area.insertAfter($textarea); + $fixed_text_area.data("auto_resize", true); + } + + var style = window.getComputedStyle($textarea[0], null); + if (style.resize === 'vertical') { + $textarea[0].style.resize = 'none'; + } else if (style.resize === 'both') { + $textarea[0].style.resize = 'horizontal'; + } + resize(); + $textarea.data("auto_resize", true); + + $textarea.on('input focus', resize); + if (options.parent) { + core.bus.on('DOM_updated', options.parent, resize); + core.bus.on('view_shown', options.parent, resize); + } + + }, + /** + * Detaches widgets from the DOM and performs their on_detach_callback() + * + * @param {Array} [to_detach] array of {widget: w, callback_args: args} such + * that w.$el will be detached and w.on_detach_callback(args) will be + * called + * @param {jQuery} [options.$to_detach] if given, detached instead of + * widgets' $el + * @return {jQuery} the detached elements + */ + detach: function (to_detach, options) { + _.each(to_detach, function(d) { + if (d.widget.on_detach_callback) { + d.widget.on_detach_callback(d.callback_args); + } + }); + var $to_detach = options && options.$to_detach; + if (!$to_detach) { + $to_detach = $(_.map(to_detach, function(d) { + return d.widget.el; + })); + } + return $to_detach.detach(); + }, + /** + * Returns the distance between a DOM element and the top-left corner of the + * window + * + * @param {element} [e] the DOM element + * @return {Object} the left and top distances in pixels + */ + getPosition: function (e) { + var position = {left: 0, top: 0}; + while (e) { + position.left += e.offsetLeft; + position.top += e.offsetTop; + e = e.offsetParent; + } + return position; + }, + /** + * Prepends content in a jQuery object and optionnally triggers an event + * + * @param {jQuery} [$target] the node where content will be prepended + * @param {htmlString or Element or Array or jQuery} [content] DOM element, + * array of elements, HTML string or jQuery object to prepend to $target + * @param {Boolean} [options.in_DOM] true if $target is in the DOM + * @param {Array} [options.callbacks] array of objects describing the + * callbacks to perform (see _notify for a complete description) + */ + prepend: function ($target, content, options) { + $target.prepend(content); + if (options && options.in_DOM) { + _notify(content, options.callbacks); + } + }, +}; + +}); diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js new file mode 100644 index 00000000000..11d6a59ec33 --- /dev/null +++ b/addons/web/static/src/js/core/domain.js @@ -0,0 +1,225 @@ +odoo.define("web.Domain", function (require) { +"use strict"; + +var collections = require("web.collections"); +var pyeval = require("web.pyeval"); + +/** + * The Domain Class allows to work with a domain as a tree and provides tools + * to manipulate array and string representations of domains. + */ +var Domain = collections.Tree.extend({ + /** + * @constructor + * @param {string|Array|boolean|undefined} domain + * - The given domain can be: + * * a string representation of the Python prefix-array + * representation of the domain. + * * a JS prefix-array representation of the domain. + * * a boolean where the "true" domain match all records and the + * "false" domain does not match any records. + * * undefined, considered as the false boolean. + */ + init: function (domain) { + this._super.apply(this, arguments); + if (domain === true || domain === false || domain === undefined) { + this._data = domain || false; + } else { + this._parse(this.normalizeArray(_.clone(this.stringToArray(domain)))); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Evaluates the domain with a set of values. + * + * @param {Object} values - a mapping {fieldName -> fieldValue} (note: all + * the fields used in the domain should be given a + * value otherwise the computation will break) + * @returns {boolean} + */ + compute: function (values) { + if (this._data === true || this._data === false) { + // The domain is a always-true or a always-false domain + return this._data; + } else if (_.isArray(this._data)) { + // The domain is a [name, operator, value] entity + // First check if we have the field value in the field values set + if (!(this._data[0] in values)) { + throw new Error(_.str.sprintf( + "Unknown field %s in domain", + this._data[0] + )); + } + var fieldValue = values[this._data[0]]; + + switch (this._data[1]) { + case "=": + case "==": + return _.isEqual(fieldValue, this._data[2]); + case "!=": + case "<>": + return !_.isEqual(fieldValue, this._data[2]); + case "<": + return (fieldValue < this._data[2]); + case ">": + return (fieldValue > this._data[2]); + case "<=": + return (fieldValue <= this._data[2]); + case ">=": + return (fieldValue >= this._data[2]); + case "in": + return _.contains( + _.isArray(this._data[2]) ? this._data[2] : [this._data[2]], + fieldValue + ); + case "not in": + return !_.contains( + _.isArray(this._data[2]) ? this._data[2] : [this._data[2]], + fieldValue + ); + case "like": + return (fieldValue.toLowerCase().indexOf(this._data[2].toLowerCase()) >= 0); + case "ilike": + return (fieldValue.indexOf(this._data[2]) >= 0); + default: + throw new Error(_.str.sprintf( + "Domain %s uses an unsupported operator", + this._data + )); + } + } else { // The domain is a set of [name, operator, value] entitie(s) + switch (this._data) { + case "&": + return _.every(this._children, function (child) { + return child.compute(values); + }); + case "|": + return _.some(this._children, function (child) { + return child.compute(values); + }); + case "!": + return !this._children[0].compute(values); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Initializes the tree representation of the domain according to its given + * JS prefix-array representation. Note: the given array is considered + * already normalized. + * + * @private + * @param {Array} domain - normalized JS prefix-array representation of + * the domain + */ + _parse: function (domain) { + this._data = (domain.length === 0 ? true : domain[0]); + if (domain.length <= 1) return; + + var expected = 1; + for (var i = 1 ; i < domain.length ; i++) { + if (domain[i] === "&" || domain[i] === "|") { + expected++; + } else if (domain[i] !== "!") { + expected--; + } + + if (!expected) { + i++; + this._addSubdomain(domain.slice(1, i)); + this._addSubdomain(domain.slice(i)); + break; + } + } + }, + + /** + * Adds a domain as a child (e.g. if the current domain is ["|", A, B], + * using this method with a ["&", C, D] domain will result in a + * ["|", "|", A, B, "&", C, D]). + * Note: the internal tree representation is automatically simplified. + * + * @param {Array} domain - normalized JS prefix-array representation of a + * domain to add + */ + _addSubdomain: function (domain) { + if (!domain.length) return; + var subdomain = new Domain(domain); + + if (!subdomain._children.length || subdomain._data !== this._data) { + this._children.push(subdomain); + } else { + var self = this; + _.each(subdomain._children, function (childDomain) { + self._children.push(childDomain); + }); + } + }, + + //-------------------------------------------------------------------------- + // Static + //-------------------------------------------------------------------------- + + /** + * Converts JS prefix-array representation of a domain to a string + * representation of the Python prefix-array representation of this domain. + * + * @static + * @param {Array|string} domain + * @returns {string} + */ + arrayToString: function (domain) { + if (_.isString(domain)) return domain; + return JSON.stringify(domain || []) + .replace(/false/g, "False") + .replace(/true/g, "True"); + }, + /** + * Converts a string representation of the Python prefix-array + * representation of a domain to a JS prefix-array representation of this + * domain. + * + * @static + * @param {string|Array} domain + * @returns {Array} + */ + stringToArray: function (domain) { + if (!_.isString(domain)) return domain; + return pyeval.eval("domain", domain || "[]"); + }, + /** + * Makes implicit "&" operators explicit in the given JS prefix-array + * representation of domain (e.g [A, B] -> ["&", A, B]) + * + * @static + * @param {Array} domain - the JS prefix-array representation of the domain + * to normalize (! will be normalized in-place) + * @returns {Array} the normalized JS prefix-array representation of the + * given domain + */ + normalizeArray: function (domain) { + var expected = 1; + _.each(domain, function (item) { + if (item === "&" || item === "|") { + expected++; + } else if (item !== "!") { + expected--; + } + }); + if (expected < 0) { + domain.unshift.apply(domain, _.times(Math.abs(expected), _.constant("&"))); + } + return domain; + }, +}); + +return Domain; +}); diff --git a/addons/web/static/src/js/core/local_storage.js b/addons/web/static/src/js/core/local_storage.js new file mode 100644 index 00000000000..6cf82e87752 --- /dev/null +++ b/addons/web/static/src/js/core/local_storage.js @@ -0,0 +1,34 @@ +odoo.define('web.local_storage', function (require) { +'use strict'; + +var Class = require('web.Class'); + +// Compatibility with private browsing in Safari +// Use dict in case of local storage is disabled +var localStorage = window.localStorage; +try { + var uid = new Date(); + localStorage.setItem(uid, uid); + localStorage.removeItem(uid); +} catch (exception) { + var storage = {}; + var RamStorage = Class.extend({ + setItem: function(key, value) { + storage[key] = value; + }, + getItem: function(key) { + return storage[key]; + }, + removeItem: function(key) { + delete storage[key]; + }, + clear: function() { + storage = {}; + }, + }); + localStorage = new RamStorage(); +} + +return localStorage; + +}); diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js new file mode 100644 index 00000000000..f65276551cf --- /dev/null +++ b/addons/web/static/src/js/core/misc.js @@ -0,0 +1,182 @@ +odoo.define('web.framework', function (require) { +"use strict"; + +var core = require('web.core'); +var crash_manager = require('web.crash_manager'); +var ajax = require('web.ajax'); +var Widget = require('web.Widget'); + +var _t = core._t; + +var messages_by_seconds = function() { + return [ + [0, _t("Loading...")], + [20, _t("Still loading...")], + [60, _t("Still loading...<br />Please be patient.")], + [120, _t("Don't leave yet,<br />it's still loading...")], + [300, _t("You may not believe it,<br />but the application is actually loading...")], + [420, _t("Take a minute to get a coffee,<br />because it's loading...")], + [3600, _t("Maybe you should consider reloading the application by pressing F5...")] + ]; +}; + +var Throbber = Widget.extend({ + template: "Throbber", + start: function() { + this.start_time = new Date().getTime(); + this.act_message(); + }, + act_message: function() { + var self = this; + setTimeout(function() { + if (self.isDestroyed()) + return; + var seconds = (new Date().getTime() - self.start_time) / 1000; + var mes; + _.each(messages_by_seconds(), function(el) { + if (seconds >= el[0]) + mes = el[1]; + }); + self.$(".oe_throbber_message").html(mes); + self.act_message(); + }, 1000); + }, +}); + +/** Setup blockui */ +if ($.blockUI) { + $.blockUI.defaults.baseZ = 1100; + $.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">'; + $.blockUI.defaults.css.border = '0'; + $.blockUI.defaults.css["background-color"] = ''; +} + + +var throbbers = []; + +function blockUI () { + var tmp = $.blockUI.apply($, arguments); + var throbber = new Throbber(); + throbbers.push(throbber); + throbber.appendTo($(".oe_blockui_spin_container")); + $('body').addClass('o_ui_blocked'); + return tmp; +} + +function unblockUI () { + _.invoke(throbbers, 'destroy'); + throbbers = []; + $('body').removeClass('o_ui_blocked'); + return $.unblockUI.apply($, arguments); +} + +/** + * Redirect to url by replacing window.location + * If wait is true, sleep 1s and wait for the server i.e. after a restart. + */ +function redirect (url, wait) { + // Dont display a dialog if some xmlhttprequest are in progress + crash_manager.disable(); + + var load = function() { + var old = "" + window.location; + var old_no_hash = old.split("#")[0]; + var url_no_hash = url.split("#")[0]; + location.assign(url); + if (old_no_hash === url_no_hash) { + location.reload(true); + } + }; + + var wait_server = function() { + ajax.rpc("/web/webclient/version_info", {}).done(load).fail(function() { + setTimeout(wait_server, 250); + }); + }; + + if (wait) { + setTimeout(wait_server, 1000); + } else { + load(); + } +} + +// * Client action to reload the whole interface. +// * If params.menu_id, it opens the given menu entry. +// * If params.wait, reload will wait the openerp server to be reachable before reloading + +function Reload(parent, action) { + var params = action.params || {}; + var menu_id = params.menu_id || false; + var l = window.location; + + var sobj = $.deparam(l.search.substr(1)); + if (params.url_search) { + sobj = _.extend(sobj, params.url_search); + } + var search = '?' + $.param(sobj); + + var hash = l.hash; + if (menu_id) { + hash = "#menu_id=" + menu_id; + } + var url = l.protocol + "//" + l.host + l.pathname + search + hash; + + redirect(url, params.wait); +} + +core.action_registry.add("reload", Reload); + + +/** + * Client action to go back home. + */ +function Home (parent, action) { + var url = '/' + (window.location.search || ''); + redirect(url, action && action.params && action.params.wait); +} +core.action_registry.add("home", Home); + +/** + * Client action to go back in breadcrumb history. + * If can't go back in history stack, will go back to home. + */ +function HistoryBack (parent) { + parent.history_back().fail(function () { + Home(parent); + }); +} +core.action_registry.add("history_back", HistoryBack); + +function login() { + redirect('/web/login'); +} +core.action_registry.add("login", login); + +function logout() { + redirect('/web/session/logout'); + return $.Deferred(); +} +core.action_registry.add("logout", logout); + +/** + * Client action to refresh the session context (making sure + * HTTP requests will have the right one) then reload the + * whole interface. + */ +function ReloadContext (parent, action) { + // side-effect of get_session_info is to refresh the session context + ajax.rpc("/web/session/get_session_info", {}).then(function() { + Reload(parent, action); + }); +} +core.action_registry.add("reload_context", ReloadContext); + + +return { + blockUI: blockUI, + unblockUI: unblockUI, + redirect: redirect, +}; + +}); diff --git a/addons/web/static/src/js/core/mixins.js b/addons/web/static/src/js/core/mixins.js new file mode 100644 index 00000000000..5caac9a2c8c --- /dev/null +++ b/addons/web/static/src/js/core/mixins.js @@ -0,0 +1,472 @@ +odoo.define('web.mixins', function (require) { +"use strict"; + +var Class = require('web.Class'); +var utils = require('web.utils'); +var AbstractService = require('web.AbstractService'); + +/** + * Mixin to structure objects' life-cycles folowing a parent-children + * relationship. Each object can a have a parent and multiple children. + * When an object is destroyed, all its children are destroyed too releasing + * any resource they could have reserved before. + */ +var ParentedMixin = { + __parentedMixin : true, + init: function() { + this.__parentedDestroyed = false; + this.__parentedChildren = []; + this.__parentedParent = null; + }, + /** + * Set the parent of the current object. When calling this method, the + * parent will also be informed and will return the current object + * when its getChildren() method is called. If the current object did + * already have a parent, it is unregistered before, which means the + * previous parent will not return the current object anymore when its + * getChildren() method is called. + */ + setParent : function(parent) { + if (this.getParent()) { + if (this.getParent().__parentedMixin) { + this.getParent().__parentedChildren = _.without(this + .getParent().getChildren(), this); + } + } + this.__parentedParent = parent; + if (parent && parent.__parentedMixin) { + parent.__parentedChildren.push(this); + } + }, + /** + * Return the current parent of the object (or null). + */ + getParent : function() { + return this.__parentedParent; + }, + /** + * Return a list of the children of the current object. + */ + getChildren : function() { + return _.clone(this.__parentedChildren); + }, + /** + * Returns true if destroy() was called on the current object. + */ + isDestroyed : function() { + return this.__parentedDestroyed; + }, + /** + Utility method to only execute asynchronous actions if the current + object has not been destroyed. + + @param {$.Deferred} promise The promise representing the asynchronous + action. + @param {bool} [reject=false] If true, the returned promise will be + rejected with no arguments if the current + object is destroyed. If false, the + returned promise will never be resolved + or rejected. + @returns {$.Deferred} A promise that will mirror the given promise if + everything goes fine but will either be rejected + with no arguments or never resolved if the + current object is destroyed. + */ + alive: function(promise, reject) { + var self = this; + return $.Deferred(function (def) { + promise.then(function () { + if (!self.isDestroyed()) { + def.resolve.apply(def, arguments); + } + }, function () { + if (!self.isDestroyed()) { + def.reject.apply(def, arguments); + } + }).always(function () { + if (reject) { + // noop if def already resolved or rejected + def.reject(); + } + // otherwise leave promise in limbo + }); + }).promise(); + }, + /** + * Inform the object it should destroy itself, releasing any + * resource it could have reserved. + */ + destroy : function() { + _.each(this.getChildren(), function(el) { + el.destroy(); + }); + this.setParent(undefined); + this.__parentedDestroyed = true; + }, + /** + * Find the closest ancestor matching predicate + */ + findAncestor: function (predicate) { + var ancestor = this; + while (ancestor && !(predicate(ancestor)) && ancestor.getParent) { + ancestor = ancestor.getParent(); + } + return ancestor; + }, +}; + +function OdooEvent (target, name, data) { + this.target = target; + this.name = name; + this.data = Object.create(null); + _.extend(this.data, data); + this.stopped = false; +} + +OdooEvent.prototype.stopPropagation = function () { + this.stopped = true; +}; + +OdooEvent.prototype.is_stopped = function () { + return this.stopped; +}; + +/** + * Backbone's events. Do not ever use it directly, use EventDispatcherMixin instead. + * + * This class just handle the dispatching of events, it is not meant to be extended, + * nor used directly. All integration with parenting and automatic unregistration of + * events is done in EventDispatcherMixin. + * + * Copyright notice for the following Class: + * + * (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. + * Backbone may be freely distributed under the MIT license. + * For all details and documentation: + * http://backbonejs.org + * + */ +var Events = Class.extend({ + on : function(events, callback, context) { + var ev; + events = events.split(/\s+/); + var calls = this._callbacks || (this._callbacks = {}); + while ((ev = events.shift())) { + var list = calls[ev] || (calls[ev] = {}); + var tail = list.tail || (list.tail = list.next = {}); + tail.callback = callback; + tail.context = context; + list.tail = tail.next = {}; + } + return this; + }, + + off : function(events, callback, context) { + var ev, calls, node; + if (!events) { + delete this._callbacks; + } else if ((calls = this._callbacks)) { + events = events.split(/\s+/); + while ((ev = events.shift())) { + node = calls[ev]; + delete calls[ev]; + if (!callback || !node) + continue; + while ((node = node.next) && node.next) { + if (node.callback === callback + && (!context || node.context === context)) + continue; + this.on(ev, node.callback, node.context); + } + } + } + return this; + }, + + callbackList: function() { + var lst = []; + _.each(this._callbacks || {}, function(el, eventName) { + var node = el; + while ((node = node.next) && node.next) { + lst.push([eventName, node.callback, node.context]); + } + }); + return lst; + }, + + trigger : function(events) { + var event, node, calls, tail, args, all, rest; + if (!(calls = this._callbacks)) + return this; + all = calls.all; + (events = events.split(/\s+/)).push(null); + // Save references to the current heads & tails. + while ((event = events.shift())) { + if (all) + events.push({ + next : all.next, + tail : all.tail, + event : event + }); + if (!(node = calls[event])) + continue; + events.push({ + next : node.next, + tail : node.tail + }); + } + rest = Array.prototype.slice.call(arguments, 1); + while ((node = events.pop())) { + tail = node.tail; + args = node.event ? [ node.event ].concat(rest) : rest; + while ((node = node.next) !== tail) { + node.callback.apply(node.context || this, args); + } + } + return this; + } +}); + +/** + Mixin containing an event system. Events are also registered by specifying the target object + (the object which will receive the event when it is raised). Both the event-emitting object + and the target object store or reference to each other. This is used to correctly remove all + reference to the event handler when any of the object is destroyed (when the destroy() method + from ParentedMixin is called). Removing those references is necessary to avoid memory leak + and phantom events (events which are raised and sent to a previously destroyed object). +*/ +var EventDispatcherMixin = _.extend({}, ParentedMixin, { + __eventDispatcherMixin: true, + init: function() { + ParentedMixin.init.call(this); + this.__edispatcherEvents = new Events(); + this.__edispatcherRegisteredEvents = []; + }, + on: function(events, dest, func) { + var self = this; + if (typeof func !== "function") { + throw new Error("Event handler must be a function."); + } + events = events.split(/\s+/); + _.each(events, function(eventName) { + self.__edispatcherEvents.on(eventName, func, dest); + if (dest && dest.__eventDispatcherMixin) { + dest.__edispatcherRegisteredEvents.push({name: eventName, func: func, source: self}); + } + }); + return this; + }, + off: function(events, dest, func) { + var self = this; + events = events.split(/\s+/); + _.each(events, function(eventName) { + self.__edispatcherEvents.off(eventName, func, dest); + if (dest && dest.__eventDispatcherMixin) { + dest.__edispatcherRegisteredEvents = _.filter(dest.__edispatcherRegisteredEvents, function(el) { + return !(el.name === eventName && el.func === func && el.source === self); + }); + } + }); + return this; + }, + once: function(events, dest, func) { + // similar to this.on(), but func is executed only once + var self = this; + if (typeof func !== "function") { + throw new Error("Event handler must be a function."); + } + self.on(events, dest, function what() { + func.apply(this, arguments); + self.off(events, dest, what); + }); + }, + trigger: function() { + this.__edispatcherEvents.trigger.apply(this.__edispatcherEvents, arguments); + return this; + }, + trigger_up: function(name, info) { + var event = new OdooEvent(this, name, info); + this._trigger_up(event); + }, + _trigger_up: function(event) { + var parent; + this.__edispatcherEvents.trigger(event.name, event); + if (!event.is_stopped() && (parent = this.getParent())) { + parent._trigger_up(event); + } + }, + destroy: function() { + var self = this; + _.each(this.__edispatcherRegisteredEvents, function(event) { + event.source.__edispatcherEvents.off(event.name, event.func, self); + }); + this.__edispatcherRegisteredEvents = []; + _.each(this.__edispatcherEvents.callbackList(), function(cal) { + this.off(cal[0], cal[2], cal[1]); + }, this); + this.__edispatcherEvents.off(); + ParentedMixin.destroy.call(this); + } +}); + +var PropertiesMixin = _.extend({}, EventDispatcherMixin, { + init: function() { + EventDispatcherMixin.init.call(this); + this.__getterSetterInternalMap = {}; + }, + set: function(arg1, arg2, arg3) { + var map; + var options; + if (typeof arg1 === "string") { + map = {}; + map[arg1] = arg2; + options = arg3 || {}; + } else { + map = arg1; + options = arg2 || {}; + } + var self = this; + var changed = false; + _.each(map, function(val, key) { + var tmp = self.__getterSetterInternalMap[key]; + if (tmp === val) + return; + // seriously, why are you doing this? it is obviously a stupid design. + // the properties mixin should not be concerned with handling fields details. + // this also has the side effect of introducing a dependency on utils. Todo: + // remove this, or move it elsewhere. Also, learn OO programming. + if (key === 'value' && self.field && self.field.type === 'float' && tmp && val){ + var digits = self.field.digits; + if (_.isArray(digits)) { + if (utils.float_is_zero(tmp - val, digits[1])) { + return; + } + } + } + changed = true; + self.__getterSetterInternalMap[key] = val; + if (! options.silent) + self.trigger("change:" + key, self, { + oldValue: tmp, + newValue: val + }); + }); + if (changed) + self.trigger("change", self); + }, + get: function(key) { + return this.__getterSetterInternalMap[key]; + } +}); + +var ServicesMixin = { + call: function(service, method) { + var args = Array.prototype.slice.call(arguments, 2); + var result; + this.trigger_up('call_service', { + service: service, + method: method, + args: args, + callback: function(r) { + result = r; + }, + }); + return result; + }, + // AJAX calls + performRPC: function(route, args) { + return this.call('ajax', 'rpc', route, args); + }, + performModelRPC: function(model, method, args, kwargs) { + return this.performRPC('/web/dataset/call_kw/' + model + '/' + method, { + model: model, + method: method, + args: args || [], + kwargs: kwargs || {}, + }); + }, + loadFieldView: function(dataset, view_id, view_type, options) { + return this.loadViews(dataset.model, dataset.get_context(), [[view_id, view_type]], options).then(function (result) { + return result[view_type]; + }); + }, + loadViews: function(modelName, context, views, options) { + var def = $.Deferred(); + this.trigger_up('load_views', { + modelName: modelName, + context: context, + views: views, + options: options, + on_success: def.resolve.bind(def), + }); + return def; + }, + loadFilters: function(dataset, action_id) { + var def = $.Deferred(); + this.trigger_up('load_filters', { + dataset: dataset, + action_id: action_id, + on_success: def.resolve.bind(def), + }); + return def; + }, + // Session stuff + getSession: function() { + var session; + this.trigger_up('get_session', { + callback: function(result) { + session = result; + } + }); + return session; + }, + /** + * Informs the action manager to do an action. This supposes that + * the action manager can be found amongst the ancestors of the current widget. + * If that's not the case this method will simply return `false`. + */ + do_action: function (action, options) { + var def = $.Deferred(); + options = options || {}; + var oldOnSuccess = options.on_success; + options.on_success = function(result) { + oldOnSuccess(result); + def.resolve(result); + }; + this.trigger_up('do_action', {'action': action, options: options}); + return def; + }, + do_notify: function(title, message, sticky) { + this.trigger_up('notification', {title: title, message: message, sticky: sticky}); + }, + do_warn: function(title, message, sticky) { + this.trigger_up('warning', {title: title, message: message, sticky: sticky}); + }, +}; + +var ServiceProvider = { + services: {}, + init: function() { + var self = this; + _.each(AbstractService.prototype.Services, function(Service) { + var service = new Service(); + self.services[service.name] = service; + }); + this.custom_events.call_service = this._call_service.bind(this); + }, + _call_service: function(event) { + var service = this.services[event.data.service]; + var result = service[event.data.method].apply(service, event.data.args); + event.data.callback(result); + }, +}; + +return { + ParentedMixin: ParentedMixin, + EventDispatcherMixin: EventDispatcherMixin, + PropertiesMixin: PropertiesMixin, + ServicesMixin: ServicesMixin, + ServiceProvider: ServiceProvider, +}; + +}); diff --git a/addons/web/static/src/js/core/pyeval.js b/addons/web/static/src/js/core/pyeval.js new file mode 100644 index 00000000000..df56f60074a --- /dev/null +++ b/addons/web/static/src/js/core/pyeval.js @@ -0,0 +1,1036 @@ +odoo.define('web.pyeval', function (require) { +"use strict"; + +var core = require('web.core'); +var utils = require('web.utils'); + +var _t = core._t; +var py = window.py; // to silence linters +/* + * py.js helpers and setup + */ + +var obj = function () {}; +obj.prototype = py.object; +var asJS = function (arg) { + if (arg instanceof obj) { + return arg.toJSON(); + } + return arg; +}; + +var datetime = py.PY_call(py.object); + +var zero = py.float.fromJSON(0); + +// Port from pypy/lib_pypy/datetime.py +var DAYS_IN_MONTH = [null, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +var DAYS_BEFORE_MONTH = [null]; +var dbm = 0; + +for (var i=1; i<DAYS_IN_MONTH.length; ++i) { + DAYS_BEFORE_MONTH.push(dbm); + dbm += DAYS_IN_MONTH[i]; +} + +function is_leap (year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function days_before_year (year) { + var y = year - 1; + return y*365 + Math.floor(y/4) - Math.floor(y/100) + Math.floor(y/400); +} + +function days_in_month (year, month) { + if (month === 2 && is_leap(year)) { + return 29; + } + return DAYS_IN_MONTH[month]; +} + +function days_before_month (year, month) { + var post_leap_feb = month > 2 && is_leap(year); + return DAYS_BEFORE_MONTH[month] + (post_leap_feb ? 1 : 0); +} + +function ymd2ord (year, month, day) { + var dim = days_in_month(year, month); + if (!(1 <= day && day <= dim)) { + throw new Error("ValueError: day must be in 1.." + dim); + } + return days_before_year(year) + + days_before_month(year, month) + + day; +} + +var DI400Y = days_before_year(401); +var DI100Y = days_before_year(101); +var DI4Y = days_before_year(5); + +function ord2ymd (n) { + --n; + var n400, n100, n4, n1, n0; + utils.divmod(n, DI400Y, function (_n400, n) { + n400 = _n400; + utils.divmod(n, DI100Y, function (_n100, n) { + n100 = _n100; + utils.divmod(n, DI4Y, function (_n4, n) { + n4 = _n4; + utils.divmod(n, 365, function (_n1, n) { + n1 = _n1; + n0 = n; + }); + }); + }); + }); + + n = n0; + var year = n400 * 400 + 1 + n100 * 100 + n4 * 4 + n1; + if (n1 == 4 || n100 == 100) { + utils.assert(n0 === 0); + return { + year: year - 1, + month: 12, + day: 31 + }; + } + + var leapyear = n1 === 3 && (n4 !== 24 || n100 == 3); + utils.assert(leapyear == is_leap(year)); + var month = (n + 50) >> 5; + var preceding = DAYS_BEFORE_MONTH[month] + ((month > 2 && leapyear) ? 1 : 0); + if (preceding > n) { + --month; + preceding -= DAYS_IN_MONTH[month] + ((month === 2 && leapyear) ? 1 : 0); + } + n -= preceding; + return { + year: year, + month: month, + day: n+1 + }; +} + +/** + * Converts the stuff passed in into a valid date, applying overflows as needed + */ +function tmxxx(year, month, day, hour, minute, second, microsecond) { + hour = hour || 0; minute = minute || 0; second = second || 0; + microsecond = microsecond || 0; + + if (microsecond < 0 || microsecond > 999999) { + utils.divmod(microsecond, 1000000, function (carry, ms) { + microsecond = ms; + second += carry; + }); + } + if (second < 0 || second > 59) { + utils.divmod(second, 60, function (carry, s) { + second = s; + minute += carry; + }); + } + if (minute < 0 || minute > 59) { + utils.divmod(minute, 60, function (carry, m) { + minute = m; + hour += carry; + }); + } + if (hour < 0 || hour > 23) { + utils.divmod(hour, 24, function (carry, h) { + hour = h; + day += carry; + }); + } + // That was easy. Now it gets muddy: the proper range for day + // can't be determined without knowing the correct month and year, + // but if day is, e.g., plus or minus a million, the current month + // and year values make no sense (and may also be out of bounds + // themselves). + // Saying 12 months == 1 year should be non-controversial. + if (month < 1 || month > 12) { + utils.divmod(month-1, 12, function (carry, m) { + month = m + 1; + year += carry; + }); + } + // Now only day can be out of bounds (year may also be out of bounds + // for a datetime object, but we don't care about that here). + // If day is out of bounds, what to do is arguable, but at least the + // method here is principled and explainable. + var dim = days_in_month(year, month); + if (day < 1 || day > dim) { + // Move day-1 days from the first of the month. First try to + // get off cheap if we're only one day out of range (adjustments + // for timezone alone can't be worse than that). + if (day === 0) { + --month; + if (month > 0) { + day = days_in_month(year, month); + } else { + --year; month=12; day=31; + } + } else if (day == dim + 1) { + ++month; + day = 1; + if (month > 12) { + month = 1; + ++year; + } + } else { + var r = ord2ymd(ymd2ord(year, month, 1) + (day - 1)); + year = r.year; + month = r.month; + day = r.day; + } + } + return { + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + }; +} + +datetime.timedelta = py.type('timedelta', null, { + __init__: function () { + var args = py.PY_parseArgs(arguments, [ + ['days', zero], ['seconds', zero], ['microseconds', zero], + ['milliseconds', zero], ['minutes', zero], ['hours', zero], + ['weeks', zero] + ]); + + var d = 0, s = 0, m = 0; + var days = args.days.toJSON() + args.weeks.toJSON() * 7; + var seconds = args.seconds.toJSON() + + args.minutes.toJSON() * 60 + + args.hours.toJSON() * 3600; + var microseconds = args.microseconds.toJSON() + + args.milliseconds.toJSON() * 1000; + + // Get rid of all fractions, and normalize s and us. + // Take a deep breath <wink>. + var daysecondsfrac = utils.modf(days, function (dayfrac, days) { + d = days; + if (dayfrac) { + return utils.modf(dayfrac * 24 * 3600, function (dsf, dsw) { + s = dsw; + return dsf; + }); + } + return 0; + }); + + var secondsfrac = utils.modf(seconds, function (sf, s) { + seconds = s; + return sf + daysecondsfrac; + }); + utils.divmod(seconds, 24*3600, function (days, seconds) { + d += days; + s += seconds; + }); + // seconds isn't referenced again before redefinition + + microseconds += secondsfrac * 1e6; + utils.divmod(microseconds, 1000000, function (seconds, microseconds) { + utils.divmod(seconds, 24*3600, function (days, seconds) { + d += days; + s += seconds; + m += Math.round(microseconds); + }); + }); + + // Carrying still possible here? + + this.days = d; + this.seconds = s; + this.microseconds = m; + }, + __str__: function () { + var hh, mm, ss; + utils.divmod(this.seconds, 60, function (m, s) { + utils.divmod(m, 60, function (h, m) { + hh = h; + mm = m; + ss = s; + }); + }); + var s = _.str.sprintf("%d:%02d:%02d", hh, mm, ss); + if (this.days) { + s = _.str.sprintf("%d day%s, %s", + this.days, + (this.days != 1 && this.days != -1) ? 's' : '', + s); + } + if (this.microseconds) { + s = _.str.sprintf("%s.%06d", s, this.microseconds); + } + return py.str.fromJSON(s); + }, + __eq__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.False; + } + + return (this.days === other.days + && this.seconds === other.seconds + && this.microseconds === other.microseconds) + ? py.True : py.False; + }, + __add__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(this.days + other.days), + py.float.fromJSON(this.seconds + other.seconds), + py.float.fromJSON(this.microseconds + other.microseconds) + ]); + }, + __radd__: function (other) { return this.__add__(other); }, + __sub__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(this.days - other.days), + py.float.fromJSON(this.seconds - other.seconds), + py.float.fromJSON(this.microseconds - other.microseconds) + ]); + }, + __rsub__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + return this.__neg__().__add__(other); + }, + __neg__: function () { + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(-this.days), + py.float.fromJSON(-this.seconds), + py.float.fromJSON(-this.microseconds) + ]); + }, + __pos__: function () { return this; }, + __mul__: function (other) { + if (!py.PY_isInstance(other, py.float)) { + return py.NotImplemented; + } + var n = other.toJSON(); + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(this.days * n), + py.float.fromJSON(this.seconds * n), + py.float.fromJSON(this.microseconds * n) + ]); + }, + __rmul__: function (other) { return this.__mul__(other); }, + __div__: function (other) { + if (!py.PY_isInstance(other, py.float)) { + return py.NotImplemented; + } + var usec = ((this.days * 24 * 3600) + this.seconds) * 1000000 + + this.microseconds; + return py.PY_call( + datetime.timedelta, [ + zero, zero, py.float.fromJSON(usec / other.toJSON())]); + }, + __floordiv__: function (other) { return this.__div__(other); }, + total_seconds: function () { + return py.float.fromJSON( + this.days * 86400 + + this.seconds + + this.microseconds / 1000000); + }, + __nonzero__: function () { + return (!!this.days || !!this.seconds || !!this.microseconds) + ? py.True + : py.False; + } +}); + +datetime.datetime = py.type('datetime', null, { + __init__: function () { + var zero = py.float.fromJSON(0); + var args = py.PY_parseArgs(arguments, [ + 'year', 'month', 'day', + ['hour', zero], ['minute', zero], ['second', zero], + ['microsecond', zero], ['tzinfo', py.None] + ]); + for(var key in args) { + if (!args.hasOwnProperty(key)) { continue; } + this[key] = asJS(args[key]); + } + }, + replace: function () { + var args = py.PY_parseArgs(arguments, [ + ['year', py.None], ['month', py.None], ['day', py.None], + ['hour', py.None], ['minute', py.None], ['second', py.None], + ['microsecond', py.None] // FIXME: tzinfo, can't use None as valid input + ]); + var params = {}; + for(var key in args) { + if (!args.hasOwnProperty(key)) { continue; } + + var arg = args[key]; + params[key] = (arg === py.None ? this[key] : asJS(arg)); + } + return py.PY_call(datetime.datetime, params); + }, + strftime: function () { + var self = this; + var args = py.PY_parseArgs(arguments, 'format'); + return py.str.fromJSON(args.format.toJSON() + .replace(/%([A-Za-z])/g, function (m, c) { + switch (c) { + case 'Y': return _.str.sprintf('%04d', self.year); + case 'm': return _.str.sprintf('%02d', self.month); + case 'd': return _.str.sprintf('%02d', self.day); + case 'H': return _.str.sprintf('%02d', self.hour); + case 'M': return _.str.sprintf('%02d', self.minute); + case 'S': return _.str.sprintf('%02d', self.second); + } + throw new Error('ValueError: No known conversion for ' + m); + })); + }, + now: py.classmethod.fromJSON(function () { + var d = new Date(); + return py.PY_call(datetime.datetime, [ + d.getFullYear(), d.getMonth() + 1, d.getDate(), + d.getHours(), d.getMinutes(), d.getSeconds(), + d.getMilliseconds() * 1000]); + }), + today: py.classmethod.fromJSON(function () { + var dt_class = py.PY_getAttr(datetime, 'datetime'); + return py.PY_call(py.PY_getAttr(dt_class, 'now')); + }), + utcnow: py.classmethod.fromJSON(function () { + var d = new Date(); + return py.PY_call(datetime.datetime, + [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), + d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), + d.getUTCMilliseconds() * 1000]); + }), + combine: py.classmethod.fromJSON(function () { + var args = py.PY_parseArgs(arguments, 'date time'); + return py.PY_call(datetime.datetime, [ + py.PY_getAttr(args.date, 'year'), + py.PY_getAttr(args.date, 'month'), + py.PY_getAttr(args.date, 'day'), + py.PY_getAttr(args.time, 'hour'), + py.PY_getAttr(args.time, 'minute'), + py.PY_getAttr(args.time, 'second') + ]); + }), + toJSON: function () { + return new Date( + this.year, + this.month - 1, + this.day, + this.hour, + this.minute, + this.second, + this.microsecond / 1000); + }, +}); + +datetime.date = py.type('date', null, { + __init__: function () { + var args = py.PY_parseArgs(arguments, 'year month day'); + this.year = asJS(args.year); + this.month = asJS(args.month); + this.day = asJS(args.day); + }, + strftime: function () { + var self = this; + var args = py.PY_parseArgs(arguments, 'format'); + return py.str.fromJSON(args.format.toJSON() + .replace(/%([A-Za-z])/g, function (m, c) { + switch (c) { + case 'Y': return self.year; + case 'm': return _.str.sprintf('%02d', self.month); + case 'd': return _.str.sprintf('%02d', self.day); + } + throw new Error('ValueError: No known conversion for ' + m); + })); + }, + __eq__: function (other) { + return (this.year === other.year + && this.month === other.month + && this.day === other.day) + ? py.True : py.False; + }, + replace: function () { + var args = py.PY_parseArgs(arguments, [ + ['year', py.None], ['month', py.None], ['day', py.None] + ]); + var params = {}; + for(var key in args) { + if (!args.hasOwnProperty(key)) { continue; } + + var arg = args[key]; + params[key] = (arg === py.None ? this[key] : asJS(arg)); + } + return py.PY_call(datetime.date, params); + }, + __add__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + var s = tmxxx(this.year, this.month, this.day + other.days); + return datetime.date.fromJSON(s.year, s.month, s.day); + }, + __radd__: function (other) { return this.__add__(other); }, + __sub__: function (other) { + if (py.PY_isInstance(other, datetime.timedelta)) { + return py.PY_add(this, py.PY_negative(other)); + } + if (py.PY_isInstance(other, datetime.date)) { + // FIXME: getattr and sub API methods + return py.PY_call(datetime.timedelta, [ + py.PY_subtract( + py.PY_call(py.PY_getAttr(this, 'toordinal')), + py.PY_call(py.PY_getAttr(other, 'toordinal'))) + ]); + } + return py.NotImplemented; + }, + toordinal: function () { + return py.float.fromJSON(ymd2ord(this.year, this.month, this.day)); + }, + weekday: function () { + return py.float.fromJSON((this.toordinal().toJSON()+6)%7); + }, + fromJSON: function (year, month, day) { + return py.PY_call(datetime.date, [year, month, day]); + }, + today: py.classmethod.fromJSON(function () { + var d = new Date (); + return py.PY_call(datetime.date, [ + d.getFullYear(), d.getMonth() + 1, d.getDate()]); + }), +}); +/** + Returns the current local date, which means the date on the client (which can be different + compared to the date of the server). + + @return {datetime.date} +*/ +function context_today () { + var d = new Date(); + return py.PY_call( + datetime.date, [d.getFullYear(), d.getMonth() + 1, d.getDate()]); +} + +datetime.time = py.type('time', null, { + __init__: function () { + var zero = py.float.fromJSON(0); + var args = py.PY_parseArgs(arguments, [ + ['hour', zero], ['minute', zero], ['second', zero], ['microsecond', zero], + ['tzinfo', py.None] + ]); + + for(var k in args) { + if (!args.hasOwnProperty(k)) { continue; } + this[k] = asJS(args[k]); + } + } +}); + +var time = py.PY_call(py.object); +time.strftime = py.PY_def.fromJSON(function () { + var args = py.PY_parseArgs(arguments, 'format'); + var dt_class = py.PY_getAttr(datetime, 'datetime'); + var d = py.PY_call(py.PY_getAttr(dt_class, 'utcnow')); + return py.PY_call(py.PY_getAttr(d, 'strftime'), [args.format]); +}); + +var args = _.map(('year month day ' + + 'years months weeks days ' + + 'weekday leapdays yearday nlyearday').split(' '), function (arg) { + switch (arg) { + case 'years':case 'months':case 'days':case 'leapdays':case 'weeks': + return [arg, zero]; + case 'year':case 'month':case 'day':case 'weekday': + case 'yearday':case 'nlyearday': + return [arg, null]; + default: + throw new Error("Unknown relativedelta argument " + arg); + } +}); +args.unshift('*'); + +var _utils = { + monthrange: function (year, month) { + if (month < 1 || month > 12) { + throw new Error("Illegal month " + month); + } + + var day1 = this.weekday(year, month, 1); + var ndays = this.mdays[month] + (month == this.February && this.isleap(year)); + return [day1, ndays]; + }, + weekday: function (year, month, day) { + var date = py.PY_call(datetime.date, [year, month, day]); + return py.PY_call(py.PY_getAttr(date, 'weekday')); + }, + isleap: function (year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) + }, + mdays: [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], + January: 1, + February: 2 +}; + +var relativedelta = py.type('relativedelta', null, { + __init__: function () { + this.ops = py.PY_parseArgs(arguments, args); + this.ops.days = py.float.fromJSON( + asJS(this.ops.days) + asJS(this.ops.weeks) * 7 + ); + + var yday = zero; + if (this.ops.nlyearday) { + yday = this.ops.nlyearday; + } else if (this.ops.yearday) { + yday = this.ops.yearday; + if (asJS(this.ops.yearday) > 59) { + this.ops.leapdays = py.float.fromJS(-1); + } + } + if (py.PY_isTrue(yday)) { + var ydayidx = [31, 59, 90, 120, 151, 181, 212, + 243, 273, 304, 334, 366]; + for(var idx=0; idx<ydayidx.length; ++idx) { + var ydays = ydayidx[idx]; + if (asJS(yday) <= ydays) { + this.ops.month = py.float.fromJSON(idx+1); + if (!idx) { + this.ops.day = yday; + } else { + this.ops.day = py.PY_subtract( + yday, + py.float.fromJSON(ydayidx[idx-1]) + ); + } + break; + } + } + if (idx === ydayidx.length) { + throw new Error("Invalid year day (" + asJS(yday) + ")"); + } + } + this._fix(); + }, + _fix: function () { + var months = asJS(this.ops.months); + if (Math.abs(months) > 11) { + var s = months > 0 ? 1 : -1; + var r = utils.divmod(months * s, 12); + this.ops.months = py.float.fromJSON(r.mod*s); + this.ops.years = py.float.fromJSON( + asJS(this.ops.years) + r.div*s); + } + this._has_time = 0; + }, + __add__: function (other) { + if (!py.PY_isInstance(other, datetime.date)) { + return py.NotImplemented; + } + // TODO: test this whole mess + var year = (asJS(this.ops.year) || asJS(other.year)) + asJS(this.ops.years); + var month = asJS(this.ops.month) || asJS(other.month); + var months; + if (months = asJS(this.ops.months)) { + if (Math.abs(months) < 1 || Math.abs(months) > 12) { + throw new Error("Can only use relative months between -12 and +12"); + } + month += months; + if (month > 12) { + year += 1; + month -= 12; + } + if (month < 1) { + year -= 1; + month += 12; + } + } + + var day = Math.min(_utils.monthrange(year, month)[1], + asJS(this.ops.day) || asJS(other.day)); + + var repl = { + year: py.float.fromJSON(year), + month: py.float.fromJSON(month), + day: py.float.fromJSON(day) + }; + + var days = asJS(this.ops.days); + if (py.PY_isTrue(this.ops.leapdays) && month > 2 && _utils.isleap(year)) { + days += asJS(this.ops.leapdays); + } + + var ret = py.PY_add( + py.PY_call(py.PY_getAttr(other, 'replace'), repl), + py.PY_call(datetime.timedelta, { + days: py.float.fromJSON(days) + }) + ); + + if (this.ops.weekday) { + // FIXME: only handles numeric weekdays, not decorated + var weekday = asJS(this.ops.weekday), nth = 1; + var jumpdays = (Math.abs(nth) - 1) * 7; + + var ret_weekday = asJS(py.PY_call(py.PY_getAttr(ret, 'weekday'))); + if (nth > 0) { + jumpdays += (7-ret_weekday+weekday) % 7; + } else { + jumpdays += (ret_weekday - weekday) % 7; + jumpdays *= -1; + } + ret = py.PY_add( + ret, + py.PY_call(datetime.timedelta, { + days: py.float.fromJSON(jumpdays) + }) + ); + } + + return ret; + }, + __radd__: function (other) { + return this.__add__(other); + }, + __rsub__: function (other) { + return this.__neg__().__radd__(other); + }, + __neg__: function () { + return py.PY_call(relativedelta, { + years: py.PY_negative(this.ops.years), + months: py.PY_negative(this.ops.months), + days: py.PY_negative(this.ops.days), + leapdays: this.ops.leapdays, + year: this.ops.year, + month: this.ops.month, + day: this.ops.day, + weekday: this.ops.weekday + }); + } +}); + +// recursively wraps JS objects passed into the context to attributedicts +// which jsonify back to JS objects +function wrap(value) { + if (value === null) { return py.None; } + + switch (typeof value) { + case 'undefined': throw new Error("No conversion for undefined"); + case 'boolean': return py.bool.fromJSON(value); + case 'number': return py.float.fromJSON(value); + case 'string': return py.str.fromJSON(value); + } + + switch(value.constructor) { + case Object: return wrapping_dict.fromJSON(value); + case Array: return wrapping_list.fromJSON(value); + } + + throw new Error("ValueError: unable to wrap " + value); +} + +var wrapping_dict = py.type('wrapping_dict', null, { + __init__: function () { + this._store = {}; + }, + __getitem__: function (key) { + var k = key.toJSON(); + if (!(k in this._store)) { + throw new Error("KeyError: '" + k + "'"); + } + return wrap(this._store[k]); + }, + __getattr__: function (key) { + return this.__getitem__(py.str.fromJSON(key)); + }, + __len__: function () { + return Object.keys(this._store).length + }, + __nonzero__: function () { + return py.PY_size(this) > 0 ? py.True : py.False; + }, + get: function () { + var args = py.PY_parseArgs(arguments, ['k', ['d', py.None]]); + + if (!(args.k.toJSON() in this._store)) { return args.d; } + return this.__getitem__(args.k); + }, + fromJSON: function (d) { + var instance = py.PY_call(wrapping_dict); + instance._store = d; + return instance; + }, + toJSON: function () { + return this._store; + }, +}); + +var wrapping_list = py.type('wrapping_list', null, { + __init__: function () { + this._store = []; + }, + __getitem__: function (index) { + return wrap(this._store[index.toJSON()]); + }, + __len__: function () { + return this._store.length; + }, + __nonzero__: function () { + return py.PY_size(this) > 0 ? py.True : py.False; + }, + fromJSON: function (ar) { + var instance = py.PY_call(wrapping_list); + instance._store = ar; + return instance; + }, + toJSON: function () { + return this._store; + }, +}); + +function wrap_context (context) { + for (var k in context) { + if (!context.hasOwnProperty(k)) { continue; } + var val = context[k]; + + if (val === null) { continue; } + if (val.constructor === Array) { + context[k] = wrapping_list.fromJSON(val); + } else if (val.constructor === Object + && !py.PY_isInstance(val, py.object)) { + context[k] = wrapping_dict.fromJSON(val); + } + } + return context; +} + +function eval_contexts (contexts, evaluation_context) { + evaluation_context = _.extend(pycontext(), evaluation_context || {}); + return _(contexts).reduce(function (result_context, ctx) { + // __eval_context evaluations can lead to some of `contexts`'s + // values being null, skip them as well as empty contexts + if (_.isEmpty(ctx)) { return result_context; } + if (_.isString(ctx)) { + // wrap raw strings in context + ctx = { __ref: 'context', __debug: ctx }; + } + var evaluated = ctx; + switch(ctx.__ref) { + case 'context': + evaluation_context.context = evaluation_context; + evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context)); + break; + case 'compound_context': + var eval_context = eval_contexts([ctx.__eval_context]); + evaluated = eval_contexts( + ctx.__contexts, _.extend({}, evaluation_context, eval_context)); + break; + } + // add newly evaluated context to evaluation context for following + // siblings + _.extend(evaluation_context, evaluated); + return _.extend(result_context, evaluated); + }, {}); +} + +function eval_domains (domains, evaluation_context) { + evaluation_context = _.extend(pycontext(), evaluation_context || {}); + var result_domain = []; + // Normalize only if the first domain is the array ["|"] or ["!"] + var need_normalization = ( + domains && + domains.length > 0 && + domains[0].length === 1 && + (domains[0][0] === "|" || domains[0][0] === "!") + ); + _(domains).each(function (domain) { + if (_.isString(domain)) { + // wrap raw strings in domain + domain = { __ref: 'domain', __debug: domain }; + } + var domain_array_to_combine; + switch(domain.__ref) { + case 'domain': + evaluation_context.context = evaluation_context; + domain_array_to_combine = py.eval(domain.__debug, wrap_context(evaluation_context)); + break; + case 'compound_domain': + var eval_context = eval_contexts([domain.__eval_context]); + domain_array_to_combine = eval_domains( + domain.__domains, _.extend({}, evaluation_context, eval_context) + ); + break; + default: + domain_array_to_combine = domain; + } + if (need_normalization) { + domain_array_to_combine = get_normalized_domain(domain_array_to_combine); + } + result_domain.push.apply(result_domain, domain_array_to_combine); + }); + return result_domain; +} + +/** + * Returns a normalized copy of the given domain array. Normalization is + * is making the implicit "&" at the start of the domain explicit, e.g. + * [A, B, C] would become ["&", "&", A, B, C]. + * + * @param {Array} domain_array + * @returns {Array} normalized copy of the given array + */ +function get_normalized_domain(domain_array) { + var expected = 1; // Holds the number of expected domain expressions + _.each(domain_array, function (item) { + if (item === "&" || item === "|") { + expected++; + } else if (item !== "!") { + expected--; + } + }); + var new_explicit_ands = _.times(-expected, _.constant("&")); + return new_explicit_ands.concat(domain_array); +} + +function eval_groupbys (contexts, evaluation_context) { + evaluation_context = _.extend(pycontext(), evaluation_context || {}); + var result_group = []; + _(contexts).each(function (ctx) { + if (_.isString(ctx)) { + // wrap raw strings in context + ctx = { __ref: 'context', __debug: ctx }; + } + var group; + var evaluated = ctx; + switch(ctx.__ref) { + case 'context': + evaluation_context.context = evaluation_context; + evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context)); + break; + case 'compound_context': + var eval_context = eval_contexts([ctx.__eval_context]); + evaluated = eval_contexts( + ctx.__contexts, _.extend({}, evaluation_context, eval_context)); + break; + } + group = evaluated.group_by; + if (!group) { return; } + if (typeof group === 'string') { + result_group.push(group); + } else if (group instanceof Array) { + result_group.push.apply(result_group, group); + } else { + throw new Error('Got invalid groupby {{' + + JSON.stringify(group) + '}}'); + } + _.extend(evaluation_context, evaluated); + }); + return result_group; +} + + +function pycontext () { + return { + datetime: datetime, + context_today: context_today, + time: time, + relativedelta: relativedelta, + current_date: py.PY_call( + time.strftime, [py.str.fromJSON('%Y-%m-%d')]), + }; +} + +/** + * @param {String} type "domains", "contexts" or "groupbys" + * @param {Array} object domains or contexts to evaluate + * @param {Object} [context] evaluation context + */ +function pyeval (type, object, context) { + context = _.extend(pycontext(), context || {}); + + //noinspection FallthroughInSwitchStatementJS + switch(type) { + case 'context': + case 'contexts': + if (type === 'context') { + object = [object]; + } + return eval_contexts(object, context); + case 'domain': + case 'domains': + if (type === 'domain') + object = [object]; + return eval_domains(object, context); + case 'groupbys': + return eval_groupbys(object, context); + } + throw new Error("Unknow evaluation type " + type); +} + +function eval_arg (arg) { + if (typeof arg !== 'object' || !arg.__ref) { return arg; } + switch(arg.__ref) { + case 'domain': case 'compound_domain': + return pyeval('domains', [arg]); + case 'context': case 'compound_context': + return pyeval('contexts', [arg]); + default: + throw new Error(_t("Unknown nonliteral type ") + ' ' + arg.__ref); + } +} + +/** + * If args or kwargs are unevaluated contexts or domains (compound or not), + * evaluated them in-place. + * + * Potentially mutates both parameters. + * + * @param args + * @param kwargs + */ +function ensure_evaluated (args, kwargs) { + for (var i=0; i<args.length; ++i) { + args[i] = eval_arg(args[i]); + } + for (var k in kwargs) { + if (!kwargs.hasOwnProperty(k)) { continue; } + kwargs[k] = eval_arg(kwargs[k]); + } +} + +function eval_domains_and_contexts (source) { + // see Session.eval_context in Python + return { + context: pyeval('contexts', source.contexts || [], source.eval_context), + domain: pyeval('domains', source.domains, source.eval_context), + group_by: pyeval('groupbys', source.group_by_seq || [], source.eval_context), + }; +} + +function py_eval (expr, context) { + return py.eval(expr, _.extend({}, context || {}, {"true": true, "false": false, "null": null})); +} + + +return { + context: pycontext, + ensure_evaluated: ensure_evaluated, + eval: pyeval, + eval_domains_and_contexts: eval_domains_and_contexts, + py_eval: py_eval, +}; + +}); diff --git a/addons/web/static/src/js/core/qweb.js b/addons/web/static/src/js/core/qweb.js new file mode 100644 index 00000000000..50742075e05 --- /dev/null +++ b/addons/web/static/src/js/core/qweb.js @@ -0,0 +1,51 @@ +odoo.define('web.QWeb', function (require) { +"use strict"; + +var translation = require('web.translation'); + +var _t = translation._t; + +function QWeb(debug, default_dict) { + var qweb = new QWeb2.Engine(); + qweb.default_dict = _.extend({}, default_dict || {}, { + '_' : _, + 'JSON': JSON, + '_t' : translation._t, + '__debug__': debug, + 'moment': function(date) { return new moment(date); }, + 'csrf_token': odoo.csrf_token, + }); + qweb.debug = debug; + qweb.preprocess_node = preprocess_node; + return qweb; +} + +function preprocess_node() { + // Note that 'this' is the Qweb Node + switch (this.node.nodeType) { + case Node.TEXT_NODE: + case Node.CDATA_SECTION_NODE: + // Text and CDATAs + var translation = this.node.parentNode.attributes['t-translation']; + if (translation && translation.value === 'off') { + return; + } + var match = /^(\s*)([\s\S]+?)(\s*)$/.exec(this.node.data); + if (match) { + this.node.data = match[1] + _t(match[2]) + match[3]; + } + break; + case Node.ELEMENT_NODE: + // Element + var attr, attrs = ['label', 'title', 'alt', 'placeholder']; + while ((attr = attrs.pop())) { + if (this.attributes[attr]) { + this.attributes[attr] = _t(this.attributes[attr]); + } + } + } +} + +return QWeb; + +}); diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js new file mode 100644 index 00000000000..d70c09e4ae0 --- /dev/null +++ b/addons/web/static/src/js/core/registry.js @@ -0,0 +1,97 @@ +odoo.define('web.Registry', function (require) { +"use strict"; + +var Class = require('web.Class'); + +/** + * The registry is really pretty much only a mapping from some keys to some + * values. The Registry class only add a few simple methods around that to make + * it nicer and slightly safer. + * + * Note that registries have a fundamental problem: the value that you try to + * get in a registry might not have been added yet, so of course, you need to + * make sure that your dependencies are solid. For this reason, it is a good + * practice to avoid using the registry if you can simply import what you need + * with the 'require' statement. + * + * However, on the flip side, sometimes you cannot just simply import something + * because we would have a dependency cycle. In that case, registries might + * help. + */ +var Registry = Class.extend({ + /** + * @constructor + * @param {Object} [mapping] the initial data in the registry + */ + init: function (mapping) { + this.map = Object.create(mapping || null); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a key (and a value) to the registry. + * + * @param {string} key + * @param {any} value + * @returns {Registry} can be used to chain add calls. + */ + add: function (key, value) { + this.map[key] = value; + return this; + }, + /** + * Check if the registry contains the value + * + * @param {string} key + * @returns {boolean} + */ + contains: function (key) { + return (key in this.map); + }, + /** + * Creates and returns a copy of the current mapping, with the provided + * mapping argument added in (replacing existing keys if needed) + * + * Parent and child remain linked, a new key in the parent (which is not + * overwritten by the child) will appear in the child. + * + * @param {Object} [mapping={}] a mapping of keys to object-paths + */ + extend: function (mapping) { + var child = new Registry(this.map); + _.extend(child.map, mapping); + return child; + }, + /** + * Returns the value associated to the given key. + * + * @param {string} key + * @returns {any} + */ + get: function (key) { + return this.map[key]; + }, + /** + * Tries a number of keys, and returns the first object matching one of + * the keys. + * + * @param {string[]} keys a sequence of keys to fetch the object for + * @returns {any} the first result found matching an object + */ + getAny: function (keys) { + for (var i=0; i<keys.length; i++) { + if (keys[i] in this.map) { + return this.map[keys[i]]; + } + } + return null; + }, +}); + +return Registry; + +}); + diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js new file mode 100644 index 00000000000..ca56eb62439 --- /dev/null +++ b/addons/web/static/src/js/core/session.js @@ -0,0 +1,412 @@ +odoo.define('web.Session', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var utils = require('web.utils'); + +var _t = core._t; +var qweb = core.qweb; +var mixins = core.mixins; + +// To do: refactor session. Session accomplishes several concerns (rpc, +// configuration, currencies (wtf?), user permissions...). They should be +// clarified and separated. + +var Session = core.Class.extend(mixins.EventDispatcherMixin, { + /** + + @param parent The parent of the newly created object. + or `null` if the server to contact is the origin server. + @param {Dict} options A dictionary that can contain the following options: + + * "override_session": Default to false. If true, the current session object will + not try to re-use a previously created session id stored in a cookie. + * "session_id": Default to null. If specified, the specified session_id will be used + by this session object. Specifying this option automatically implies that the option + "override_session" is set to true. + */ + init: function(parent, origin, options) { + mixins.EventDispatcherMixin.init.call(this, parent); + options = options || {}; + this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || []; + this.server = null; + this.session_id = options.session_id || null; + this.override_session = options.override_session || !!options.session_id || false; + this.avoid_recursion = false; + this.use_cors = options.use_cors || false; + this.setup(origin); + var debug_param = $.deparam($.param.querystring()).debug; + this.debug = (debug_param !== undefined ? debug_param || 1 : false); + + // for historic reasons, the session requires a name to properly work + // (see the methods get_cookie and set_cookie). We should perhaps + // remove it totally (but need to make sure the cookies are properly set) + this.name = "instance0"; + // TODO: session store in cookie should be optional + this.qweb_mutex = new concurrency.Mutex(); + this.currencies = {}; + this._groups_def = {}; + }, + setup: function(origin, options) { + // must be able to customize server + var window_origin = location.protocol + "//" + location.host; + origin = origin ? origin.replace( /\/+$/, '') : window_origin; + if (!_.isUndefined(this.origin) && this.origin !== origin) + throw new Error('Session already bound to ' + this.origin); + else + this.origin = origin; + this.prefix = this.origin; + this.server = this.origin; // keep chs happy + this.origin_server = this.origin === window_origin; + options = options || {}; + if ('use_cors' in options) { + this.use_cors = options.use_cors; + } + }, + /** + * Setup a session + */ + session_bind: function(origin) { + var self = this; + this.setup(origin); + qweb.default_dict._s = this.origin; + this.uid = null; + this.username = null; + this.user_context= {}; + this.db = null; + this.module_loaded = {}; + _(this.module_list).each(function (mod) { + self.module_loaded[mod] = true; + }); + this.active_id = null; + return this.session_init(); + }, + /** + * Init a session, reloads from cookie, if it exists + */ + session_init: function () { + var def = this.session_reload(); + if (this.is_frontend) return def; + + var self = this; + return def.then(function() { + var modules = self.module_list.join(','); + var deferred = self.load_qweb(modules); + if(self.session_is_valid()) { + return deferred.then(function() { return self.load_modules(); }); + } + return $.when( + deferred, + self.rpc('/web/webclient/bootstrap_translations', {mods: self.module_list}).then(function(trans) { + _t.database.set_bundle(trans); + }) + ); + }); + }, + session_is_valid: function() { + var db = $.deparam.querystring().db; + if (db && this.db !== db) { + return false; + } + return !!this.uid; + }, + /** + * The session is validated by restoration of a previous session + */ + session_authenticate: function() { + var self = this; + return $.when(this._session_authenticate.apply(this, arguments)).then(function() { + return self.load_modules(); + }); + }, + /** + * The session is validated either by login or by restoration of a previous session + */ + _session_authenticate: function(db, login, password) { + var self = this; + var params = {db: db, login: login, password: password}; + return this.rpc("/web/session/authenticate", params).then(function(result) { + if (!result.uid) { + return $.Deferred().reject(); + } + delete result.session_id; + _.extend(self, result); + }); + }, + session_logout: function() { + $.bbq.removeState(); + return this.rpc("/web/session/destroy", {}); + }, + user_has_group: function(group) { + if (!this.uid) { + return $.when(false); + } + var def = this._groups_def[group]; + if (!def) { + def = this._groups_def[group] = this.rpc('/web/dataset/call_kw/res.users/has_group', { + "model": "res.users", + "method": "has_group", + "args": [group], + "kwargs": {} + }); + } + return def; + }, + get_cookie: function (name) { + if (!this.name) { return null; } + var nameEQ = this.name + '|' + name + '='; + var cookies = document.cookie.split(';'); + for(var i=0; i<cookies.length; ++i) { + var cookie = cookies[i].replace(/^\s*/, ''); + if(cookie.indexOf(nameEQ) === 0) { + try { + return JSON.parse(decodeURIComponent(cookie.substring(nameEQ.length))); + } catch(err) { + // wrong cookie, delete it + this.set_cookie(name, '', -1); + } + } + } + return null; + }, + /** + * Create a new cookie with the provided name and value + * + * @private + * @param name the cookie's name + * @param value the cookie's value + * @param ttl the cookie's time to live, 1 year by default, set to -1 to delete + */ + set_cookie: function (name, value, ttl) { + if (!this.name) { return; } + ttl = ttl || 24*60*60*365; + utils.set_cookie(this.name + '|' + name, value, ttl); + }, + /** + * Load additional web addons of that instance and init them + * + */ + load_modules: function() { + var self = this; + var modules = odoo._modules; + var all_modules = _.uniq(self.module_list.concat(modules)); + var to_load = _.difference(modules, self.module_list).join(','); + this.module_list = all_modules; + + var loaded = $.when(self.load_translations()); + var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US'; + var file_list = [ locale ]; + if(to_load.length) { + loaded = $.when( + loaded, + self.rpc('/web/webclient/csslist', {mods: to_load}).done(self.load_css.bind(self)), + self.load_qweb(to_load), + self.rpc('/web/webclient/jslist', {mods: to_load}).done(function(files) { + file_list = file_list.concat(files); + }) + ); + } + return loaded.then(function () { + return self.load_js(file_list); + }).done(function() { + self.on_modules_loaded(); + self.trigger('module_loaded'); + }); + }, + load_translations: function() { + return _t.database.load_translations(this, this.module_list, this.user_context.lang); + }, + load_css: function (files) { + var self = this; + _.each(files, function (file) { + ajax.loadCSS(self.url(file, null)); + }); + }, + load_js: function(files) { + var self = this; + var d = $.Deferred(); + if (files.length !== 0) { + var file = files.shift(); + var url = self.url(file, null); + ajax.loadJS(url).done(d.resolve); + } else { + d.resolve(); + } + return d; + }, + load_qweb: function(mods) { + this.qweb_mutex.exec(function () { + return $.get('/web/webclient/qweb?mods=' + mods).then(function (doc) { + if (!doc) { return; } + qweb.add_template(doc); + }); + }); + return this.qweb_mutex.def; + }, + on_modules_loaded: function() { + var openerp = window.openerp; + for(var j=0; j<this.module_list.length; j++) { + var mod = this.module_list[j]; + if(this.module_loaded[mod]) + continue; + openerp[mod] = {}; + // init module mod + var fct = openerp._openerp[mod]; + if(typeof(fct) === "function") { + openerp._openerp[mod] = {}; + for (var k in fct) { + openerp._openerp[mod][k] = fct[k]; + } + fct(openerp, openerp._openerp[mod]); + } + this.module_loaded[mod] = true; + } + }, + get_currency: function(currency_id) { + return this.currencies[currency_id]; + }, + get_file: function (options) { + if (this.override_session){ + options.data.session_id = this.session_id; + } + options.session = this; + ajax.get_file(options); + }, + /** + * (re)loads the content of a session: db name, username, user id, session + * context and status of the support contract + * + * @returns {$.Deferred} deferred indicating the session is done reloading + */ + session_reload: function () { + var result = _.extend({}, window.odoo.session_info); + delete result.session_id; + _.extend(this, result); + return $.when(); + }, + check_session_id: function() { + var self = this; + if (this.avoid_recursion) + return $.when(); + if (this.session_id) + return $.when(); // we already have the session id + if (!this.use_cors && (this.override_session || ! this.origin_server)) { + // If we don't use the origin server we consider we should always create a new session. + // Even if some browsers could support cookies when using jsonp that behavior is + // not consistent and the browser creators are tending to removing that feature. + this.avoid_recursion = true; + return this.rpc("/gen_session_id", {}).then(function(result) { + self.session_id = result; + }).always(function() { + self.avoid_recursion = false; + }); + } else { + // normal use case, just use the cookie + self.session_id = utils.get_cookie("session_id"); + return $.when(); + } + }, + /** + * Executes an RPC call, registering the provided callbacks. + * + * Registers a default error callback if none is provided, and handles + * setting the correct session id and session context in the parameter + * objects + * + * @param {String} url RPC endpoint + * @param {Object} params call parameters + * @param {Object} options additional options for rpc call + * @param {Function} success_callback function to execute on RPC call success + * @param {Function} error_callback function to execute on RPC call failure + * @returns {jQuery.Deferred} jquery-provided ajax deferred + */ + rpc: function(url, params, options) { + var self = this; + options = _.clone(options || {}); + var shadow = options.shadow || false; + options.headers = _.extend({}, options.headers) + if (odoo.debug) { + options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug; + } + + delete options.shadow; + + return self.check_session_id().then(function() { + // TODO: remove + if (! _.isString(url)) { + _.extend(options, url); + url = url.url; + } + // TODO correct handling of timeouts + if (! shadow) + self.trigger('request'); + var fct; + if (self.origin_server) { + fct = ajax.jsonRpc; + if (self.override_session) { + options.headers["X-Openerp-Session-Id"] = self.session_id || ''; + } + } else if (self.use_cors) { + fct = ajax.jsonRpc; + url = self.url(url, null); + options.session_id = self.session_id || ''; + if (self.override_session) { + options.headers["X-Openerp-Session-Id"] = self.session_id || ''; + } + } else { + fct = ajax.jsonpRpc; + url = self.url(url, null); + options.session_id = self.session_id || ''; + } + var p = fct(url, "call", params, options); + p = p.then(function (result) { + if (! shadow) + self.trigger('response'); + return result; + }, function(type, error, textStatus, errorThrown) { + if (type === "server") { + if (! shadow) + self.trigger('response'); + if (error.code === 100) { + self.uid = false; + } + return $.Deferred().reject(error, $.Event()); + } else { + if (! shadow) + self.trigger('response_failed'); + var nerror = { + code: -32098, + message: "XmlHttpRequestError " + errorThrown, + data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] } + }; + return $.Deferred().reject(nerror, $.Event()); + } + }); + return p.fail(function() { // Allow deferred user to disable rpc_error call in fail + p.fail(function(error, event) { + if (!event.isDefaultPrevented()) { + self.trigger('error', error, event); + } + }); + }); + }); + }, + url: function(path, params) { + params = _.extend(params || {}); + if (this.override_session || (! this.origin_server)) + params.session_id = this.session_id; + var qs = $.param(params); + if (qs.length > 0) + qs = "?" + qs; + var prefix = _.any(['http://', 'https://', '//'], function(el) { + return path.length >= el.length && path.slice(0, el.length) === el; + }) ? '' : this.prefix; + return prefix + path + qs; + }, +}); + +return Session; + +}); diff --git a/addons/web/static/src/js/core/time.js b/addons/web/static/src/js/core/time.js new file mode 100644 index 00000000000..af13aa5a3b1 --- /dev/null +++ b/addons/web/static/src/js/core/time.js @@ -0,0 +1,299 @@ +odoo.define('web.time', function (require) { +"use strict"; + +var translation = require('web.translation'); +var utils = require('web.utils'); + +var lpad = utils.lpad; +var rpad = utils.rpad; +var _t = translation._t; + +/** + * Replacer function for JSON.stringify, serializes Date objects to UTC + * datetime in the OpenERP Server format. + * + * However, if a serialized value has a toJSON method that method is called + * *before* the replacer is invoked. Date#toJSON exists, and thus the value + * passed to the replacer is a string, the original Date has to be fetched + * on the parent object (which is provided as the replacer's context). + * + * @param {String} k + * @param {Object} v + * @returns {Object} + */ +function date_to_utc (k, v) { + var value = this[k]; + if (!(value instanceof Date)) { return v; } + + return datetime_to_str(value); +} + +/** + * Converts a string to a Date javascript object using OpenERP's + * datetime string format (exemple: '2011-12-01 15:12:35.832'). + * + * The time zone is assumed to be UTC (standard for OpenERP 6.1) + * and will be converted to the browser's time zone. + * + * @param {String} str A string representing a datetime. + * @returns {Date} + */ +function str_to_datetime (str) { + if(!str) { + return str; + } + var regex = /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/; + var res = regex.exec(str); + if ( !res ) { + throw new Error("'" + str + "' is not a valid datetime"); + } + var tmp = new Date(2000,0,1); + tmp.setUTCMonth(1970); + tmp.setUTCMonth(0); + tmp.setUTCDate(1); + tmp.setUTCFullYear(parseFloat(res[1])); + tmp.setUTCMonth(parseFloat(res[2]) - 1); + tmp.setUTCDate(parseFloat(res[3])); + tmp.setUTCHours(parseFloat(res[4])); + tmp.setUTCMinutes(parseFloat(res[5])); + tmp.setUTCSeconds(parseFloat(res[6])); + tmp.setUTCSeconds(parseFloat(res[6])); + tmp.setUTCMilliseconds(parseFloat(utils.rpad((res[7] || "").slice(0, 3), 3))); + return tmp; +} + +/** + * Converts a string to a Date javascript object using OpenERP's + * date string format (exemple: '2011-12-01'). + * + * As a date is not subject to time zones, we assume it should be + * represented as a Date javascript object at 00:00:00 in the + * time zone of the browser. + * + * @param {String} str A string representing a date. + * @returns {Date} + */ +function str_to_date (str) { + if(!str) { + return str; + } + var regex = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; + var res = regex.exec(str); + if ( !res ) { + throw new Error("'" + str + "' is not a valid date"); + } + var tmp = new Date(2000,0,1); + tmp.setFullYear(parseFloat(res[1])); + tmp.setMonth(parseFloat(res[2]) - 1); + tmp.setDate(parseFloat(res[3])); + tmp.setHours(0); + tmp.setMinutes(0); + tmp.setSeconds(0); + return tmp; +} + +/** + * Converts a string to a Date javascript object using OpenERP's + * time string format (exemple: '15:12:35'). + * + * The OpenERP times are supposed to always be naive times. We assume it is + * represented using a javascript Date with a date 1 of January 1970 and a + * time corresponding to the meant time in the browser's time zone. + * + * @param {String} str A string representing a time. + * @returns {Date} + */ +function str_to_time (str) { + if(!str) { + return str; + } + var regex = /^(\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/; + var res = regex.exec(str); + if ( !res ) { + throw new Error("'" + str + "' is not a valid time"); + } + var tmp = new Date(); + tmp.setFullYear(1970); + tmp.setMonth(0); + tmp.setDate(1); + tmp.setHours(parseFloat(res[1])); + tmp.setMinutes(parseFloat(res[2])); + tmp.setSeconds(parseFloat(res[3])); + tmp.setMilliseconds(parseFloat(rpad((res[4] || "").slice(0, 3), 3))); + return tmp; +} + +/** + * Converts a Date javascript object to a string using OpenERP's + * datetime string format (exemple: '2011-12-01 15:12:35'). + * + * The time zone of the Date object is assumed to be the one of the + * browser and it will be converted to UTC (standard for OpenERP 6.1). + * + * @param {Date} obj + * @returns {String} A string representing a datetime. + */ +function datetime_to_str (obj) { + if (!obj) { + return false; + } + return lpad(obj.getUTCFullYear(),4) + "-" + lpad(obj.getUTCMonth() + 1,2) + "-" + + lpad(obj.getUTCDate(),2) + " " + lpad(obj.getUTCHours(),2) + ":" + + lpad(obj.getUTCMinutes(),2) + ":" + lpad(obj.getUTCSeconds(),2); +} + +/** + * Converts a Date javascript object to a string using OpenERP's + * date string format (exemple: '2011-12-01'). + * + * As a date is not subject to time zones, we assume it should be + * represented as a Date javascript object at 00:00:00 in the + * time zone of the browser. + * + * @param {Date} obj + * @returns {String} A string representing a date. + */ +function date_to_str (obj) { + if (!obj) { + return false; + } + return lpad(obj.getFullYear(),4) + "-" + lpad(obj.getMonth() + 1,2) + "-" + + lpad(obj.getDate(),2); +} + +/** + * Converts a Date javascript object to a string using OpenERP's + * time string format (exemple: '15:12:35'). + * + * The OpenERP times are supposed to always be naive times. We assume it is + * represented using a javascript Date with a date 1 of January 1970 and a + * time corresponding to the meant time in the browser's time zone. + * + * @param {Date} obj + * @returns {String} A string representing a time. + */ +function time_to_str (obj) { + if (!obj) { + return false; + } + return lpad(obj.getHours(),2) + ":" + lpad(obj.getMinutes(),2) + ":" + + lpad(obj.getSeconds(),2); +} + +function auto_str_to_date (value) { + try { + return str_to_datetime(value); + } catch(e) {} + try { + return str_to_date(value); + } catch(e) {} + try { + return str_to_time(value); + } catch(e) {} + throw new Error(_.str.sprintf(_t("'%s' is not a correct date, datetime nor time"), value)); +} + +function auto_date_to_str (value, type) { + switch(type) { + case 'datetime': + return datetime_to_str(value); + case 'date': + return date_to_str(value); + case 'time': + return time_to_str(value); + default: + throw new Error(_.str.sprintf(_t("'%s' is not convertible to date, datetime nor time"), type)); + } +} + +/** + * Convert Python strftime to escaped moment.js format. + * + * @param {String} value original format + */ +function strftime_to_moment_format (value) { + if (_normalize_format_cache[value] === undefined) { + var isletter = /[a-zA-Z]/, + output = [], + inToken = false; + + for (var index=0; index < value.length; ++index) { + var character = value[index]; + if (character === '%' && !inToken) { + inToken = true; + continue; + } + if (isletter.test(character)) { + if (inToken && normalize_format_table[character] !== undefined) { + character = normalize_format_table[character]; + } else { + character = '[' + character + ']'; // moment.js escape + } + } + output.push(character); + inToken = false; + } + _normalize_format_cache[value] = output.join(''); + } + return _normalize_format_cache[value]; +} + +/** + * Convert moment.js format to python strftime + * + * @param {String} value original format + */ +function moment_to_strftime_format(value) { + var regex = /(MMMM|DDDD|dddd|YYYY|MMM|ddd|mm|ss|ww|WW|MM|YY|hh|HH|DD|A|d)/g; + return value.replace(regex, function(val){ + return '%'+inverse_normalize_format_table[val]; + }); +} + +var _normalize_format_cache = {}; +var normalize_format_table = { + // Python strftime to moment.js conversion table + // See openerp/addons/base/res/res_lang_view.xml + // for details about supported directives + 'a': 'ddd', + 'A': 'dddd', + 'b': 'MMM', + 'B': 'MMMM', + 'd': 'DD', + 'H': 'HH', + 'I': 'hh', + 'j': 'DDDD', + 'm': 'MM', + 'M': 'mm', + 'p': 'A', + 'S': 'ss', + 'U': 'ww', + 'W': 'WW', + 'w': 'd', + 'y': 'YY', + 'Y': 'YYYY', + // unsupported directives + 'c': 'ddd MMM D HH:mm:ss YYYY', + 'x': 'MM/DD/YY', + 'X': 'HH:mm:ss' +}; +var inverse_normalize_format_table = _.invert(normalize_format_table); + + + +return { + date_to_utc: date_to_utc, + str_to_datetime: str_to_datetime, + str_to_date: str_to_date, + str_to_time: str_to_time, + datetime_to_str: datetime_to_str, + date_to_str: date_to_str, + time_to_str: time_to_str, + auto_str_to_date: auto_str_to_date, + auto_date_to_str: auto_date_to_str, + strftime_to_moment_format: strftime_to_moment_format, + moment_to_strftime_format: moment_to_strftime_format, +}; + +}); + diff --git a/addons/web/static/src/js/core/translation.js b/addons/web/static/src/js/core/translation.js new file mode 100644 index 00000000000..f2bad0db4ee --- /dev/null +++ b/addons/web/static/src/js/core/translation.js @@ -0,0 +1,117 @@ + +odoo.define('web.translation', function (require) { +"use strict"; + +var Class = require('web.Class'); + +var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# */{ + init: function() { + this.db = {}; + this.multi_lang = false + this.parameters = {"direction": 'ltr', + "date_format": '%m/%d/%Y', + "time_format": '%H:%M:%S', + "grouping": [], + "decimal_point": ".", + "thousands_sep": ","}; + }, + set_bundle: function(translation_bundle) { + var self = this; + this.multi_lang = translation_bundle.multi_lang + var modules = _.keys(translation_bundle.modules); + modules.sort(); + if (_.include(modules, "web")) { + modules = ["web"].concat(_.without(modules, "web")); + } + _.each(modules, function(name) { + self.add_module_translation(translation_bundle.modules[name]); + }); + if (translation_bundle.lang_parameters) { + this.parameters = translation_bundle.lang_parameters; + this.parameters.grouping = JSON.parse(this.parameters.grouping); + } + }, + add_module_translation: function(mod) { + var self = this; + _.each(mod.messages, function(message) { + self.db[message.id] = message.string; + }); + }, + build_translation_function: function() { + var self = this; + var fcnt = function(str) { + var tmp = self.get(str); + return tmp === undefined ? str : tmp; + }; + fcnt.database = this; + return fcnt; + }, + get: function(key) { + return this.db[key]; + }, + /** + Loads the translations from an OpenERP server. + + @param {openerp.Session} session The session object to contact the server. + @param {Array} [modules] The list of modules to load the translation. If not specified, + it will default to all the modules installed in the current database. + @param {Object} [lang] lang The language. If not specified it will default to the language + of the current user. + @returns {jQuery.Deferred} + */ + load_translations: function(session, modules, lang) { + var self = this; + return session.rpc('/web/webclient/translations', { + "mods": modules || null, + "lang": lang || null + }).done(function(trans) { + self.set_bundle(trans); + }); + } +}); + +var _t = new TranslationDataBase().build_translation_function(); +/** + * Lazy translation function, only performs the translation when actually + * printed (e.g. inserted into a template) + * + * Useful when defining translatable strings in code evaluated before the + * translation database is loaded, as class attributes or at the top-level of + * an OpenERP Web module + * + * @param {String} s string to translate + * @returns {Object} lazy translation object + */ +var _lt = function (s) { + return {toString: function () { return _t(s); }}; +}; + +/** Setup jQuery timeago */ +/* + * Strings in timeago are "composed" with prefixes, words and suffixes. This + * makes their detection by our translating system impossible. Use all literal + * strings we're using with a translation mark here so the extractor can do its + * job. + */ +{ + _t('less than a minute ago'); + _t('about a minute ago'); + _t('%d minutes ago'); + _t('about an hour ago'); + _t('%d hours ago'); + _t('a day ago'); + _t('%d days ago'); + _t('about a month ago'); + _t('%d months ago'); + _t('about a year ago'); + _t('%d years ago'); +} + + +return { + _t: _t, + _lt: _lt, + TranslationDataBase: TranslationDataBase, +}; + +}); diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js new file mode 100644 index 00000000000..578eb3478f1 --- /dev/null +++ b/addons/web/static/src/js/core/utils.js @@ -0,0 +1,436 @@ +odoo.define('web.utils', function (require) { +"use strict"; + +/** + * Utils + * + * Various generic utility functions + */ + +var translation = require('web.translation'); + +var _t = translation._t; +var id = -1; + +var utils = { + /** + * Throws an error if the given condition is not true + * + * @param {any} bool + */ + assert: function (bool) { + if (!bool) { + throw new Error("AssertionError"); + } + }, + /** + * Confines a value inside an interval + * + * @param {number} [val] the value to confine + * @param {number} [min] the minimum of the interval + * @param {number} [max] the maximum of the interval + * @return {number} val if val is in [min, max], min if val < min and max + * otherwise + */ + confine: function (val, min, max) { + return Math.max(min, Math.min(max, val)); + }, + /** + * computes (Math.floor(a/b), a%b and passes that to the callback. + * + * returns the callback's result + */ + divmod: function (a, b, fn) { + var mod = a%b; + // in python, sign(a % b) === sign(b). Not in JS. If wrong side, add a + // round of b + if (mod > 0 && b < 0 || mod < 0 && b > 0) { + mod += b; + } + return fn(Math.floor(a/b), mod); + }, + /** + * @param {number} value + * @param {integer} decimals + * @returns {boolean} + */ + float_is_zero: function (value, decimals) { + var epsilon = Math.pow(10, -decimals); + return Math.abs(utils.round_precision(value, epsilon)) < epsilon; + }, + /** + * Generate a unique numerical ID + * + * @returns {integer} + */ + generateID: function () { + return ++id; + }, + /** + * Read the cookie described by c_name + * + * @param {string} c_name + * @returns {string} + */ + get_cookie: function (c_name) { + var cookies = document.cookie ? document.cookie.split('; ') : []; + for (var i = 0, l = cookies.length; i < l; i++) { + var parts = cookies[i].split('='); + var name = parts.shift(); + var cookie = parts.join('='); + + if (c_name && c_name === name) { + return cookie; + } + } + return ""; + }, + /** + * Returns a human readable number + * + * @param {Number} number + */ + human_number: function (number, decimals) { + decimals = decimals | 0; + number = Math.round(number); + var d2 = Math.pow(10, decimals); + var val = _t("kMGTPE"); + var i = val.length-1, s; + while( i ) { + s = Math.pow(10,i--*3); + if( s <= number ) { + number = Math.round(number*d2/s)/d2 + val[i]; + } + } + return number; + }, + /** + * Returns a human readable size + * + * @param {Number} number of bytes + */ + human_size: function (size) { + var units = _t("Bytes,Kb,Mb,Gb,Tb,Pb,Eb,Zb,Yb").split(','); + var i = 0; + while (size >= 1024) { + size /= 1024; + ++i; + } + return size.toFixed(2) + ' ' + units[i]; + }, + /** + * Insert "thousands" separators in the provided number (which is actually + * a string) + * + * @param {String} num + * @returns {String} + */ + insert_thousand_seps: function (num) { + var negative = num[0] === '-'; + num = (negative ? num.slice(1) : num); + return (negative ? '-' : '') + utils.intersperse( + num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep); + }, + /** + * Intersperses ``separator`` in ``str`` at the positions indicated by + * ``indices``. + * + * ``indices`` is an array of relative offsets (from the previous insertion + * position, starting from the end of the string) at which to insert + * ``separator``. + * + * There are two special values: + * + * ``-1`` + * indicates the insertion should end now + * ``0`` + * indicates that the previous section pattern should be repeated (until all + * of ``str`` is consumed) + * + * @param {String} str + * @param {Array<Number>} indices + * @param {String} separator + * @returns {String} + */ + intersperse: function (str, indices, separator) { + separator = separator || ''; + var result = [], last = str.length; + + for(var i=0; i<indices.length; ++i) { + var section = indices[i]; + if (section === -1 || last <= 0) { + // Done with string, or -1 (stops formatting string) + break; + } else if(section === 0 && i === 0) { + // repeats previous section, which there is none => stop + break; + } else if (section === 0) { + // repeat previous section forever + //noinspection AssignmentToForLoopParameterJS + section = indices[--i]; + } + result.push(str.substring(last-section, last)); + last -= section; + } + + var s = str.substring(0, last); + if (s) { result.push(s); } + return result.reverse().join(separator); + }, + /** + * @param {any} object + * @param {any} path + * @returns + */ + into: function (object, path) { + if (!_(path).isArray()) { + path = path.split('.'); + } + for (var i = 0; i < path.length; i++) { + object = object[path[i]]; + } + return object; + }, + /** + * @param {string} v + * @returns {boolean} + */ + is_bin_size: function (v) { + return (/^\d+(\.\d*)? \w+$/).test(v); + }, + /** + * @param {any} node + * @param {any} human_readable + * @param {any} indent + * @returns {string} + */ + json_node_to_xml: function (node, human_readable, indent) { + // For debugging purpose, this function will convert a json node back to xml + indent = indent || 0; + var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''), + r = sindent + '<' + node.tag, + cr = human_readable ? '\n' : ''; + + if (typeof(node) === 'string') { + return sindent + node.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) { + throw new Error( + _.str.sprintf(_t("Node [%s] is not a JSONified XML node"), + JSON.stringify(node))); + } + for (var attr in node.attrs) { + var vattr = node.attrs[attr]; + if (typeof(vattr) !== 'string') { + // domains, ... + vattr = JSON.stringify(vattr); + } + vattr = vattr.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + if (human_readable) { + vattr = vattr.replace(/"/g, "'"); + } + r += ' ' + attr + '="' + vattr + '"'; + } + if (node.children && node.children.length) { + r += '>' + cr; + var childs = []; + for (var i = 0, ii = node.children.length; i < ii; i++) { + childs.push(utils.json_node_to_xml(node.children[i], human_readable, indent + 1)); + } + r += childs.join(cr); + r += cr + sindent + '</' + node.tag + '>'; + return r; + } else { + return r + '/>'; + } + }, + /** + * Left-pad provided arg 1 with zeroes until reaching size provided by second + * argument. + * + * @see rpad + * + * @param {number|string} str value to pad + * @param {number} size size to reach on the final padded value + * @returns {string} padded string + */ + lpad: function (str, size) { + str = "" + str; + return new Array(size - str.length + 1).join('0') + str; + }, + /** + * Passes the fractional and integer parts of x to the callback, returns + * the callback's result + */ + modf: function (x, fn) { + var mod = x%1; + if (mod < 0) { + mod += 1; + } + return fn(mod, Math.floor(x)); + }, + /** + * performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision + * See the corresponding float_round() in server/tools/float_utils.py for more info + * @param {Number} the value to be rounded + * @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14 + */ + round_decimals: function (value, decimals) { + return utils.round_precision(value, Math.pow(10,-decimals)); + }, + /** + * performs a half up rounding with arbitrary precision, correcting for float loss of precision + * See the corresponding float_round() in server/tools/float_utils.py for more info + * + * @param {number} value the value to be rounded + * @param {number} precision a precision parameter. eg: 0.01 rounds to two digits. + */ + round_precision: function (value, precision) { + if (!value) { + return 0; + } else if (!precision || precision < 0) { + precision = 1; + } + var normalized_value = value / precision; + var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2); + var epsilon = Math.pow(2, epsilon_magnitude - 53); + normalized_value += normalized_value >= 0 ? epsilon : -epsilon; + + /** + * Javascript performs strictly the round half up method, which is asymmetric. However, in + * Python, the method is symmetric. For example: + * - In JS, Math.round(-0.5) is equal to -0. + * - In Python, round(-0.5) is equal to -1. + * We want to keep the Python behavior for consistency. + */ + var sign = normalized_value < 0 ? -1.0 : 1.0; + var rounded_value = sign * Math.round(Math.abs(normalized_value)); + return rounded_value * precision; + }, + /** + * @see lpad + * + * @param {string} str + * @param {number} size + * @returns {string} + */ + rpad: function (str, size) { + str = "" + str; + return str + new Array(size - str.length + 1).join('0'); + }, + /** + * Create a cookie + * @param {String} name : the name of the cookie + * @param {String} value : the value stored in the cookie + * @param {Integer} ttl : time to live of the cookie in millis. -1 to erase the cookie. + */ + set_cookie: function (name, value, ttl) { + ttl = ttl || 24*60*60*365; + document.cookie = [ + name + '=' + value, + 'path=/', + 'max-age=' + ttl, + 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString() + ].join(';'); + }, + /** + * @param {any} array + * @param {any} elem1 + * @param {any} elem2 + */ + swap: function (array, elem1, elem2) { + var i1 = array.indexOf(elem1); + var i2 = array.indexOf(elem2); + array[i2] = elem1; + array[i1] = elem2; + }, + /** + * @param {any} str + * @param {any} elseValues + * @param {any} trueValues + * @param {any} falseValues + * @returns + */ + toBoolElse: function (str, elseValues, trueValues, falseValues) { + var ret = _.str.toBool(str, trueValues, falseValues); + if (_.isUndefined(ret)) { + return elseValues; + } + return ret; + }, + /** + * @todo: is this really the correct place? + * + * @param {any} data + * @param {any} f + */ + traverse_records: function (data, f) { + if (data.type === 'record') { + f(data); + } else if (data.data) { + for (var i = 0; i < data.data.length; i++) { + utils.traverse_records(data.data[i], f); + } + } + }, + /** + * @param {any} node + * @param {any} strip_whitespace + * @returns + */ + xml_to_json: function (node, strip_whitespace) { + switch (node.nodeType) { + case 9: + return utils.xml_to_json(node.documentElement, strip_whitespace); + case 3: + case 4: + return (strip_whitespace && node.data.trim() === '') ? undefined : node.data; + case 1: + var attrs = $(node).getAttributes(); + _.each(['domain', 'filter_domain', 'context', 'default_get'], function (key) { + if (attrs[key]) { + try { + attrs[key] = JSON.parse(attrs[key]); + } catch(e) { } + } + }); + return { + tag: node.tagName.toLowerCase(), + attrs: attrs, + children: _.compact(_.map(node.childNodes, function (node) { + return utils.xml_to_json(node, strip_whitespace); + })), + }; + } + }, + /** + * @param {any} node + * @returns {string} + */ + xml_to_str: function (node) { + var str = ""; + if (window.XMLSerializer) { + str = (new XMLSerializer()).serializeToString(node); + } else if (window.ActiveXObject) { + str = node.xml; + } else { + throw new Error(_t("Could not serialize XML")); + } + // Browsers won't deal with self closing tags except void elements: + // http://www.w3.org/TR/html-markup/syntax.html + var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' '); + + // The following regex is a bit naive but it's ok for the xmlserializer output + str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function (match, tag, attrs) { + if (void_elements.indexOf(tag) < 0) { + return "<" + tag + attrs + "></" + tag + ">"; + } else { + return match; + } + }); + return str; + }, +}; + +return utils; + +}); diff --git a/addons/web/static/src/js/core/widget.js b/addons/web/static/src/js/core/widget.js new file mode 100644 index 00000000000..221a127c9dd --- /dev/null +++ b/addons/web/static/src/js/core/widget.js @@ -0,0 +1,397 @@ +odoo.define('web.Widget', function (require) { +"use strict"; + +var core = require('web.core'); + +var mixins = core.mixins; + +/** + * Base class for all visual components. Provides a lot of functionalities helpful + * for the management of a part of the DOM. + * + * Widget handles: + * - Rendering with QWeb. + * - Life-cycle management and parenting (when a parent is destroyed, all its children are + * destroyed too). + * - Insertion in DOM. + * + * Guide to create implementations of the Widget class: + * ============================================== + * + * Here is a sample child class: + * + * MyWidget = openerp.base.Widget.extend({ + * // the name of the QWeb template to use for rendering + * template: "MyQWebTemplate", + * + * init: function(parent) { + * this._super(parent); + * // stuff that you want to init before the rendering + * }, + * start: function() { + * // stuff you want to make after the rendering, `this.$el` holds a correct value + * this.$(".my_button").click(/* an example of event binding * /); + * + * // if you have some asynchronous operations, it's a good idea to return + * // a promise in start() + * var promise = this.rpc(...); + * return promise; + * } + * }); + * + * Now this class can simply be used with the following syntax: + * + * var my_widget = new MyWidget(this); + * my_widget.appendTo($(".some-div")); + * + * With these two lines, the MyWidget instance was inited, rendered, it was inserted into the + * DOM inside the ".some-div" div and its events were binded. + * + * And of course, when you don't need that widget anymore, just do: + * + * my_widget.destroy(); + * + * That will kill the widget in a clean way and erase its content from the dom. + */ + + +var Widget = core.Class.extend(mixins.PropertiesMixin, mixins.ServicesMixin, { + // Backbone-ish API + tagName: 'div', + id: null, + className: null, + attributes: {}, + events: {}, + custom_events: {}, + /** + * The name of the QWeb template that will be used for rendering. Must be + * redefined in subclasses or the default render() method can not be used. + * + * @type string + */ + template: null, + /** + * Constructs the widget and sets its parent if a parent is given. + * + * @constructs openerp.Widget + * + * @param {openerp.Widget} parent Binds the current instance to the given Widget instance. + * When that widget is destroyed by calling destroy(), the current instance will be + * destroyed too. Can be null. + */ + init: function (parent) { + mixins.PropertiesMixin.init.call(this); + this.setParent(parent); + // Bind on_/do_* methods to this + // We might remove this automatic binding in the future + for (var name in this) { + if(typeof(this[name]) == "function") { + if((/^on_|^do_/).test(name)) { + this[name] = _.bind(this[name], this); + } + } + } + // FIXME: this should not be + this.setElement(this._make_descriptive()); + this.delegateCustomEvents(); + }, + /** + * Method called between init and start. Performs asynchronous calls required by start. + * + * This method should return a Deferred which is resolved when start can be executed. + * + * @return {jQuery.Deferred} + */ + willStart: function () { + return $.when(); + }, + /** + * Destroys the current widget, also destroys all its children before destroying itself. + */ + destroy: function () { + _.each(this.getChildren(), function (el) { + el.destroy(); + }); + if(this.$el) { + this.$el.remove(); + } + mixins.PropertiesMixin.destroy.call(this); + }, + /** + * Renders the current widget and appends it to the given jQuery object or Widget. + * + * @param target A jQuery object or a Widget instance. + */ + appendTo: function (target) { + var self = this; + return this.__widgetRenderAndInsert(function (t) { + self.$el.appendTo(t); + }, target); + }, + /** + * Renders the current widget and prepends it to the given jQuery object or Widget. + * + * @param target A jQuery object or a Widget instance. + */ + prependTo: function (target) { + var self = this; + return this.__widgetRenderAndInsert(function (t) { + self.$el.prependTo(t); + }, target); + }, + /** + * Renders the current widget and inserts it after to the given jQuery object or Widget. + * + * @param target A jQuery object or a Widget instance. + */ + insertAfter: function (target) { + var self = this; + return this.__widgetRenderAndInsert(function (t) { + self.$el.insertAfter(t); + }, target); + }, + /** + * Renders the current widget and inserts it before to the given jQuery object or Widget. + * + * @param target A jQuery object or a Widget instance. + */ + insertBefore: function (target) { + var self = this; + return this.__widgetRenderAndInsert(function (t) { + self.$el.insertBefore(t); + }, target); + }, + /** + * Attach the current widget to a dom element + * + * @param target A jQuery object or a Widget instance. + */ + attachTo: function (target) { + var self = this; + this.setElement(target.$el || target); + return this.willStart().then(function () { + return self.start(); + }); + }, + /** + * Renders the current widget and replaces the given jQuery object. + * + * @param target A jQuery object or a Widget instance. + */ + replace: function (target) { + return this.__widgetRenderAndInsert(_.bind(function (t) { + this.$el.replaceAll(t); + }, this), target); + }, + __widgetRenderAndInsert: function (insertion, target) { + var self = this; + return this.willStart().then(function () { + self.renderElement(); + insertion(target); + return self.start(); + }); + }, + /** + * Method called after rendering. Mostly used to bind actions, perform asynchronous + * calls, etc... + * + * By convention, this method should return an object that can be passed to $.when() + * to inform the caller when this widget has been initialized. + * + * @returns {jQuery.Deferred or any} + */ + start: function () { + return $.when(); + }, + /** + * Renders the element. The default implementation renders the widget using QWeb, + * `this.template` must be defined. The context given to QWeb contains the "widget" + * key that references `this`. + */ + renderElement: function () { + var $el; + if (this.template) { + $el = $(core.qweb.render(this.template, {widget: this}).trim()); + } else { + $el = this._make_descriptive(); + } + this.replaceElement($el); + }, + /** + * Re-sets the widget's root element and replaces the old root element + * (if any) by the new one in the DOM. + * + * @param {HTMLElement | jQuery} $el + * @returns {*} this + */ + replaceElement: function ($el) { + var $oldel = this.$el; + this.setElement($el); + if ($oldel && !$oldel.is(this.$el)) { + if ($oldel.length > 1) { + $oldel.wrapAll('<div/>'); + $oldel.parent().replaceWith(this.$el); + } else { + $oldel.replaceWith(this.$el); + } + } + return this; + }, + /** + * Re-sets the widget's root element (el/$el/$el). + * + * Includes: + * * re-delegating events + * * re-binding sub-elements + * * if the widget already had a root element, replacing the pre-existing + * element in the DOM + * + * @param {HTMLElement | jQuery} element new root element for the widget + * @return {*} this + */ + setElement: function (element) { + // NB: completely useless, as WidgetMixin#init creates a $el + // always + if (this.$el) { + this.undelegateEvents(); + } + + this.$el = (element instanceof $) ? element : $(element); + this.el = this.$el[0]; + + this.delegateEvents(); + + return this; + }, + /** + * Utility function to build small DOM elements. + * + * @param {String} tagName name of the DOM element to create + * @param {Object} [attributes] map of DOM attributes to set on the element + * @param {String} [content] HTML content to set on the element + * @return {Element} + */ + make: function (tagName, attributes, content) { + var el = document.createElement(tagName); + if (!_.isEmpty(attributes)) { + $(el).attr(attributes); + } + if (content) { + $(el).html(content); + } + return el; + }, + /** + * Makes a potential root element from the declarative builder of the + * widget + * + * @return {jQuery} + * @private + */ + _make_descriptive: function () { + var attrs = _.extend({}, this.attributes || {}); + if (this.id) { attrs.id = this.id; } + if (this.className) { attrs['class'] = this.className; } + return $(this.make(this.tagName, attrs)); + }, + delegateEvents: function () { + var events = this.events; + if (_.isEmpty(events)) { return; } + + for(var key in events) { + if (!events.hasOwnProperty(key)) { continue; } + + var method = this.proxy(events[key]); + + var match = /^(\S+)(\s+(.*))?$/.exec(key); + var event = match[1]; + var selector = match[3]; + + event += '.widget_events'; + if (!selector) { + this.$el.on(event, method); + } else { + this.$el.on(event, selector, method); + } + } + }, + delegateCustomEvents: function () { + if (_.isEmpty(this.custom_events)) { return; } + for (var key in this.custom_events) { + if (!this.custom_events.hasOwnProperty(key)) { continue; } + + var method = this.proxy(this.custom_events[key]); + this.on(key, this, method); + } + }, + undelegateEvents: function () { + this.$el.off('.widget_events'); + }, + /** + * Shortcut for ``this.$el.find(selector)`` + * + * @param {String} selector CSS selector, rooted in $el + * @returns {jQuery} selector match + */ + $: function (selector) { + if (selector === undefined) + return this.$el; + return this.$el.find(selector); + }, + /** + * Displays the widget + */ + do_show: function () { + this.$el.removeClass('o_hidden'); + }, + /** + * Hides the widget + */ + do_hide: function () { + this.$el.addClass('o_hidden'); + }, + /** + * Displays or hides the widget + * @param {Boolean} [display] use true to show the widget or false to hide it + */ + do_toggle: function (display) { + if (_.isBoolean(display)) { + display ? this.do_show() : this.do_hide(); + } else { + this.$el.hasClass('o_hidden') ? this.do_show() : this.do_hide(); + } + }, + /** + * Proxies a method of the object, in order to keep the right ``this`` on + * method invocations. + * + * This method is similar to ``Function.prototype.bind`` or ``_.bind``, and + * even more so to ``jQuery.proxy`` with a fundamental difference: its + * resolution of the method being called is lazy, meaning it will use the + * method as it is when the proxy is called, not when the proxy is created. + * + * Other methods will fix the bound method to what it is when creating the + * binding/proxy, which is fine in most javascript code but problematic in + * OpenERP Web where developers may want to replace existing callbacks with + * theirs. + * + * The semantics of this precisely replace closing over the method call. + * + * @param {String|Function} method function or name of the method to invoke + * @returns {Function} proxied method + */ + proxy: function (method) { + var self = this; + return function () { + var fn = (typeof method === 'string') ? self[method] : method; + if (fn === void 0) { + throw new Error("Couldn't find method '" + method + "' in widget " + self); + } + return fn.apply(self, arguments); + }; + }, +}); + +return Widget; + +}); |
