summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/core
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/core
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/core')
-rw-r--r--addons/web/static/src/js/core/abstract_service.js91
-rw-r--r--addons/web/static/src/js/core/abstract_storage_service.js88
-rw-r--r--addons/web/static/src/js/core/ajax.js582
-rw-r--r--addons/web/static/src/js/core/browser_detection.js20
-rw-r--r--addons/web/static/src/js/core/bus.js19
-rw-r--r--addons/web/static/src/js/core/class.js155
-rw-r--r--addons/web/static/src/js/core/collections.js44
-rw-r--r--addons/web/static/src/js/core/concurrency.js323
-rw-r--r--addons/web/static/src/js/core/context.js53
-rw-r--r--addons/web/static/src/js/core/custom_hooks.js118
-rw-r--r--addons/web/static/src/js/core/data_comparison_utils.js139
-rw-r--r--addons/web/static/src/js/core/dialog.js494
-rw-r--r--addons/web/static/src/js/core/dom.js734
-rw-r--r--addons/web/static/src/js/core/domain.js433
-rw-r--r--addons/web/static/src/js/core/local_storage.js54
-rw-r--r--addons/web/static/src/js/core/math_utils.js73
-rw-r--r--addons/web/static/src/js/core/misc.js236
-rw-r--r--addons/web/static/src/js/core/mixins.js418
-rw-r--r--addons/web/static/src/js/core/mvc.js250
-rw-r--r--addons/web/static/src/js/core/owl_dialog.js275
-rw-r--r--addons/web/static/src/js/core/patch_mixin.js80
-rw-r--r--addons/web/static/src/js/core/popover.js328
-rw-r--r--addons/web/static/src/js/core/py_utils.js562
-rw-r--r--addons/web/static/src/js/core/qweb.js62
-rw-r--r--addons/web/static/src/js/core/ram_storage.js82
-rw-r--r--addons/web/static/src/js/core/registry.js154
-rw-r--r--addons/web/static/src/js/core/rpc.js128
-rw-r--r--addons/web/static/src/js/core/service_mixins.js282
-rw-r--r--addons/web/static/src/js/core/session.js414
-rw-r--r--addons/web/static/src/js/core/session_storage.js56
-rw-r--r--addons/web/static/src/js/core/smooth_scroll_on_drag.js389
-rw-r--r--addons/web/static/src/js/core/time.js352
-rw-r--r--addons/web/static/src/js/core/translation.js132
-rw-r--r--addons/web/static/src/js/core/utils.js1028
-rw-r--r--addons/web/static/src/js/core/widget.js447
35 files changed, 9095 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 00000000..5157b5b5
--- /dev/null
+++ b/addons/web/static/src/js/core/abstract_service.js
@@ -0,0 +1,91 @@
+odoo.define('web.AbstractService', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+const { serviceRegistry } = require("web.core");
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+
+var AbstractService = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ dependencies: [],
+ init: function (env) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.env = env;
+ },
+ /**
+ * @abstract
+ */
+ start: function () {},
+ /**
+ * Directly calls the requested service, instead of triggering a
+ * 'call_service' event up, which wouldn't work as services have no parent
+ *
+ * @param {OdooEvent} ev
+ */
+ _trigger_up: function (ev) {
+ Mixins.EventDispatcherMixin._trigger_up.apply(this, arguments);
+ if (ev.is_stopped()) {
+ return;
+ }
+ const payload = ev.data;
+ if (ev.name === 'call_service') {
+ let args = payload.args || [];
+ if (payload.service === 'ajax' && payload.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(ev.target);
+ }
+ const service = this.env.services[payload.service];
+ const result = service[payload.method].apply(service, args);
+ payload.callback(result);
+ } else if (ev.name === 'do_action') {
+ this.env.bus.trigger('do-action', payload);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Static
+ //--------------------------------------------------------------------------
+
+ /**
+ * Deploy services in the env (specializations of AbstractService registered
+ * into the serviceRegistry).
+ *
+ * @static
+ * @param {Object} env
+ */
+ deployServices(env) {
+ const UndeployedServices = Object.assign({}, serviceRegistry.map);
+ function _deployServices() {
+ let done = false;
+ while (!done) {
+ // find a service with no missing dependency
+ const serviceName = Object.keys(UndeployedServices).find(serviceName => {
+ const Service = UndeployedServices[serviceName];
+ return Service.prototype.dependencies.every(depName => {
+ return env.services[depName];
+ });
+ });
+ if (serviceName) {
+ const Service = UndeployedServices[serviceName];
+ const service = new Service(env);
+ env.services[serviceName] = service;
+ delete UndeployedServices[serviceName];
+ service.start();
+ } else {
+ done = true;
+ }
+ }
+ }
+ serviceRegistry.onAdd((serviceName, Service) => {
+ if (serviceName in env.services || serviceName in UndeployedServices) {
+ throw new Error(`Service ${serviceName} is already loaded.`);
+ }
+ UndeployedServices[serviceName] = Service;
+ _deployServices();
+ });
+ _deployServices();
+ }
+});
+
+return AbstractService;
+});
diff --git a/addons/web/static/src/js/core/abstract_storage_service.js b/addons/web/static/src/js/core/abstract_storage_service.js
new file mode 100644
index 00000000..5d724f87
--- /dev/null
+++ b/addons/web/static/src/js/core/abstract_storage_service.js
@@ -0,0 +1,88 @@
+odoo.define('web.AbstractStorageService', function (require) {
+'use strict';
+
+/**
+ * This module defines an abstraction for services that write into Storage
+ * objects (e.g. localStorage or sessionStorage).
+ */
+
+var AbstractService = require('web.AbstractService');
+
+var AbstractStorageService = AbstractService.extend({
+ // the 'storage' attribute must be set by actual StorageServices extending
+ // this abstraction
+ storage: null,
+
+ /**
+ * @override
+ */
+ destroy: function () {
+ // storage can be permanent or transient, destroy transient ones
+ if ((this.storage || {}).destroy) {
+ this.storage.destroy();
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Removes all data from the storage
+ */
+ clear: function() {
+ this.storage.clear();
+ },
+ /**
+ * Returns the value associated with a given key in the storage
+ *
+ * @param {string} key
+ * @returns {string}
+ */
+ getItem: function(key, defaultValue) {
+ var val = this.storage.getItem(key);
+ return val ? JSON.parse(val) : defaultValue;
+ },
+ /**
+ * @param {integer} index
+ * @return {string}
+ */
+ key: function (index) {
+ return this.storage.key(index);
+ },
+ /**
+ * @return {integer}
+ */
+ length: function () {
+ return this.storage.length;
+ },
+ /**
+ * Removes the given key from the storage
+ *
+ * @param {string} key
+ */
+ removeItem: function(key) {
+ this.storage.removeItem(key);
+ },
+ /**
+ * Sets the value of a given key in the storage
+ *
+ * @param {string} key
+ * @param {string} value
+ */
+ setItem: function(key, value) {
+ this.storage.setItem(key, JSON.stringify(value));
+ },
+ /**
+ * Add an handler on storage event
+ *
+ */
+ onStorage: function () {
+ this.storage.on.apply(this.storage, ["storage"].concat(Array.prototype.slice.call(arguments)));
+ },
+});
+
+return AbstractStorageService;
+
+});
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 00000000..29321862
--- /dev/null
+++ b/addons/web/static/src/js/core/ajax.js
@@ -0,0 +1,582 @@
+odoo.define('web.ajax', function (require) {
+"use strict";
+
+var config = require('web.config');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var time = require('web.time');
+var download = require('web.download');
+var contentdisposition = require('web.contentdisposition');
+
+var _t = core._t;
+
+// Create the final object containing all the functions first to allow monkey
+// patching them correctly if ever needed.
+var ajax = {};
+
+function _genericJsonRpc (fct_name, params, settings, fct) {
+ var shadow = settings.shadow || false;
+ delete settings.shadow;
+ if (!shadow) {
+ core.bus.trigger('rpc_request');
+ }
+
+ 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.then(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.debug(
+ "Server application error\n",
+ "Error code:", result.error.code, "\n",
+ "Error message:", result.error.message, "\n",
+ "Error data message:\n", result.error.data.message, "\n",
+ "Error data debug:\n", result.error.data.debug
+ );
+ }
+ return Promise.reject({type: "server", error: result.error});
+ } else {
+ return result.result;
+ }
+ }, function() {
+ //console.error("JsonRPC communication error", _.toArray(arguments));
+ var reason = {
+ type: 'communication',
+ error: arguments[0],
+ textStatus: arguments[1],
+ errorThrown: arguments[2],
+ };
+ return Promise.reject(reason);
+ });
+
+ var rejection;
+ var promise = new Promise(function (resolve, reject) {
+ rejection = reject;
+
+ result.then(function (result) {
+ if (!shadow) {
+ core.bus.trigger('rpc_response');
+ }
+ resolve(result);
+ }, function (reason) {
+ var type = reason.type;
+ var error = reason.error;
+ var textStatus = reason.textStatus;
+ var errorThrown = reason.errorThrown;
+ if (type === "server") {
+ if (!shadow) {
+ core.bus.trigger('rpc_response');
+ }
+ if (error.code === 100) {
+ core.bus.trigger('invalidate_session');
+ }
+ reject({message: error, event: $.Event()});
+ } else {
+ if (!shadow) {
+ core.bus.trigger('rpc_response_failed');
+ }
+ var nerror = {
+ code: -32098,
+ message: "XmlHttpRequestError " + errorThrown,
+ data: {
+ type: "xhr"+textStatus,
+ debug: error.responseText,
+ objects: [error, errorThrown],
+ arguments: [reason || textStatus]
+ },
+ };
+ reject({message: nerror, event: $.Event()});
+ }
+ });
+ });
+
+ // FIXME: jsonp?
+ promise.abort = function () {
+ rejection({
+ message: "XmlHttpRequestError abort",
+ event: $.Event('abort')
+ });
+ if (xhr.abort) {
+ xhr.abort();
+ }
+ };
+ promise.guardedCatch(function (reason) { // Allow promise user to disable rpc_error call in case of failure
+ setTimeout(function () {
+ // we want to execute this handler after all others (hence
+ // setTimeout) to let the other handlers prevent the event
+ if (!reason.event.isDefaultPrevented()) {
+ core.bus.trigger('rpc_error', reason.message, reason.event);
+ }
+ }, 0);
+ });
+ return promise;
+};
+
+function jsonRpc(url, fct_name, params, settings) {
+ settings = settings || {};
+ return _genericJsonRpc(fct_name, params, settings, function(data) {
+ return $.ajax(url, _.extend({}, settings, {
+ url: url,
+ dataType: 'json',
+ type: 'POST',
+ data: JSON.stringify(data, time.date_to_utc),
+ contentType: 'application/json'
+ }));
+ });
+}
+
+// helper function to make a rpc with a function name hardcoded to 'call'
+function rpc(url, params, settings) {
+ return jsonRpc(url, 'call', params, settings);
+}
+
+
+/**
+ * Load css asynchronously: fetch it from the url parameter and add a link tag
+ * to <head>.
+ * If the url has already been requested and loaded, the promise will resolve
+ * immediately.
+ *
+ * @param {String} url of the css to be fetched
+ * @returns {Promise} resolved when the css has been loaded.
+ */
+var loadCSS = (function () {
+ var urlDefs = {};
+
+ return function loadCSS(url) {
+ if (url in urlDefs) {
+ // nothing to do here
+ } else if ($('link[href="' + url + '"]').length) {
+ // the link is already in the DOM, the promise can be resolved
+ urlDefs[url] = Promise.resolve();
+ } else {
+ var $link = $('<link>', {
+ 'href': url,
+ 'rel': 'stylesheet',
+ 'type': 'text/css'
+ });
+ urlDefs[url] = new Promise(function (resolve, reject) {
+ $link.on('load', function () {
+ resolve();
+ }).on('error', function () {
+ reject(new Error("Couldn't load css dependency: " + $link[0].href));
+ });
+ });
+ $('head').append($link);
+ }
+ return urlDefs[url];
+ };
+})();
+
+var loadJS = (function () {
+ var dependenciesPromise = {};
+
+ 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 promise indicating if
+ // the script has been fully loaded. If not, the promise has to be initialized.
+ // This is initialized as already resolved if the script was already there without the need of loadJS.
+ if (url in dependenciesPromise) {
+ return dependenciesPromise[url];
+ }
+ var scriptLoadedPromise = new Promise(function (resolve, reject) {
+ if (alreadyRequired) {
+ resolve();
+ } else {
+ // Get the script associated promise and returns it after initializing the script if needed. The
+ // promise is marked to be resolved on script load and rejected on script error.
+ 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;
+ resolve(url);
+ };
+ script.onerror = function () {
+ console.error("Error loading file", script.src);
+ reject(url);
+ };
+ var head = document.head || document.getElementsByTagName('head')[0];
+ head.appendChild(script);
+ }
+ });
+
+ dependenciesPromise[url] = scriptLoadedPromise;
+ return scriptLoadedPromise;
+ };
+
+ 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
+ * @returns {boolean} a false value means that a popup window was blocked. This
+ * mean that we probably need to inform the user that something needs to be
+ * changed to make it work.
+ */
+function get_file(options) {
+ var xhr = new XMLHttpRequest();
+
+ var data;
+ if (options.form) {
+ xhr.open(options.form.method, options.form.action);
+ data = new FormData(options.form);
+ } else {
+ xhr.open('POST', options.url);
+ data = new FormData();
+ _.each(options.data || {}, function (v, k) {
+ data.append(k, v);
+ });
+ }
+ data.append('token', 'dummy-because-api-expects-one');
+ if (core.csrf_token) {
+ data.append('csrf_token', core.csrf_token);
+ }
+ // IE11 wants this after xhr.open or it throws
+ xhr.responseType = 'blob';
+
+ // onreadystatechange[readyState = 4]
+ // => onload (success) | onerror (error) | onabort
+ // => onloadend
+ xhr.onload = function () {
+ var mimetype = xhr.response.type;
+ if (xhr.status === 200 && mimetype !== 'text/html') {
+ // replace because apparently we send some C-D headers with a trailing ";"
+ // todo: maybe a lack of CD[attachment] should be interpreted as an error case?
+ var header = (xhr.getResponseHeader('Content-Disposition') || '').replace(/;$/, '');
+ var filename = header ? contentdisposition.parse(header).parameters.filename : null;
+
+ download(xhr.response, filename, mimetype);
+ // not sure download is going to be sync so this may be called
+ // before the file is actually fetched (?)
+ if (options.success) { options.success(); }
+ return true;
+ }
+
+ if (!options.error) {
+ return true;
+ }
+ var decoder = new FileReader();
+ decoder.onload = function () {
+ var contents = decoder.result;
+
+ var err;
+ var doc = new DOMParser().parseFromString(contents, 'text/html');
+ var nodes = doc.body.children.length === 0 ? doc.body.childNodes : doc.body.children;
+ try { // Case of a serialized Odoo Exception: It is Json Parsable
+ var node = nodes[1] || nodes[0];
+ err = JSON.parse(node.textContent);
+ } catch (e) { // Arbitrary uncaught python side exception
+ err = {
+ message: nodes.length > 1 ? nodes[1].textContent : '',
+ data: {
+ name: String(xhr.status),
+ title: nodes.length > 0 ? nodes[0].textContent : '',
+ }
+ };
+ }
+ options.error(err);
+ };
+ decoder.readAsText(xhr.response);
+ };
+ xhr.onerror = function () {
+ if (options.error) {
+ options.error({
+ message: _t("Something happened while trying to contact the server, check that the server is online and that you still have a working network connection."),
+ data: { title: _t("Could not connect to the server") }
+ });
+ }
+ };
+ if (options.complete) {
+ xhr.onloadend = function () { options.complete(); };
+ }
+
+ xhr.send(data);
+ return true;
+}
+
+function post (controller_url, data) {
+ var postData = new FormData();
+
+ $.each(data, function(i,val) {
+ postData.append(i, val);
+ });
+ if (core.csrf_token) {
+ postData.append('csrf_token', core.csrf_token);
+ }
+
+ return new Promise(function (resolve, reject) {
+ $.ajax(controller_url, {
+ data: postData,
+ processData: false,
+ contentType: false,
+ type: 'POST'
+ }).then(resolve).fail(reject);
+ });
+}
+
+/**
+ * Loads an XML file according to the given URL and adds its associated qweb
+ * templates to the given qweb engine. The function can also be used to get
+ * the promise which indicates when all the calls to the function are finished.
+ *
+ * Note: "all the calls" = the calls that happened before the current no-args
+ * one + the calls that will happen after but when the previous ones are not
+ * finished yet.
+ *
+ * @param {string} [url] - an URL where to find qweb templates
+ * @param {QWeb} [qweb] - the engine to which the templates need to be added
+ * @returns {Promise}
+ * If no argument is given to the function, the promise's state
+ * indicates if "all the calls" are finished (see main description).
+ * Otherwise, it indicates when the templates associated to the given
+ * url have been loaded.
+ */
+var loadXML = (function () {
+ // Some "static" variables associated to the loadXML function
+ var isLoading = false;
+ var loadingsData = [];
+ var seenURLs = [];
+
+ return function (url, qweb) {
+ function _load() {
+ isLoading = true;
+ if (loadingsData.length) {
+ // There is something to load, load it, resolve the associated
+ // promise then start loading the next one
+ var loadingData = loadingsData[0];
+ loadingData.qweb.add_template(loadingData.url, function () {
+ // Remove from array only now so that multiple calls to
+ // loadXML with the same URL returns the right promise
+ loadingsData.shift();
+ loadingData.resolve();
+ _load();
+ });
+ } else {
+ // There is nothing to load anymore, so resolve the
+ // "all the calls" promise
+ isLoading = false;
+ }
+ }
+
+ // If no argument, simply returns the promise which indicates when
+ // "all the calls" are finished
+ if (!url || !qweb) {
+ return Promise.resolve();
+ }
+
+ // If the given URL has already been seen, do nothing but returning the
+ // associated promise
+ if (_.contains(seenURLs, url)) {
+ var oldLoadingData = _.findWhere(loadingsData, {url: url});
+ return oldLoadingData ? oldLoadingData.def : Promise.resolve();
+ }
+ seenURLs.push(url);
+
+
+ // Add the information about the new data to load: the url, the qweb
+ // engine and the associated promise
+ var newLoadingData = {
+ url: url,
+ qweb: qweb,
+ };
+ newLoadingData.def = new Promise(function (resolve, reject) {
+ newLoadingData.resolve = resolve;
+ newLoadingData.reject = reject;
+ });
+ loadingsData.push(newLoadingData);
+
+ // If not already started, start the loading loop (reinitialize the
+ // "all the calls" promise to an unresolved state)
+ if (!isLoading) {
+ _load();
+ }
+
+ // Return the promise associated to the new given URL
+ return newLoadingData.def;
+ };
+})();
+
+/**
+ * Loads a template file according to the given xmlId.
+ *
+ * @param {string} [xmlId] - the template xmlId
+ * @param {Object} [context]
+ * additionnal rpc context to be merged with the default one
+ * @param {string} [tplRoute='/web/dataset/call_kw/']
+ * @returns {Deferred} resolved with an object
+ * cssLibs: list of css files
+ * cssContents: list of style tag contents
+ * jsLibs: list of JS files
+ * jsContents: list of script tag contents
+ */
+var loadAsset = (function () {
+ var cache = {};
+
+ var load = function loadAsset(xmlId, context, tplRoute = '/web/dataset/call_kw/') {
+ if (cache[xmlId]) {
+ return cache[xmlId];
+ }
+ context = _.extend({}, odoo.session_info.user_context, context);
+ const params = {
+ args: [xmlId, {
+ debug: config.isDebug()
+ }],
+ kwargs: {
+ context: context,
+ },
+ };
+ if (tplRoute === '/web/dataset/call_kw/') {
+ Object.assign(params, {
+ model: 'ir.ui.view',
+ method: 'render_public_asset',
+ });
+ }
+ cache[xmlId] = rpc(tplRoute, params).then(function (xml) {
+ var $xml = $(xml);
+ return {
+ cssLibs: $xml.filter('link[href]:not([type="image/x-icon"])').map(function () {
+ return $(this).attr('href');
+ }).get(),
+ cssContents: $xml.filter('style').map(function () {
+ return $(this).html();
+ }).get(),
+ jsLibs: $xml.filter('script[src]').map(function () {
+ return $(this).attr('src');
+ }).get(),
+ jsContents: $xml.filter('script:not([src])').map(function () {
+ return $(this).html();
+ }).get(),
+ };
+ }).guardedCatch(reason => {
+ reason.event.preventDefault();
+ throw `Unable to render the required templates for the assets to load: ${reason.message.message}`;
+ });
+ return cache[xmlId];
+ };
+
+ return load;
+})();
+
+/**
+ * Loads the given js/css libraries and asset bundles. Note that no library or
+ * asset will be loaded if it was already done before.
+ *
+ * @param {Object} libs
+ * @param {Array<string|string[]>} [libs.assetLibs=[]]
+ * The list of assets to load. Each list item may be a string (the xmlID
+ * of the asset to load) or a list of strings. The first level is loaded
+ * sequentially (so use this if the order matters) while the assets in
+ * inner lists are loaded in parallel (use this for efficiency but only
+ * if the order does not matter, should rarely be the case for assets).
+ * @param {string[]} [libs.cssLibs=[]]
+ * The list of CSS files to load. They will all be loaded in parallel but
+ * put in the DOM in the given order (only the order in the DOM is used
+ * to determine priority of CSS rules, not loaded time).
+ * @param {Array<string|string[]>} [libs.jsLibs=[]]
+ * The list of JS files to load. Each list item may be a string (the URL
+ * of the file to load) or a list of strings. The first level is loaded
+ * sequentially (so use this if the order matters) while the files in inner
+ * lists are loaded in parallel (use this for efficiency but only
+ * if the order does not matter).
+ * @param {string[]} [libs.cssContents=[]]
+ * List of inline styles to add after loading the CSS files.
+ * @param {string[]} [libs.jsContents=[]]
+ * List of inline scripts to add after loading the JS files.
+ * @param {Object} [context]
+ * additionnal rpc context to be merged with the default one
+ * @param {string} [tplRoute]
+ * Custom route to use for template rendering of the potential assets
+ * to load (see libs.assetLibs).
+ *
+ * @returns {Promise}
+ */
+function loadLibs(libs, context, tplRoute) {
+ var mutex = new concurrency.Mutex();
+ mutex.exec(function () {
+ var defs = [];
+ var cssLibs = [libs.cssLibs || []]; // Force loading in parallel
+ defs.push(_loadArray(cssLibs, ajax.loadCSS).then(function () {
+ if (libs.cssContents && libs.cssContents.length) {
+ $('head').append($('<style/>', {
+ html: libs.cssContents.join('\n'),
+ }));
+ }
+ }));
+ defs.push(_loadArray(libs.jsLibs || [], ajax.loadJS).then(function () {
+ if (libs.jsContents && libs.jsContents.length) {
+ $('head').append($('<script/>', {
+ html: libs.jsContents.join('\n'),
+ }));
+ }
+ }));
+ return Promise.all(defs);
+ });
+ mutex.exec(function () {
+ return _loadArray(libs.assetLibs || [], function (xmlID) {
+ return ajax.loadAsset(xmlID, context, tplRoute).then(function (asset) {
+ return ajax.loadLibs(asset);
+ });
+ });
+ });
+
+ function _loadArray(array, loadCallback) {
+ var _mutex = new concurrency.Mutex();
+ array.forEach(function (urlData) {
+ _mutex.exec(function () {
+ if (typeof urlData === 'string') {
+ return loadCallback(urlData);
+ }
+ return Promise.all(urlData.map(loadCallback));
+ });
+ });
+ return _mutex.getUnlockedDef();
+ }
+
+ return mutex.getUnlockedDef();
+}
+
+_.extend(ajax, {
+ jsonRpc: jsonRpc,
+ rpc: rpc,
+ loadCSS: loadCSS,
+ loadJS: loadJS,
+ loadXML: loadXML,
+ loadAsset: loadAsset,
+ loadLibs: loadLibs,
+ get_file: get_file,
+ post: post,
+});
+
+return ajax;
+
+});
diff --git a/addons/web/static/src/js/core/browser_detection.js b/addons/web/static/src/js/core/browser_detection.js
new file mode 100644
index 00000000..37c6a35e
--- /dev/null
+++ b/addons/web/static/src/js/core/browser_detection.js
@@ -0,0 +1,20 @@
+odoo.define('web.BrowserDetection', function (require) {
+ "use strict";
+ var Class = require('web.Class');
+
+ var BrowserDetection = Class.extend({
+ init: function () {
+
+ },
+ isOsMac: function () {
+ return navigator.platform.toLowerCase().indexOf('mac') !== -1;
+ },
+ isBrowserChrome: function () {
+ return $.browser.chrome && // depends on jquery 1.x, removed in jquery 2 and above
+ navigator.userAgent.toLocaleLowerCase().indexOf('edge') === -1; // as far as jquery is concerned, Edge is chrome
+ }
+
+ });
+ return BrowserDetection;
+});
+
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 00000000..5257453e
--- /dev/null
+++ b/addons/web/static/src/js/core/bus.js
@@ -0,0 +1,19 @@
+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
+ *
+ * @class Bus
+ */
+return Class.extend(mixins.EventDispatcherMixin, {
+ init: function (parent) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(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 00000000..4d1e6baa
--- /dev/null
+++ b/addons/web/static/src/js/core/class.js
@@ -0,0 +1,155 @@
+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 instanced 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.
+ *
+ * @class Class
+ */
+function OdooClass(){}
+
+var initializing = false;
+var fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/;
+
+/**
+ * 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 00000000..bc27a902
--- /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 00000000..716abafa
--- /dev/null
+++ b/addons/web/static/src/js/core/concurrency.js
@@ -0,0 +1,323 @@
+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 (promise) 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 {
+ /**
+ * Returns a promise resolved after 'wait' milliseconds
+ *
+ * @param {int} [wait=0] the delay in ms
+ * @return {Promise}
+ */
+ delay: function (wait) {
+ return new Promise(function (resolve) {
+ setTimeout(resolve, wait);
+ });
+ },
+ /**
+ * 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 promise (usually an async request) to the sequencer
+ *
+ * @param {Promise} promise to ensure add
+ * @returns {Promise}
+ */
+ add: function (promise) {
+ var self = this;
+ var seq = this.lsn++;
+ var res = new Promise(function (resolve, reject) {
+ promise.then(function (result) {
+ if (seq > self.rsn) {
+ self.rsn = seq;
+ resolve(result);
+ } else if (self.failMisordered) {
+ reject();
+ }
+ }).guardedCatch(function (result) {
+ reject(result);
+ });
+ });
+ return res;
+ },
+ }),
+ /**
+ * 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 promise and rejects the previous one
+ *
+ * @param {Promise} promise the new promise
+ * @returns {Promise}
+ */
+ add: function (promise) {
+ if (this.currentDef) {
+ this.currentDef.reject();
+ }
+ var rejection;
+ var res = new Promise(function (resolve, reject) {
+ rejection = reject;
+ promise.then(resolve).catch(function (reason) {
+ reject(reason);
+ });
+ });
+
+ this.currentDef = res;
+ this.currentDef.reject = rejection;
+ return res;
+ }
+ }),
+ /**
+ * 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 promise 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.lock = Promise.resolve();
+ this.queueSize = 0;
+ this.unlockedProm = undefined;
+ this._unlock = undefined;
+ },
+ /**
+ * 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 Promise
+ * @returns {Promise}
+ */
+ exec: function (action) {
+ var self = this;
+ var currentLock = this.lock;
+ var result;
+ this.queueSize++;
+ this.unlockedProm = this.unlockedProm || new Promise(function (resolve) {
+ self._unlock = resolve;
+ });
+ this.lock = new Promise(function (unlockCurrent) {
+ currentLock.then(function () {
+ result = action();
+ var always = function (returnedResult) {
+ unlockCurrent();
+ self.queueSize--;
+ if (self.queueSize === 0) {
+ self.unlockedProm = undefined;
+ self._unlock();
+ }
+ return returnedResult;
+ };
+ Promise.resolve(result).then(always).guardedCatch(always);
+ });
+ });
+ return this.lock.then(function () {
+ return result;
+ });
+ },
+ /**
+ * @returns {Promise} resolved as soon as the Mutex is unlocked
+ * (directly if it is currently idle)
+ */
+ getUnlockedDef: function () {
+ return this.unlockedProm || Promise.resolve();
+ },
+ }),
+ /**
+ * A MutexedDropPrevious is a primitive for serializing computations while
+ * skipping the ones that where executed between a current one and before
+ * the execution of a new one. This is useful to avoid useless RPCs.
+ *
+ * You can read the Mutex description to understand its role ; for the
+ * DropPrevious part of this abstraction, imagine the following situation:
+ * you have a code that call the server with a fixed argument and a list of
+ * operations that only grows after each call and you only care about the
+ * RPC result (the server code doesn't do anything). If this code is called
+ * three times (A B C) and C is executed before B has started, it's useless
+ * to make an extra RPC (B) if you know that it won't have an impact and you
+ * won't use its result.
+ *
+ * Note that the promise returned by the exec call won't be resolved if
+ * exec is called before the first exec call resolution ; only the promise
+ * returned by the last exec call will be resolved (the other are rejected);
+ *
+ * A MutexedDropPrevious has to be a class, and not a function, because we
+ * have to keep track of some internal state. The exec function takes as
+ * argument an action (and not a promise as DropPrevious for example)
+ * because it's the MutexedDropPrevious role to trigger the RPC call that
+ * returns a promise when it's time.
+ */
+ MutexedDropPrevious: Class.extend({
+ init: function () {
+ this.locked = false;
+ this.currentProm = null;
+ this.pendingAction = null;
+ this.pendingProm = null;
+ },
+ /**
+ * @param {function} action a function which may return a promise
+ * @returns {Promise}
+ */
+ exec: function (action) {
+ var self = this;
+ var resolution;
+ var rejection;
+ if (this.locked) {
+ this.pendingAction = action;
+ var oldPendingDef = this.pendingProm;
+
+ this.pendingProm = new Promise(function (resolve, reject) {
+ resolution = resolve;
+ rejection = reject;
+ if (oldPendingDef) {
+ oldPendingDef.reject();
+ }
+ self.currentProm.reject();
+ });
+ this.pendingProm.resolve = resolution;
+ this.pendingProm.reject = rejection;
+ return this.pendingProm;
+ } else {
+ this.locked = true;
+ this.currentProm = new Promise(function (resolve, reject) {
+ resolution = resolve;
+ rejection = reject;
+ function unlock() {
+ self.locked = false;
+ if (self.pendingAction) {
+ var action = self.pendingAction;
+ var prom = self.pendingProm;
+ self.pendingAction = null;
+ self.pendingProm = null;
+ self.exec(action)
+ .then(prom.resolve)
+ .guardedCatch(prom.reject);
+ }
+ }
+ Promise.resolve(action())
+ .then(function (result) {
+ resolve(result);
+ unlock();
+ })
+ .guardedCatch(function (reason) {
+ reject(reason);
+ unlock();
+ });
+ });
+ this.currentProm.resolve = resolution;
+ this.currentProm.reject = rejection;
+ return this.currentProm;
+ }
+ }
+ }),
+ /**
+ * Rejects a promise as soon as a reference promise is either resolved or
+ * rejected
+ *
+ * @param {Promise} [target_def] the promise to potentially reject
+ * @param {Promise} [reference_def] the reference target
+ * @returns {Promise}
+ */
+ rejectAfter: function (target_def, reference_def) {
+ return new Promise(function (resolve, reject) {
+ target_def.then(resolve).guardedCatch(reject);
+ reference_def.then(reject).guardedCatch(reject);
+ });
+ }
+};
+
+});
diff --git a/addons/web/static/src/js/core/context.js b/addons/web/static/src/js/core/context.js
new file mode 100644
index 00000000..a7ce8017
--- /dev/null
+++ b/addons/web/static/src/js/core/context.js
@@ -0,0 +1,53 @@
+odoo.define('web.Context', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var pyUtils = require('web.py_utils');
+
+var Context = Class.extend({
+ init: function () {
+ this.__ref = "compound_context";
+ this.__contexts = [];
+ this.__eval_context = null;
+ var self = this;
+ _.each(arguments, function (x) {
+ self.add(x);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ add: function (context) {
+ this.__contexts.push(context);
+ return this;
+ },
+ eval: function () {
+ return pyUtils.eval('context', this);
+ },
+ /**
+ * Set the evaluation context to be used when we actually eval.
+ *
+ * @param {Object} evalContext
+ * @returns {Context}
+ */
+ set_eval_context: function (evalContext) {
+ // a special case needs to be done for moment objects. Dates are
+ // internally represented by a moment object, but they need to be
+ // converted to the server format before being sent. We call the toJSON
+ // method, because it returns the date with the format required by the
+ // server
+ for (var key in evalContext) {
+ if (evalContext[key] instanceof moment) {
+ evalContext[key] = evalContext[key].toJSON();
+ }
+ }
+ this.__eval_context = evalContext;
+ return this;
+ },
+});
+
+return Context;
+
+});
diff --git a/addons/web/static/src/js/core/custom_hooks.js b/addons/web/static/src/js/core/custom_hooks.js
new file mode 100644
index 00000000..883f385a
--- /dev/null
+++ b/addons/web/static/src/js/core/custom_hooks.js
@@ -0,0 +1,118 @@
+odoo.define('web.custom_hooks', function () {
+ "use strict";
+
+ const { Component, hooks } = owl;
+ const { onMounted, onPatched, onWillUnmount } = hooks;
+
+ /**
+ * Focus a given selector as soon as it appears in the DOM and if it was not
+ * displayed before. If the selected target is an input|textarea, set the selection
+ * at the end.
+ * @param {Object} [params]
+ * @param {string} [params.selector='autofocus'] default: select the first element
+ * with an `autofocus` attribute.
+ * @returns {Function} function that forces the focus on the next update if visible.
+ */
+ function useAutofocus(params = {}) {
+ const comp = Component.current;
+ // Prevent autofocus in mobile
+ if (comp.env.device.isMobile) {
+ return () => {};
+ }
+ const selector = params.selector || '[autofocus]';
+ let target = null;
+ function autofocus() {
+ const prevTarget = target;
+ target = comp.el.querySelector(selector);
+ if (target && target !== prevTarget) {
+ target.focus();
+ if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
+ target.selectionStart = target.selectionEnd = target.value.length;
+ }
+ }
+ }
+ onMounted(autofocus);
+ onPatched(autofocus);
+
+ return function focusOnUpdate() {
+ target = null;
+ };
+ }
+
+ /**
+ * The useListener hook offers an alternative to Owl's classical event
+ * registration mechanism (with attribute 't-on-eventName' in xml). It is
+ * especially useful for abstract components, meant to be extended by
+ * specific ones. If those abstract components need to define event handlers,
+ * but don't have any template (because the template completely depends on
+ * specific cases), then using the 't-on' mechanism isn't adequate, as the
+ * handlers would be lost by the template override. In this case, using this
+ * hook instead is more convenient.
+ *
+ * Example: navigation event handling in AbstractField
+ *
+ * Usage: like all Owl hooks, this function has to be called in the
+ * constructor of an Owl component:
+ *
+ * useListener('click', () => { console.log('clicked'); });
+ *
+ * An optional native query selector can be specified as second argument for
+ * event delegation. In this case, the handler is only called if the event
+ * is triggered on an element matching the given selector.
+ *
+ * useListener('click', 'button', () => { console.log('clicked'); });
+ *
+ * Note: components that alter the event's target (e.g. Portal) are not
+ * expected to behave as expected with event delegation.
+ *
+ * @param {string} eventName the name of the event
+ * @param {string} [querySelector] a JS native selector for event delegation
+ * @param {function} handler the event handler (will be bound to the component)
+ * @param {Object} [addEventListenerOptions] to be passed to addEventListener as options.
+ * Useful for listening in the capture phase
+ */
+ function useListener(eventName, querySelector, handler, addEventListenerOptions) {
+ if (typeof arguments[1] !== 'string') {
+ querySelector = null;
+ handler = arguments[1];
+ addEventListenerOptions = arguments[2];
+ }
+ if (typeof handler !== 'function') {
+ throw new Error('The handler must be a function');
+ }
+
+ const comp = Component.current;
+ let boundHandler;
+ if (querySelector) {
+ boundHandler = function (ev) {
+ let el = ev.target;
+ let target;
+ while (el && !target) {
+ if (el.matches(querySelector)) {
+ target = el;
+ } else if (el === comp.el) {
+ el = null;
+ } else {
+ el = el.parentElement;
+ }
+ }
+ if (el) {
+ handler.call(comp, ev);
+ }
+ };
+ } else {
+ boundHandler = handler.bind(comp);
+ }
+ onMounted(function () {
+ comp.el.addEventListener(eventName, boundHandler, addEventListenerOptions);
+ });
+ onWillUnmount(function () {
+ comp.el.removeEventListener(eventName, boundHandler, addEventListenerOptions);
+ });
+ }
+
+ return {
+ useAutofocus,
+ useListener,
+ };
+});
diff --git a/addons/web/static/src/js/core/data_comparison_utils.js b/addons/web/static/src/js/core/data_comparison_utils.js
new file mode 100644
index 00000000..ad848824
--- /dev/null
+++ b/addons/web/static/src/js/core/data_comparison_utils.js
@@ -0,0 +1,139 @@
+odoo.define('web.dataComparisonUtils', function (require) {
+"use strict";
+
+var fieldUtils = require('web.field_utils');
+var Class = require('web.Class');
+
+var DateClasses = Class.extend({
+ /**
+ * This small class offers a light API to manage equivalence classes of
+ * dates. Two dates in different dateSets are declared equivalent when
+ * their indexes are equal.
+ *
+ * @param {Array[]} dateSets, a list of list of dates
+ */
+ init: function (dateSets) {
+ // At least one dateSet must be non empty.
+ // The completion of the first inhabited dateSet will serve as a reference set.
+ // The reference set elements will be the default representatives for the classes.
+ this.dateSets = dateSets;
+ this.referenceIndex = null;
+ for (var i = 0; i < dateSets.length; i++) {
+ var dateSet = dateSets[i];
+ if (dateSet.length && this.referenceIndex === null) {
+ this.referenceIndex = i;
+ }
+ }
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Returns the index of a date in a given datesetIndex. This can be considered
+ * as the date class itself.
+ *
+ * @param {number} datesetIndex
+ * @param {string} date
+ * @return {number}
+ */
+ dateClass: function (datesetIndex, date) {
+ return this.dateSets[datesetIndex].indexOf(date);
+ },
+ /**
+ * returns the dates occuring in a given class
+ *
+ * @param {number} dateClass
+ * @return {string[]}
+ */
+ dateClassMembers: function (dateClass) {
+ return _.uniq(_.compact(this.dateSets.map(function (dateSet) {
+ return dateSet[dateClass];
+ })));
+ },
+ /**
+ * return the representative of a date class from a date set specified by an
+ * index.
+ *
+ * @param {number} dateClass
+ * @param {number} [index]
+ * @return {string}
+ */
+ representative: function (dateClass, index) {
+ index = index === undefined ? this.referenceIndex : index;
+ return this.dateSets[index][dateClass];
+ },
+});
+/**
+ * @param {Number} value
+ * @param {Number} comparisonValue
+ * @returns {Number}
+ */
+function computeVariation(value, comparisonValue) {
+ if (isNaN(value) || isNaN(comparisonValue)) {
+ return NaN;
+ }
+ if (comparisonValue === 0) {
+ if (value === 0) {
+ return 0;
+ } else if (value > 0) {
+ return 1;
+ } else {
+ return -1;
+ }
+ }
+ return (value - comparisonValue) / Math.abs(comparisonValue);
+}
+/**
+ * @param {Number} variation
+ * @param {Object} field
+ * @param {Object} options
+ * @returns {Object}
+ */
+function renderVariation(variation, field, options) {
+ var className = 'o_variation';
+ var value;
+ if (!isNaN(variation)) {
+ if (variation > 0) {
+ className += ' o_positive';
+ } else if (variation < 0) {
+ className += ' o_negative';
+ } else {
+ className += ' o_null';
+ }
+ value = fieldUtils.format.percentage(variation, field, options);
+ } else {
+ value = '-';
+ }
+ return $('<div>', {class: className, html: value});
+}
+/**
+ * @param {JQuery} $node
+ * @param {Number} value
+ * @param {Number} comparisonValue
+ * @param {Number} variation
+ * @param {function} formatter
+ * @param {Object} field
+ * @param {Object} options
+ * @returns {Object}
+ */
+function renderComparison($node, value, comparisonValue, variation, formatter, field, options) {
+ var $variation = renderVariation(variation, field, options);
+ $node.append($variation);
+ if (!isNaN(variation)) {
+ $node.append(
+ $('<div>', {class: 'o_comparison'})
+ .html(formatter(value, field, options) + ' <span>vs</span> ' + formatter(comparisonValue, field, options))
+ );
+ }
+}
+
+return {
+ computeVariation: computeVariation,
+ DateClasses: DateClasses,
+ renderComparison: renderComparison,
+ renderVariation: renderVariation,
+};
+
+});
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 00000000..8b2a8e1d
--- /dev/null
+++ b/addons/web/static/src/js/core/dialog.js
@@ -0,0 +1,494 @@
+odoo.define('web.Dialog', function (require) {
+"use strict";
+
+var core = require('web.core');
+var dom = require('web.dom');
+var Widget = require('web.Widget');
+const OwlDialog = require('web.OwlDialog');
+
+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({
+ tagName: 'main',
+ xmlDependencies: ['/web/static/src/xml/dialog.xml'],
+ custom_events: _.extend({}, Widget.prototype.custom_events, {
+ focus_control_button: '_onFocusControlButton',
+ close_dialog: '_onCloseDialog',
+ }),
+ events: _.extend({}, Widget.prototype.events, {
+ 'keydown .modal-footer button': '_onFooterButtonKeyDown',
+ }),
+ /**
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.title=Odoo]
+ * @param {string} [options.subtitle]
+ * @param {string} [options.size=large] - 'extra-large', 'large', 'medium'
+ * or 'small'
+ * @param {boolean} [options.fullscreen=false] - whether or not the dialog
+ * should be open in fullscreen mode (the main usecase is mobile)
+ * @param {string} [options.dialogClass] - class to add to the modal-body
+ * @param {jQuery} [options.$content]
+ * Element which will be the $el, replace the .modal-body and get the
+ * modal-body class
+ * @param {Object[]} [options.buttons]
+ * List of button descriptions. Note: if no buttons, a "ok" primary
+ * button is added to allow closing the dialog
+ * @param {string} [options.buttons[].text]
+ * @param {string} [options.buttons[].classes]
+ * Default to 'btn-primary' if only one button, 'btn-secondary'
+ * otherwise
+ * @param {boolean} [options.buttons[].close=false]
+ * @param {function} [options.buttons[].click]
+ * @param {boolean} [options.buttons[].disabled]
+ * @param {boolean} [options.technical=true]
+ * If set to false, the modal will have the standard frontend style
+ * (use this for non-editor frontend features)
+ * @param {jQueryElement} [options.$parentNode]
+ * Element in which dialog will be appended, by default it will be
+ * in the body
+ * @param {boolean|string} [options.backdrop='static']
+ * The kind of modal backdrop to use (see BS documentation)
+ * @param {boolean} [options.renderHeader=true]
+ * Whether or not the dialog should be rendered with header
+ * @param {boolean} [options.renderFooter=true]
+ * Whether or not the dialog should be rendered with footer
+ * @param {function} [options.onForceClose]
+ * Callback that triggers when the modal is closed by other means than with the buttons
+ * e.g. pressing ESC
+ */
+ init: function (parent, options) {
+ var self = this;
+ this._super(parent);
+ this._opened = new Promise(function (resolve) {
+ self._openedResolver = resolve;
+ });
+ if (this.on_attach_callback) {
+ this._opened = this.opened(this.on_attach_callback);
+ }
+ options = _.defaults(options || {}, {
+ title: _t('Odoo'), subtitle: '',
+ size: 'large',
+ fullscreen: false,
+ dialogClass: '',
+ $content: false,
+ buttons: [{text: _t("Ok"), close: true}],
+ technical: true,
+ $parentNode: false,
+ backdrop: 'static',
+ renderHeader: true,
+ renderFooter: true,
+ onForceClose: false,
+ });
+
+ this.$content = options.$content;
+ this.title = options.title;
+ this.subtitle = options.subtitle;
+ this.fullscreen = options.fullscreen;
+ this.dialogClass = options.dialogClass;
+ this.size = options.size;
+ this.buttons = options.buttons;
+ this.technical = options.technical;
+ this.$parentNode = options.$parentNode;
+ this.backdrop = options.backdrop;
+ this.renderHeader = options.renderHeader;
+ this.renderFooter = options.renderFooter;
+ this.onForceClose = options.onForceClose;
+
+ core.bus.on('close_dialogs', this, this.destroy.bind(this));
+ },
+ /**
+ * Wait for XML dependencies and instantiate the modal structure (except
+ * modal-body).
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ // Render modal once xml dependencies are loaded
+ self.$modal = $(QWeb.render('Dialog', {
+ fullscreen: self.fullscreen,
+ title: self.title,
+ subtitle: self.subtitle,
+ technical: self.technical,
+ renderHeader: self.renderHeader,
+ renderFooter: self.renderFooter,
+ }));
+ switch (self.size) {
+ case 'extra-large':
+ self.$modal.find('.modal-dialog').addClass('modal-xl');
+ break;
+ case 'large':
+ self.$modal.find('.modal-dialog').addClass('modal-lg');
+ break;
+ case 'small':
+ self.$modal.find('.modal-dialog').addClass('modal-sm');
+ break;
+ }
+ if (self.renderFooter) {
+ self.$footer = self.$modal.find(".modal-footer");
+ self.set_buttons(self.buttons);
+ }
+ self.$modal.on('hidden.bs.modal', _.bind(self.destroy, self));
+ });
+ },
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super();
+ // Note: ideally, the $el which is created/set here should use the
+ // 'main' tag, we cannot enforce this as it would require to re-create
+ // the whole element.
+ if (this.$content) {
+ this.setElement(this.$content);
+ }
+ this.$el.addClass('modal-body ' + this.dialogClass);
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @param {Object[]} buttons - @see init
+ */
+ set_buttons: function (buttons) {
+ this._setButtonsTo(this.$footer, buttons);
+ },
+
+ 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;
+ },
+
+ /**
+ * Show a dialog
+ *
+ * @param {Object} options
+ * @param {boolean} options.shouldFocusButtons if true, put the focus on
+ * the first button primary when the dialog opens
+ */
+ open: function (options) {
+ $('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened
+
+ var self = this;
+ this.appendTo($('<div/>')).then(function () {
+ if (self.isDestroyed()) {
+ return;
+ }
+ self.$modal.find(".modal-body").replaceWith(self.$el);
+ self.$modal.attr('open', true);
+ self.$modal.removeAttr("aria-hidden");
+ if (self.$parentNode) {
+ self.$modal.appendTo(self.$parentNode);
+ }
+ self.$modal.modal({
+ show: true,
+ backdrop: self.backdrop,
+ });
+ self._openedResolver();
+ if (options && options.shouldFocusButtons) {
+ self._onFocusControlButton();
+ }
+
+ // Notifies OwlDialog to adjust focus/active properties on owl dialogs
+ OwlDialog.display(self);
+ });
+
+ return self;
+ },
+
+ close: function () {
+ this.destroy();
+ },
+
+ /**
+ * Close and destroy the dialog.
+ *
+ * @param {Object} [options]
+ * @param {Object} [options.infos] if provided and `silent` is unset, the
+ * `on_close` handler will pass this information related to closing this
+ * information.
+ * @param {boolean} [options.silent=false] if set, do not call the
+ * `on_close` handler.
+ */
+ destroy: function (options) {
+ // Need to trigger before real destroy but if 'closed' handler destroys
+ // the widget again, we want to avoid infinite recursion
+ if (!this.__closed) {
+ this.__closed = true;
+ this.trigger('closed', options);
+ }
+
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ // Notifies OwlDialog to adjust focus/active properties on owl dialogs.
+ // Only has to be done if the dialog has been opened (has an el).
+ if (this.el) {
+ OwlDialog.hide(this);
+ }
+
+ // Triggers the onForceClose event if the callback is defined
+ if (this.onForceClose) {
+ this.onForceClose();
+ }
+ var isFocusSet = this._focusOnClose();
+
+ this._super();
+
+ $('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared
+ if (this.$modal) {
+ if (this.on_detach_callback) {
+ this.on_detach_callback();
+ }
+ this.$modal.modal('hide');
+ this.$modal.remove();
+ }
+
+ var modals = $('body > .modal').filter(':visible');
+ if (modals.length) {
+ if (!isFocusSet) {
+ modals.last().focus();
+ }
+ // Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal
+ $('body').addClass('modal-open');
+ }
+ },
+ /**
+ * adds the keydown behavior to the dialogs after external files modifies
+ * its DOM.
+ */
+ rebindButtonBehavior: function () {
+ this.$footer.on('keydown', this._onFooterButtonKeyDown);
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ /**
+ * Manages the focus when the dialog closes. The default behavior is to set the focus on the top-most opened popup.
+ * The goal of this function is to be overridden by all children of the dialog class.
+ *
+ * @returns: boolean should return true if the focus has already been set else false.
+ */
+ _focusOnClose: function() {
+ return false;
+ },
+ /**
+ * Render and set the given buttons into a target element
+ *
+ * @private
+ * @param {jQueryElement} $target The destination of the rendered buttons
+ * @param {Array} buttons The array of buttons to render
+ */
+ _setButtonsTo($target, buttons) {
+ var self = this;
+ $target.empty();
+ _.each(buttons, function (buttonData) {
+ var $button = dom.renderButton({
+ attrs: {
+ class: buttonData.classes || (buttons.length > 1 ? 'btn-secondary' : 'btn-primary'),
+ disabled: buttonData.disabled,
+ },
+ icon: buttonData.icon,
+ text: buttonData.text,
+ });
+ $button.on('click', function (e) {
+ var def;
+ if (buttonData.click) {
+ def = buttonData.click.call(self, e);
+ }
+ if (buttonData.close) {
+ self.onForceClose = false;
+ Promise.resolve(def).then(self.close.bind(self));
+ }
+ });
+ if (self.technical) {
+ $target.append($button);
+ } else {
+ $target.prepend($button);
+ }
+ });
+ },
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+ /**
+ * @private
+ */
+ _onCloseDialog: function (ev) {
+ ev.stopPropagation();
+ this.close();
+ },
+ /**
+ * Moves the focus to the first button primary in the footer of the dialog
+ *
+ * @private
+ * @param {odooEvent} e
+ */
+ _onFocusControlButton: function (e) {
+ if (this.$footer) {
+ if (e) {
+ e.stopPropagation();
+ }
+ this.$footer.find('.btn-primary:visible:first()').focus();
+ }
+ },
+ /**
+ * Manages the TAB key on the buttons. If you the focus is on a primary
+ * button and the users tries to tab to go to the next button, display
+ * a tooltip
+ *
+ * @param {jQueryEvent} e
+ * @private
+ */
+ _onFooterButtonKeyDown: function (e) {
+ switch(e.which) {
+ case $.ui.keyCode.TAB:
+ if (!e.shiftKey && e.target.classList.contains("btn-primary")) {
+ e.preventDefault();
+ var $primaryButton = $(e.target);
+ $primaryButton.tooltip({
+ delay: {show: 200, hide:0},
+ title: function(){
+ return QWeb.render('FormButton.tooltip',{title:$primaryButton.text().toUpperCase()});
+ },
+ trigger: 'manual',
+ });
+ $primaryButton.tooltip('show');
+ }
+ break;
+ }
+ }
+});
+
+// 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: $('<main/>', {
+ role: 'alert',
+ text: message,
+ }),
+ title: _t("Alert"),
+ onForceClose: options && (options.onForceClose || options.confirm_callback),
+ }, options)).open({shouldFocusButtons:true});
+};
+
+// 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: $('<main/>', {
+ role: 'alert',
+ text: message,
+ }),
+ title: _t("Confirmation"),
+ onForceClose: options && (options.onForceClose || options.cancel_callback),
+ }, options)).open({shouldFocusButtons:true});
+};
+
+/**
+ * Static method to open double confirmation dialog.
+ *
+ * @param {Widget} owner
+ * @param {string} message
+ * @param {Object} [options] @see Dialog.init @see Dialog.confirm
+ * @param {string} [options.securityLevel="warning"] - bootstrap color
+ * @param {string} [options.securityMessage="I am sure about this"]
+ * @returns {Dialog} (open() is automatically called)
+ */
+Dialog.safeConfirm = function (owner, message, options) {
+ var $checkbox = dom.renderCheckbox({
+ text: options && options.securityMessage || _t("I am sure about this."),
+ }).addClass('mb0');
+ var $securityCheck = $('<div/>', {
+ class: 'alert alert-' + (options && options.securityLevel || 'warning') + ' mt8 mb0',
+ }).prepend($checkbox);
+ var $content;
+ if (options && options.$content) {
+ $content = options.$content;
+ delete options.$content;
+ } else {
+ $content = $('<div>', {
+ text: message,
+ });
+ }
+ $content = $('<main/>', {role: 'alert'}).append($content, $securityCheck);
+
+ var buttons = [
+ {
+ text: _t("Ok"),
+ classes: 'btn-primary o_safe_confirm_button',
+ close: true,
+ click: options && options.confirm_callback,
+ disabled: true,
+ },
+ {
+ text: _t("Cancel"),
+ close: true,
+ click: options && options.cancel_callback
+ }
+ ];
+ var dialog = new Dialog(owner, _.extend({
+ size: 'medium',
+ buttons: buttons,
+ $content: $content,
+ title: _t("Confirmation"),
+ onForceClose: options && (options.onForceClose || options.cancel_callback),
+ }, options));
+ dialog.opened(function () {
+ var $button = dialog.$footer.find('.o_safe_confirm_button');
+ $securityCheck.on('click', 'input[type="checkbox"]', function (ev) {
+ $button.prop('disabled', !$(ev.currentTarget).prop('checked'));
+ });
+ });
+ return dialog.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 00000000..249a56ee
--- /dev/null
+++ b/addons/web/static/src/js/core/dom.js
@@ -0,0 +1,734 @@
+odoo.define('web.dom_ready', function (require) {
+'use strict';
+
+ return new Promise(function (resolve, reject) {
+ $(resolve);
+ });
+});
+//==============================================================================
+
+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 concurrency = require('web.concurrency');
+var config = require('web.config');
+var core = require('web.core');
+var _t = core._t;
+
+/**
+ * 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) {
+ callbacks.forEach(function (c) {
+ if (c.widget && c.widget.on_attach_callback) {
+ c.widget.on_attach_callback(c.callback_args);
+ }
+ });
+ core.bus.trigger('DOM_updated', content);
+}
+
+var dom = {
+ DEBOUNCE: 400,
+
+ /**
+ * 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);
+ }
+ },
+ /**
+ * Detects if 2 elements are colliding.
+ *
+ * @param {Element} el1
+ * @param {Element} el2
+ * @returns {boolean}
+ */
+ areColliding(el1, el2) {
+ const el1Rect = el1.getBoundingClientRect();
+ const el2Rect = el2.getBoundingClientRect();
+ return el1Rect.bottom > el2Rect.top
+ && el1Rect.top < el2Rect.bottom
+ && el1Rect.right > el2Rect.left
+ && el1Rect.left < el2Rect.right;
+ },
+ /**
+ * 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;
+ }
+
+ var $fixedTextarea;
+ var minHeight;
+
+ function resize() {
+ $fixedTextarea.insertAfter($textarea);
+ var heightOffset = 0;
+ var style = window.getComputedStyle($textarea[0], null);
+ if (style.boxSizing === 'border-box') {
+ var paddingHeight = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
+ var borderHeight = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
+ heightOffset = borderHeight + paddingHeight;
+ }
+ $fixedTextarea.width($textarea.width());
+ $fixedTextarea.val($textarea.val());
+ var height = $fixedTextarea[0].scrollHeight;
+ $textarea.css({height: Math.max(height + heightOffset, minHeight)});
+ }
+
+ function removeVerticalResize() {
+ // We already compute the correct height:
+ // we don't want the user to resize it vertically.
+ // On Chrome this needs to be called after the DOM is ready.
+ 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';
+ }
+ }
+
+ options = options || {};
+ minHeight = 'min_height' in options ? options.min_height : 50;
+
+ $fixedTextarea = $('<textarea disabled>', {
+ class: $textarea[0].className,
+ });
+
+ var direction = _t.database.parameters.direction === 'rtl' ? 'right' : 'left';
+ $fixedTextarea.css({
+ position: 'absolute',
+ opacity: 0,
+ height: 10,
+ borderTopWidth: 0,
+ borderBottomWidth: 0,
+ padding: 0,
+ overflow: 'hidden',
+ top: -10000,
+ }).css(direction, -10000);
+ $fixedTextarea.data("auto_resize", true);
+
+ // The following line is necessary to prevent the scrollbar to appear
+ // on the textarea on Firefox when adding a new line if the current line
+ // has just enough characters to completely fill the line.
+ // This fix should be fine since we compute the height depending on the
+ // content, there should never be an overflow.
+ // TODO ideally understand why and fix this another way if possible.
+ $textarea.css({'overflow-y': 'hidden'});
+
+ resize();
+ removeVerticalResize();
+ $textarea.data("auto_resize", true);
+
+ $textarea.on('input focus change', resize);
+ if (options.parent) {
+ core.bus.on('DOM_updated', options.parent, function () {
+ resize();
+ removeVerticalResize();
+ });
+ }
+ },
+ /**
+ * @return {HTMLElement}
+ */
+ closestScrollable(el) {
+ return $(el).closestScrollable()[0];
+ },
+ /**
+ * @param {HTMLElement} el
+ * @see $.compensateScrollbar
+ */
+ compensateScrollbar(el, ...rest) {
+ $(el).compensateScrollbar(...rest);
+ },
+ /**
+ * jQuery find function behavior is::
+ *
+ * $('A').find('A B') <=> $('A A B')
+ *
+ * The searches behavior to find options' DOM needs to be::
+ *
+ * $('A').find('A B') <=> $('A B')
+ *
+ * This is what this function does.
+ *
+ * @param {jQuery} $from - the jQuery element(s) from which to search
+ * @param {string} selector - the CSS selector to match
+ * @param {boolean} [addBack=false] - whether or not the $from element
+ * should be considered in the results
+ * @returns {jQuery}
+ */
+ cssFind: function ($from, selector, addBack) {
+ var $results;
+
+ // No way to correctly parse a complex jQuery selector but having no
+ // spaces should be a good-enough condition to use a simple find
+ var multiParts = selector.indexOf(' ') >= 0;
+ if (multiParts) {
+ $results = $from.find('*').filter(selector);
+ } else {
+ $results = $from.find(selector);
+ }
+
+ if (addBack && $from.is(selector)) {
+ $results = $results.add($from);
+ }
+
+ return $results;
+ },
+ /**
+ * 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) {
+ to_detach.forEach( 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 = $(to_detach.map(function (d) {
+ return d.widget.el;
+ }));
+ }
+ return $to_detach.detach();
+ },
+ /**
+ * Returns the selection range of an input or textarea
+ *
+ * @param {Object} node DOM item input or texteara
+ * @returns {Object} range
+ */
+ getSelectionRange: function (node) {
+ return {
+ start: node.selectionStart,
+ end: node.selectionEnd,
+ };
+ },
+ /**
+ * Returns the distance between a DOM element and the top-left corner of the
+ * window
+ *
+ * @param {Object} e DOM element (input or texteara)
+ * @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;
+ },
+ /**
+ * @returns {HTMLElement}
+ */
+ getScrollingElement() {
+ return $().getScrollingElement()[0];
+ },
+ /**
+ * @param {HTMLElement} el
+ * @returns {boolean}
+ */
+ hasScrollableContent(el) {
+ return $(el).hasScrollableContent();
+ },
+ /**
+ * @param {HTMLElement} el
+ * @returns {boolean}
+ */
+ isScrollable(el) {
+ return $(el).isScrollable();
+ },
+ /**
+ * Protects a function which is to be used as a handler by preventing its
+ * execution for the duration of a previous call to it (including async
+ * parts of that call).
+ *
+ * Limitation: as the handler is ignored during async actions,
+ * the 'preventDefault' or 'stopPropagation' calls it may want to do
+ * will be ignored too. Using the 'preventDefault' and 'stopPropagation'
+ * arguments solves that problem.
+ *
+ * @param {function} fct
+ * The function which is to be used as a handler. If a promise
+ * is returned, it is used to determine when the handler's action is
+ * finished. Otherwise, the return is used as jQuery uses it.
+ * @param {function|boolean} preventDefault
+ * @param {function|boolean} stopPropagation
+ */
+ makeAsyncHandler: function (fct, preventDefault, stopPropagation) {
+ var pending = false;
+ function _isLocked() {
+ return pending;
+ }
+ function _lock() {
+ pending = true;
+ }
+ function _unlock() {
+ pending = false;
+ }
+ return function (ev) {
+ if (preventDefault === true || preventDefault && preventDefault()) {
+ ev.preventDefault();
+ }
+ if (stopPropagation === true || stopPropagation && stopPropagation()) {
+ ev.stopPropagation();
+ }
+
+ if (_isLocked()) {
+ // If a previous call to this handler is still pending, ignore
+ // the new call.
+ return;
+ }
+
+ _lock();
+ var result = fct.apply(this, arguments);
+ Promise.resolve(result).then(_unlock).guardedCatch(_unlock);
+ return result;
+ };
+ },
+ /**
+ * Creates a debounced version of a function to be used as a button click
+ * handler. Also improves the handler to disable the button for the time of
+ * the debounce and/or the time of the async actions it performs.
+ *
+ * Limitation: if two handlers are put on the same button, the button will
+ * become enabled again once any handler's action finishes (multiple click
+ * handlers should however not be binded to the same button).
+ *
+ * @param {function} fct
+ * The function which is to be used as a button click handler. If a
+ * promise is returned, it is used to determine when the button can be
+ * re-enabled. Otherwise, the return is used as jQuery uses it.
+ */
+ makeButtonHandler: function (fct) {
+ // Fallback: if the final handler is not binded to a button, at least
+ // make it an async handler (also handles the case where some events
+ // might ignore the disabled state of the button).
+ fct = dom.makeAsyncHandler(fct);
+
+ return function (ev) {
+ var result = fct.apply(this, arguments);
+
+ var $button = $(ev.target).closest('.btn');
+ if (!$button.length) {
+ return result;
+ }
+
+ // Disable the button for the duration of the handler's action
+ // or at least for the duration of the click debounce. This makes
+ // a 'real' debounce creation useless. Also, during the debouncing
+ // part, the button is disabled without any visual effect.
+ $button.addClass('o_debounce_disabled');
+ Promise.resolve(dom.DEBOUNCE && concurrency.delay(dom.DEBOUNCE)).then(function () {
+ $button.removeClass('o_debounce_disabled');
+ const restore = dom.addButtonLoadingEffect($button[0]);
+ return Promise.resolve(result).then(restore).guardedCatch(restore);
+ });
+
+ return result;
+ };
+ },
+ /**
+ * Gives the button a loading effect by disabling it and adding a `fa`
+ * spinner icon.
+ * The existing button `fa` icons will be hidden through css.
+ *
+ * @param {HTMLElement} btn - the button to disable/load
+ * @return {function} a callback function that will restore the button
+ * initial state
+ */
+ addButtonLoadingEffect: function (btn) {
+ const $btn = $(btn);
+ $btn.addClass('o_website_btn_loading disabled');
+ $btn.prop('disabled', true);
+ const $loader = $('<span/>', {
+ class: 'fa fa-refresh fa-spin mr-2',
+ });
+ $btn.prepend($loader);
+ return () => {
+ $btn.removeClass('o_website_btn_loading disabled');
+ $btn.prop('disabled', false);
+ $loader.remove();
+ };
+ },
+ /**
+ * 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);
+ }
+ },
+ /**
+ * Renders a button with standard odoo template. This does not use any xml
+ * template to avoid forcing the frontend part to lazy load a xml file for
+ * each widget which might want to create a simple button.
+ *
+ * @param {Object} options
+ * @param {Object} [options.attrs] - Attributes to put on the button element
+ * @param {string} [options.attrs.type='button']
+ * @param {string} [options.attrs.class='btn-secondary']
+ * Note: automatically completed with "btn btn-X"
+ * (@see options.size for the value of X)
+ * @param {string} [options.size] - @see options.attrs.class
+ * @param {string} [options.icon]
+ * The specific fa icon class (for example "fa-home") or an URL for
+ * an image to use as icon.
+ * @param {string} [options.text] - the button's text
+ * @returns {jQuery}
+ */
+ renderButton: function (options) {
+ var jQueryParams = _.extend({
+ type: 'button',
+ }, options.attrs || {});
+
+ var extraClasses = jQueryParams.class;
+ if (extraClasses) {
+ // If we got extra classes, check if old oe_highlight/oe_link
+ // classes are given and switch them to the right classes (those
+ // classes have no style associated to them anymore).
+ // TODO ideally this should be dropped at some point.
+ extraClasses = extraClasses.replace(/\boe_highlight\b/g, 'btn-primary')
+ .replace(/\boe_link\b/g, 'btn-link');
+ }
+
+ jQueryParams.class = 'btn';
+ if (options.size) {
+ jQueryParams.class += (' btn-' + options.size);
+ }
+ jQueryParams.class += (' ' + (extraClasses || 'btn-secondary'));
+
+ var $button = $('<button/>', jQueryParams);
+
+ if (options.icon) {
+ if (options.icon.substr(0, 3) === 'fa-') {
+ $button.append($('<i/>', {
+ class: 'fa fa-fw o_button_icon ' + options.icon,
+ }));
+ } else {
+ $button.append($('<img/>', {
+ src: options.icon,
+ }));
+ }
+ }
+ if (options.text) {
+ $button.append($('<span/>', {
+ text: options.text,
+ }));
+ }
+
+ return $button;
+ },
+ /**
+ * Renders a checkbox with standard odoo/BS template. This does not use any
+ * xml template to avoid forcing the frontend part to lazy load a xml file
+ * for each widget which might want to create a simple checkbox.
+ *
+ * @param {Object} [options]
+ * @param {Object} [options.prop]
+ * Allows to set the input properties (disabled and checked states).
+ * @param {string} [options.text]
+ * The checkbox's associated text. If none is given then a simple
+ * checkbox is rendered.
+ * @returns {jQuery}
+ */
+ renderCheckbox: function (options) {
+ var id = _.uniqueId('checkbox-');
+ var $container = $('<div/>', {
+ class: 'custom-control custom-checkbox',
+ });
+ var $input = $('<input/>', {
+ type: 'checkbox',
+ id: id,
+ class: 'custom-control-input',
+ });
+ var $label = $('<label/>', {
+ for: id,
+ class: 'custom-control-label',
+ text: options && options.text || '',
+ });
+ if (!options || !options.text) {
+ $label.html('&#8203;'); // BS checkboxes need some label content (so
+ // add a zero-width space when there is no text)
+ }
+ if (options && options.prop) {
+ $input.prop(options.prop);
+ }
+ if (options && options.role) {
+ $input.attr('role', options.role);
+ }
+ return $container.append($input, $label);
+ },
+ /**
+ * Sets the selection range of a given input or textarea
+ *
+ * @param {Object} node DOM element (input or textarea)
+ * @param {integer} range.start
+ * @param {integer} range.end
+ */
+ setSelectionRange: function (node, range) {
+ if (node.setSelectionRange){
+ node.setSelectionRange(range.start, range.end);
+ } else if (node.createTextRange){
+ node.createTextRange()
+ .collapse(true)
+ .moveEnd('character', range.start)
+ .moveStart('character', range.end)
+ .select();
+ }
+ },
+ /**
+ * Computes the size by which a scrolling point should be decreased so that
+ * the top fixed elements of the page appear above that scrolling point.
+ *
+ * @returns {number}
+ */
+ scrollFixedOffset() {
+ let size = 0;
+ for (const el of $('.o_top_fixed_element')) {
+ size += $(el).outerHeight();
+ }
+ return size;
+ },
+ /**
+ * @param {HTMLElement} el - the element to stroll to
+ * @param {number} [options] - same as animate of jQuery
+ * @param {number} [options.extraOffset=0]
+ * extra offset to add on top of the automatic one (the automatic one
+ * being computed based on fixed header sizes)
+ * @param {number} [options.forcedOffset]
+ * offset used instead of the automatic one (extraOffset will be
+ * ignored too)
+ * @return {Promise}
+ */
+ scrollTo(el, options = {}) {
+ const $el = $(el);
+ const $scrollable = $el.parent().closestScrollable();
+ const $topLevelScrollable = $().getScrollingElement();
+ const isTopScroll = $scrollable.is($topLevelScrollable);
+
+ function _computeScrollTop() {
+ let offsetTop = $el.offset().top;
+ if (el.classList.contains('d-none')) {
+ el.classList.remove('d-none');
+ offsetTop = $el.offset().top;
+ el.classList.add('d-none');
+ }
+ const elPosition = $scrollable[0].scrollTop + (offsetTop - $scrollable.offset().top);
+ let offset = options.forcedOffset;
+ if (offset === undefined) {
+ offset = (isTopScroll ? dom.scrollFixedOffset() : 0) + (options.extraOffset || 0);
+ }
+ return Math.max(0, elPosition - offset);
+ }
+
+ const originalScrollTop = _computeScrollTop();
+
+ return new Promise(resolve => {
+ const clonedOptions = Object.assign({}, options);
+
+ // During the animation, detect any change needed for the scroll
+ // offset. If any occurs, stop the animation and continuing it to
+ // the new scroll point for the remaining time.
+ // Note: limitation, the animation won't be as fluid as possible if
+ // the easing mode is different of 'linear'.
+ clonedOptions.progress = function (a, b, remainingMs) {
+ if (options.progress) {
+ options.progress.apply(this, ...arguments);
+ }
+ const newScrollTop = _computeScrollTop();
+ if (Math.abs(newScrollTop - originalScrollTop) <= 1.0) {
+ return;
+ }
+ $scrollable.stop();
+ dom.scrollTo(el, Object.assign({}, options, {
+ duration: remainingMs,
+ })).then(() => resolve());
+ };
+
+ // Detect the end of the animation to be able to indicate it to
+ // the caller via the returned Promise.
+ clonedOptions.complete = function () {
+ if (options.complete) {
+ options.complete.apply(this, ...arguments);
+ }
+ resolve();
+ };
+
+ $scrollable.animate({scrollTop: originalScrollTop}, clonedOptions);
+ });
+ },
+ /**
+ * Creates an automatic 'more' dropdown-menu for a set of navbar items.
+ *
+ * @param {jQuery} $el
+ * @param {Object} [options]
+ * @param {string} [options.unfoldable='none']
+ * @param {function} [options.maxWidth]
+ * @param {string} [options.sizeClass='SM']
+ */
+ initAutoMoreMenu: function ($el, options) {
+ options = _.extend({
+ unfoldable: 'none',
+ maxWidth: false,
+ sizeClass: 'SM',
+ }, options || {});
+
+ var autoMarginLeftRegex = /\bm[lx]?(?:-(?:sm|md|lg|xl))?-auto\b/;
+ var autoMarginRightRegex = /\bm[rx]?(?:-(?:sm|md|lg|xl))?-auto\b/;
+
+ var $extraItemsToggle = null;
+
+ var debouncedAdapt = _.debounce(_adapt, 250);
+ core.bus.on('resize', null, debouncedAdapt);
+ _adapt();
+
+ $el.data('dom:autoMoreMenu:adapt', _adapt);
+ $el.data('dom:autoMoreMenu:destroy', function () {
+ _restore();
+ core.bus.off('resize', null, debouncedAdapt);
+ $el.removeData(['dom:autoMoreMenu:destroy', 'dom:autoMoreMenu:adapt']);
+ });
+
+ function _restore() {
+ if ($extraItemsToggle === null) {
+ return;
+ }
+ var $items = $extraItemsToggle.children('.dropdown-menu').children();
+ $items.addClass('nav-item');
+ $items.children('.dropdown-item, a').removeClass('dropdown-item').addClass('nav-link');
+ $items.insertBefore($extraItemsToggle);
+ $extraItemsToggle.remove();
+ $extraItemsToggle = null;
+ }
+
+ function _adapt() {
+ _restore();
+
+ if (!$el.is(':visible') || $el.closest('.show').length) {
+ // Never transform the menu when it is not visible yet or if
+ // it is a toggleable one.
+ return;
+ }
+ if (config.device.size_class <= config.device.SIZES[options.sizeClass]) {
+ return;
+ }
+
+ var $allItems = $el.children();
+ var $unfoldableItems = $allItems.filter(options.unfoldable);
+ var $items = $allItems.not($unfoldableItems);
+
+ var maxWidth = 0;
+ if (options.maxWidth) {
+ maxWidth = options.maxWidth();
+ } else {
+ maxWidth = computeFloatOuterWidthWithMargins($el[0], true, true, true);
+ var style = window.getComputedStyle($el[0]);
+ maxWidth -= (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth));
+ maxWidth -= _.reduce($unfoldableItems, function (sum, el) {
+ return sum + computeFloatOuterWidthWithMargins(el, true, true, false);
+ }, 0);
+ }
+
+ var nbItems = $items.length;
+ var menuItemsWidth = _.reduce($items, function (sum, el) {
+ return sum + computeFloatOuterWidthWithMargins(el, true, true, false);
+ }, 0);
+
+ if (maxWidth - menuItemsWidth >= -0.001) {
+ return;
+ }
+
+ var $dropdownMenu = $('<ul/>', {class: 'dropdown-menu'});
+ $extraItemsToggle = $('<li/>', {class: 'nav-item dropdown o_extra_menu_items'})
+ .append($('<a/>', {role: 'button', href: '#', class: 'nav-link dropdown-toggle o-no-caret', 'data-toggle': 'dropdown', 'aria-expanded': false})
+ .append($('<i/>', {class: 'fa fa-plus'})))
+ .append($dropdownMenu);
+ $extraItemsToggle.insertAfter($items.last());
+
+ menuItemsWidth += computeFloatOuterWidthWithMargins($extraItemsToggle[0], true, true, false);
+ do {
+ menuItemsWidth -= computeFloatOuterWidthWithMargins($items.eq(--nbItems)[0], true, true, false);
+ } while (!(maxWidth - menuItemsWidth >= -0.001) && (nbItems > 0));
+
+ var $extraItems = $items.slice(nbItems).detach();
+ $extraItems.removeClass('nav-item');
+ $extraItems.children('.nav-link, a').removeClass('nav-link').addClass('dropdown-item');
+ $dropdownMenu.append($extraItems);
+ $extraItemsToggle.find('.nav-link').toggleClass('active', $extraItems.children().hasClass('active'));
+ }
+
+ function computeFloatOuterWidthWithMargins(el, mLeft, mRight, considerAutoMargins) {
+ var rect = el.getBoundingClientRect();
+ var style = window.getComputedStyle(el);
+ var outerWidth = rect.right - rect.left;
+ if (mLeft !== false && (considerAutoMargins || !autoMarginLeftRegex.test(el.getAttribute('class')))) {
+ outerWidth += parseFloat(style.marginLeft);
+ }
+ if (mRight !== false && (considerAutoMargins || !autoMarginRightRegex.test(el.getAttribute('class')))) {
+ outerWidth += parseFloat(style.marginRight);
+ }
+ // Would be NaN for invisible elements for example
+ return isNaN(outerWidth) ? 0 : outerWidth;
+ }
+ },
+ /**
+ * Cleans what has been done by ``initAutoMoreMenu``.
+ *
+ * @param {jQuery} $el
+ */
+ destroyAutoMoreMenu: function ($el) {
+ var destroyFunc = $el.data('dom:autoMoreMenu:destroy');
+ if (destroyFunc) {
+ destroyFunc.call(null);
+ }
+ },
+};
+return dom;
+});
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 00000000..a1d6e7e7
--- /dev/null
+++ b/addons/web/static/src/js/core/domain.js
@@ -0,0 +1,433 @@
+odoo.define("web.Domain", function (require) {
+"use strict";
+
+var collections = require("web.collections");
+var pyUtils = require("web.py_utils");
+var py = window.py; // look py.js
+
+const TRUE_LEAF = [1, '=', 1];
+const FALSE_LEAF = [0, '=', 1];
+const TRUE_DOMAIN = [TRUE_LEAF];
+const FALSE_DOMAIN = [FALSE_LEAF];
+
+function compare(a, b) {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+/**
+ * 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.
+ * * a number, considered as true except 0 considered as false.
+ * @param {Object} [evalContext] - in case the given domain is a string, an
+ * evaluation context might be needed
+ */
+ init: function (domain, evalContext) {
+ this._super.apply(this, arguments);
+ if (_.isArray(domain) || _.isString(domain)) {
+ this._parse(this.normalizeArray(_.clone(this.stringToArray(domain, evalContext))));
+ } else {
+ this._data = !!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
+ // and if the first part of the domain contains 'parent.field'
+ // get the value from the parent record.
+ var isParentField = false;
+ var fieldName = this._data[0];
+ // We split the domain first part and check if it's a match
+ // for the syntax 'parent.field'.
+
+ let fieldValue;
+ if (compare(this._data, FALSE_LEAF) || compare(this._data, TRUE_LEAF)) {
+ fieldValue = this._data[0];
+ } else {
+ var parentField = this._data[0].split('.');
+ if ('parent' in values && parentField.length === 2) {
+ fieldName = parentField[1];
+ isParentField = parentField[0] === 'parent' &&
+ fieldName in values.parent;
+ }
+ if (!(this._data[0] in values) && !(isParentField)) {
+ throw new Error(_.str.sprintf(
+ "Unknown field %s in domain",
+ this._data[0]
+ ));
+ }
+ fieldValue = isParentField ? values.parent[fieldName] : values[fieldName];
+ }
+
+ 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 _.intersection(
+ _.isArray(this._data[2]) ? this._data[2] : [this._data[2]],
+ _.isArray(fieldValue) ? fieldValue : [fieldValue],
+ ).length !== 0;
+ case "not in":
+ return _.intersection(
+ _.isArray(this._data[2]) ? this._data[2] : [this._data[2]],
+ _.isArray(fieldValue) ? fieldValue : [fieldValue],
+ ).length === 0;
+ case "like":
+ if (fieldValue === false) {
+ return false;
+ }
+ return (fieldValue.indexOf(this._data[2]) >= 0);
+ case "=like":
+ if (fieldValue === false) {
+ return false;
+ }
+ return new RegExp(this._data[2].replace(/%/g, '.*')).test(fieldValue);
+ case "ilike":
+ if (fieldValue === false) {
+ return false;
+ }
+ return (fieldValue.toLowerCase().indexOf(this._data[2].toLowerCase()) >= 0);
+ case "=ilike":
+ if (fieldValue === false) {
+ return false;
+ }
+ return new RegExp(this._data[2].replace(/%/g, '.*'), 'i').test(fieldValue);
+ 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);
+ }
+ }
+ },
+ /**
+ * Return the JS prefix-array representation of this domain. Note that all
+ * domains that use the "false" domain cannot be represented as such.
+ *
+ * @returns {Array} JS prefix-array representation of this domain
+ */
+ toArray: function () {
+ if (this._data === false) {
+ throw new Error("'false' domain cannot be converted to array");
+ } else if (this._data === true) {
+ return [];
+ } else {
+ var arr = [this._data];
+ return arr.concat.apply(arr, _.map(this._children, function (child) {
+ return child.toArray();
+ }));
+ }
+ },
+ /**
+ * @returns {string} representation of the Python prefix-array
+ * representation of the domain
+ */
+ toString: function () {
+ return Domain.prototype.arrayToString(this.toArray());
+ },
+
+ //--------------------------------------------------------------------------
+ // 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|undefined} domain
+ * @returns {string}
+ */
+ arrayToString: function (domain) {
+ if (_.isString(domain)) return domain;
+ const parts = (domain || []).map(part => {
+ if (_.isArray(part)) { // e.g. ['name', 'ilike', 'foo'] or ['is_active', '=', true]
+ return "[" + part.map(c => {
+ switch (c) {
+ case null: return "None";
+ case true: return "True";
+ case false: return "False";
+ default: return JSON.stringify(c);
+ }
+ }).join(',') + "]";
+ } else { // e.g. '|' or '&'
+ return JSON.stringify(part);
+ }
+ });
+ return "[" + parts.join(',') + "]";
+ },
+ /**
+ * 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
+ * @param {Object} [evalContext]
+ * @returns {Array}
+ */
+ stringToArray: function (domain, evalContext) {
+ if (!_.isString(domain)) return _.clone(domain);
+ return pyUtils.eval("domain", domain ? domain.replace(/%%/g, '%') : "[]", evalContext);
+ },
+ /**
+ * 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
+ * @throws {Error} if the domain is invalid and can't be normalised
+ */
+ normalizeArray: function (domain) {
+ if (domain.length === 0) { return 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("&")));
+ } else if (expected > 0) {
+ throw new Error(_.str.sprintf(
+ "invalid domain %s (missing %d segment(s))",
+ JSON.stringify(domain), expected
+ ));
+ }
+ return domain;
+ },
+ /**
+ * Converts JS prefix-array representation of a domain to a python condition
+ *
+ * @static
+ * @param {Array} domain
+ * @returns {string}
+ */
+ domainToCondition: function (domain) {
+ if (!domain.length) {
+ return 'True';
+ }
+ var self = this;
+ function consume(stack) {
+ var len = stack.length;
+ if (len <= 1) {
+ return stack;
+ } else if (stack[len-1] === '|' || stack[len-1] === '&' || stack[len-2] === '|' || stack[len-2] === '&') {
+ return stack;
+ } else if (len == 2) {
+ stack.splice(-2, 2, stack[len-2] + ' and ' + stack[len-1]);
+ } else if (stack[len-3] == '|') {
+ if (len === 3) {
+ stack.splice(-3, 3, stack[len-2] + ' or ' + stack[len-1]);
+ } else {
+ stack.splice(-3, 3, '(' + stack[len-2] + ' or ' + stack[len-1] + ')');
+ }
+ } else {
+ stack.splice(-3, 3, stack[len-2] + ' and ' + stack[len-1]);
+ }
+ consume(stack);
+ }
+
+ var stack = [];
+ _.each(domain, function (dom) {
+ if (dom === '|' || dom === '&') {
+ stack.push(dom);
+ } else {
+ var operator = dom[1] === '=' ? '==' : dom[1];
+ if (!operator) {
+ throw new Error('Wrong operator for this domain');
+ }
+ if (operator === '!=' && dom[2] === false) { // the field is set
+ stack.push(dom[0]);
+ } else if (dom[2] === null || dom[2] === true || dom[2] === false) {
+ stack.push(dom[0] + ' ' + (operator === '!=' ? 'is not ' : 'is ') + (dom[2] === null ? 'None' : (dom[2] ? 'True' : 'False')));
+ } else {
+ stack.push(dom[0] + ' ' + operator + ' ' + JSON.stringify(dom[2]));
+ }
+ consume(stack);
+ }
+ });
+
+ if (stack.length !== 1) {
+ throw new Error('Wrong domain');
+ }
+
+ return stack[0];
+ },
+ /**
+ * Converts python condition to a JS prefix-array representation of a domain
+ *
+ * @static
+ * @param {string} condition
+ * @returns {Array}
+ */
+ conditionToDomain: function (condition) {
+ if (!condition || condition.match(/^\s*(True)?\s*$/)) {
+ return [];
+ }
+
+ var ast = py.parse(py.tokenize(condition));
+
+
+ function astToStackValue (node) {
+ switch (node.id) {
+ case '(name)': return node.value;
+ case '.': return astToStackValue(node.first) + '.' + astToStackValue(node.second);
+ case '(string)': return node.value;
+ case '(number)': return node.value;
+ case '(constant)': return node.value === 'None' ? null : node.value === 'True' ? true : false;
+ case '[': return _.map(node.first, function (node) {return astToStackValue(node);});
+ }
+ }
+ function astToStack (node) {
+ switch (node.id) {
+ case '(name)': return [[astToStackValue(node), '!=', false]];
+ case '.': return [[astToStackValue(node.first) + '.' + astToStackValue(node.second), '!=', false]];
+ case 'not': return [[astToStackValue(node.first), '=', false]];
+
+ case 'or': return ['|'].concat(astToStack(node.first)).concat(astToStack(node.second));
+ case 'and': return ['&'].concat(astToStack(node.first)).concat(astToStack(node.second));
+ case '(comparator)':
+ if (node.operators.length !== 1) {
+ throw new Error('Wrong condition to convert in domain');
+ }
+ var right = astToStackValue(node.expressions[0]);
+ var left = astToStackValue(node.expressions[1]);
+ var operator = node.operators[0];
+ switch (operator) {
+ case 'is': operator = '='; break;
+ case 'is not': operator = '!='; break;
+ case '==': operator = '='; break;
+ }
+ return [[right, operator, left]];
+ default:
+ throw "Condition cannot be transformed into domain";
+ }
+ }
+
+ return astToStack(ast);
+ },
+});
+
+Domain.TRUE_LEAF = TRUE_LEAF;
+Domain.FALSE_LEAF = FALSE_LEAF;
+Domain.TRUE_DOMAIN = TRUE_DOMAIN;
+Domain.FALSE_DOMAIN = FALSE_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 00000000..ecc0cb7b
--- /dev/null
+++ b/addons/web/static/src/js/core/local_storage.js
@@ -0,0 +1,54 @@
+odoo.define('web.local_storage', function (require) {
+'use strict';
+
+var RamStorage = require('web.RamStorage');
+var mixins = require('web.mixins');
+
+// use a fake localStorage in RAM if the native localStorage is unavailable
+// (e.g. private browsing in Safari)
+var storage;
+var localStorage = window.localStorage;
+try {
+ var uid = new Date();
+ localStorage.setItem(uid, uid);
+ localStorage.removeItem(uid);
+
+ /*
+ * We create an intermediate object in order to triggered the storage on
+ * this object. the localStorage. This simplifies testing and usage as
+ * starages are commutable in services without change. Also, objects
+ * that use storage do not have to know that events go through window,
+ * it's not up to them to handle these cases.
+ */
+ storage = (function () {
+ var storage = Object.create(_.extend({
+ getItem: localStorage.getItem.bind(localStorage),
+ setItem: localStorage.setItem.bind(localStorage),
+ removeItem: localStorage.removeItem.bind(localStorage),
+ clear: localStorage.clear.bind(localStorage),
+ },
+ mixins.EventDispatcherMixin
+ ));
+ storage.init();
+ $(window).on('storage', function (e) {
+ var key = e.originalEvent.key;
+ var newValue = e.originalEvent.newValue;
+ try {
+ JSON.parse(newValue);
+ storage.trigger('storage', {
+ key: key,
+ newValue: newValue,
+ });
+ } catch (error) {}
+ });
+ return storage;
+ })();
+
+} catch (exception) {
+ console.warn('Fail to load localStorage');
+ storage = new RamStorage();
+}
+
+return storage;
+
+});
diff --git a/addons/web/static/src/js/core/math_utils.js b/addons/web/static/src/js/core/math_utils.js
new file mode 100644
index 00000000..6f560624
--- /dev/null
+++ b/addons/web/static/src/js/core/math_utils.js
@@ -0,0 +1,73 @@
+odoo.define('web.mathUtils', function () {
+"use strict";
+
+/**
+ * Same values returned as those returned by cartesian function for case n = 0
+ * and n > 1. For n = 1, brackets are put around the unique parameter elements.
+ *
+ * @returns {Array}
+ */
+function _cartesian() {
+ var args = Array.prototype.slice.call(arguments);
+ if (args.length === 0) {
+ return [undefined];
+ }
+ var firstArray = args[0].map(function (elem) {
+ return [elem];
+ });
+ if (args.length === 1) {
+ return firstArray;
+ }
+ var productOfOtherArrays = _cartesian.apply(null, args.slice(1));
+ var result = firstArray.reduce(
+ function (acc, elem) {
+ return acc.concat(productOfOtherArrays.map(function (tuple) {
+ return elem.concat(tuple);
+ }));
+ },
+ []
+ );
+ return result;
+}
+
+/**
+ * Returns the product of any number n of arrays.
+ * The internal structures of their elements is preserved.
+ * For n = 1, no brackets are put around the unique parameter elements
+ * For n = 0, [undefined] is returned since it is the unit
+ * of the cartesian product (up to isomorphism).
+ *
+ * @returns {Array}
+ */
+function cartesian() {
+ var args = Array.prototype.slice.call(arguments);
+ if (args.length === 0) {
+ return [undefined];
+ } else if (args.length === 1) {
+ return args[0];
+ } else {
+ return _cartesian.apply(null, args);
+ }
+}
+
+/**
+ * Returns all initial sections of a given array, e.g. for [1, 2] the array
+ * [[], [1], [1, 2]] is returned.
+ *
+ * @param {Array} array
+ * @returns {Array[]}
+ */
+function sections(array) {
+ var sections = [];
+ for (var i = 0; i < array.length + 1; i++) {
+ sections.push(array.slice(0, i));
+ }
+ return sections;
+}
+
+return {
+ cartesian: cartesian,
+ sections: sections,
+};
+
+});
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 00000000..24e9831f
--- /dev/null
+++ b/addons/web/static/src/js/core/misc.js
@@ -0,0 +1,236 @@
+odoo.define('web.framework', function (require) {
+"use strict";
+
+var core = require('web.core');
+var ajax = require('web.ajax');
+var Widget = require('web.Widget');
+var disableCrashManager = require('web.CrashManager').disable;
+const {sprintf} = require('web.utils')
+
+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"] = '';
+}
+
+
+/**
+ * Remove the "accesskey" attributes to avoid the use of the access keys
+ * while the blockUI is enable.
+ */
+
+function blockAccessKeys() {
+ var elementWithAccessKey = [];
+ elementWithAccessKey = document.querySelectorAll('[accesskey]');
+ _.each(elementWithAccessKey, function (elem) {
+ elem.setAttribute("data-accesskey",elem.getAttribute('accesskey'));
+ elem.removeAttribute('accesskey');
+ });
+}
+
+function unblockAccessKeys() {
+ var elementWithDataAccessKey = [];
+ elementWithDataAccessKey = document.querySelectorAll('[data-accesskey]');
+ _.each(elementWithDataAccessKey, function (elem) {
+ elem.setAttribute('accesskey', elem.getAttribute('data-accesskey'));
+ elem.removeAttribute('data-accesskey');
+ });
+}
+
+var throbbers = [];
+
+function blockUI() {
+ var tmp = $.blockUI.apply($, arguments);
+ var throbber = new Throbber();
+ throbbers.push(throbber);
+ throbber.appendTo($(".oe_blockui_spin_container"));
+ $(document.body).addClass('o_ui_blocked');
+ blockAccessKeys();
+ return tmp;
+}
+
+function unblockUI() {
+ _.invoke(throbbers, 'destroy');
+ throbbers = [];
+ $(document.body).removeClass('o_ui_blocked');
+ unblockAccessKeys();
+ 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
+ disableCrashManager();
+
+ 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", {}).then(load).guardedCatch(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;
+
+ // Clear cache
+ core.bus.trigger('clear_cache');
+
+ 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);
+
+function login() {
+ redirect('/web/login');
+}
+core.action_registry.add("login", login);
+
+function logout() {
+ redirect('/web/session/logout');
+ return new Promise();
+}
+core.action_registry.add("logout", logout);
+
+/**
+ * @param {ActionManager} parent
+ * @param {Object} action
+ * @param {Object} action.params notification params
+ * @see ServiceMixin.displayNotification
+ */
+function displayNotification(parent, action) {
+ let {title='', message='', links=[], type='info', sticky=false, next} = action.params || {};
+ links = links.map(({url, label}) => `<a href="${_.escape(url)}" target="_blank">${_.escape(label)}</a>`)
+ parent.displayNotification({
+ title: _.escape(title),
+ message: sprintf(_.escape(message), ...links),
+ type,
+ sticky
+ });
+ return next;
+}
+core.action_registry.add("display_notification", displayNotification);
+
+/**
+ * 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);
+
+// In Internet Explorer, document doesn't have the contains method, so we make a
+// polyfill for the method in order to be compatible.
+if (!document.contains) {
+ document.contains = function contains (node) {
+ if (!(0 in arguments)) {
+ throw new TypeError('1 argument is required');
+ }
+
+ do {
+ if (this === node) {
+ return true;
+ }
+ } while (node = node && node.parentNode);
+
+ return false;
+ };
+}
+
+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 00000000..06f39e0f
--- /dev/null
+++ b/addons/web/static/src/js/core/mixins.js
@@ -0,0 +1,418 @@
+odoo.define('web.mixins', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var utils = require('web.utils');
+
+/**
+ * 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.
+ *
+ * @name ParentedMixin
+ * @mixin
+ */
+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 {Promise} promise The promise representing the asynchronous
+ * action.
+ * @param {bool} [shouldReject=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 {Promise} 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, shouldReject) {
+ var self = this;
+
+ return new Promise(function (resolve, reject) {
+ promise.then(function (result) {
+ if (!self.isDestroyed()) {
+ resolve(result);
+ } else if (shouldReject) {
+ reject();
+ }
+ }).guardedCatch(function (reason) {
+ if (!self.isDestroyed()) {
+ reject(reason);
+ } else if (shouldReject) {
+ reject();
+ }
+ });
+ });
+ },
+ /**
+ * Inform the object it should destroy itself, releasing any
+ * resource it could have reserved.
+ */
+ destroy : function () {
+ this.getChildren().forEach(function (child) {
+ child.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).
+ *
+ * @name EventDispatcherMixin
+ * @mixin
+ */
+var EventDispatcherMixin = _.extend({}, ParentedMixin, {
+ __eventDispatcherMixin: true,
+ custom_events: {},
+ init: function () {
+ ParentedMixin.init.call(this);
+ this.__edispatcherEvents = new Events();
+ this.__edispatcherRegisteredEvents = [];
+ this._delegateCustomEvents();
+ },
+ /**
+ * 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);
+ };
+ },
+ _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);
+ }
+ },
+ 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);
+ //console.info('event: ', name, info);
+ this._trigger_up(event);
+ return 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);
+ }
+});
+
+/**
+ * @name PropertiesMixin
+ * @mixin
+ */
+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];
+ }
+});
+
+return {
+ ParentedMixin: ParentedMixin,
+ EventDispatcherMixin: EventDispatcherMixin,
+ PropertiesMixin: PropertiesMixin,
+};
+
+});
diff --git a/addons/web/static/src/js/core/mvc.js b/addons/web/static/src/js/core/mvc.js
new file mode 100644
index 00000000..23f2b44b
--- /dev/null
+++ b/addons/web/static/src/js/core/mvc.js
@@ -0,0 +1,250 @@
+odoo.define('web.mvc', function (require) {
+"use strict";
+
+/**
+ * This file contains a 'formalization' of a MVC pattern, applied to Odoo
+ * idioms.
+ *
+ * For a simple widget/component, this is definitely overkill. However, when
+ * working on complex systems, such as Odoo views (or the control panel, or some
+ * client actions), it is useful to clearly separate the code in concerns.
+ *
+ * We define here 4 classes: Factory, Model, Renderer, Controller. Note that
+ * for various historical reasons, we use the term Renderer instead of View. The
+ * main issue is that the term 'View' is used way too much in Odoo land, and
+ * adding it would increase the confusion.
+ *
+ * In short, here are the responsabilities of the four classes:
+ * - Model: this is where the main state of the system lives. This is the part
+ * that will talk to the server, process the results and is the owner of the
+ * state
+ * - Renderer: this is the UI code: it should only be concerned with the look
+ * and feel of the system: rendering, binding handlers, ...
+ * - Controller: coordinates the model with the renderer and the parents widgets.
+ * This is more a 'plumbing' widget.
+ * - Factory: setting up the MRC components is a complex task, because each of
+ * them needs the proper arguments/options, it needs to be extensible, they
+ * needs to be created in the proper order, ... The job of the factory is
+ * to process all the various arguments, and make sure each component is as
+ * simple as possible.
+ */
+
+var ajax = require('web.ajax');
+var Class = require('web.Class');
+var mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+var Widget = require('web.Widget');
+
+
+/**
+ * Owner of the state, this component is tasked with fetching data, processing
+ * it, updating it, ...
+ *
+ * Note that this is not a widget: it is a class which has not UI representation.
+ *
+ * @class Model
+ */
+var Model = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {Widget} parent
+ * @param {Object} params
+ */
+ init: function (parent, params) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method should return the complete state necessary for the renderer
+ * to display the current data.
+ *
+ * @returns {*}
+ */
+ get: function () {
+ },
+ /**
+ * The load method is called once in a model, when we load the data for the
+ * first time. The method returns (a promise that resolves to) some kind
+ * of token/handle. The handle can then be used with the get method to
+ * access a representation of the data.
+ *
+ * @param {Object} params
+ * @returns {Promise} The promise resolves to some kind of handle
+ */
+ load: function () {
+ return Promise.resolve();
+ },
+});
+
+/**
+ * Only responsibility of this component is to display the user interface, and
+ * react to user changes.
+ *
+ * @class Renderer
+ */
+var Renderer = Widget.extend({
+ /**
+ * @override
+ * @param {any} state
+ * @param {Object} params
+ */
+ init: function (parent, state, params) {
+ this._super(parent);
+ this.state = state;
+ },
+});
+
+/**
+ * The controller has to coordinate between parent, renderer and model.
+ *
+ * @class Controller
+ */
+var Controller = Widget.extend({
+ /**
+ * @override
+ * @param {Model} model
+ * @param {Renderer} renderer
+ * @param {Object} params
+ * @param {any} [params.handle=null]
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.handle = params.handle || null;
+ this.model = model;
+ this.renderer = renderer;
+ },
+ /**
+ * @returns {Promise}
+ */
+ start: function () {
+ return Promise.all(
+ [this._super.apply(this, arguments),
+ this._startRenderer()]
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Appends the renderer in the $el. To override to insert it elsewhere.
+ *
+ * @private
+ */
+ _startRenderer: function () {
+ return this.renderer.appendTo(this.$el);
+ },
+});
+
+var Factory = Class.extend({
+ config: {
+ Model: Model,
+ Renderer: Renderer,
+ Controller: Controller,
+ },
+ /**
+ * @override
+ */
+ init: function () {
+ this.rendererParams = {};
+ this.controllerParams = {};
+ this.modelParams = {};
+ this.loadParams = {};
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Main method of the Factory class. Create a controller, and make sure that
+ * data and libraries are loaded.
+ *
+ * There is a unusual thing going in this method with parents: we create
+ * renderer/model with parent as parent, then we have to reassign them at
+ * the end to make sure that we have the proper relationships. This is
+ * necessary to solve the problem that the controller needs the model and
+ * the renderer to be instantiated, but the model need a parent to be able
+ * to load itself, and the renderer needs the data in its constructor.
+ *
+ * @param {Widget} parent the parent of the resulting Controller (most
+ * likely an action manager)
+ * @returns {Promise<Controller>}
+ */
+ getController: function (parent) {
+ var self = this;
+ var model = this.getModel(parent);
+ return Promise.all([this._loadData(model), ajax.loadLibs(this)]).then(function (result) {
+ const { state, handle } = result[0];
+ var renderer = self.getRenderer(parent, state);
+ var Controller = self.Controller || self.config.Controller;
+ const initialState = model.get(handle);
+ var controllerParams = _.extend({
+ initialState,
+ handle,
+ }, self.controllerParams);
+ var controller = new Controller(parent, model, renderer, controllerParams);
+ model.setParent(controller);
+ renderer.setParent(controller);
+ return controller;
+ });
+ },
+ /**
+ * Returns a new model instance
+ *
+ * @param {Widget} parent the parent of the model
+ * @returns {Model} instance of the model
+ */
+ getModel: function (parent) {
+ var Model = this.config.Model;
+ return new Model(parent, this.modelParams);
+ },
+ /**
+ * Returns a new renderer instance
+ *
+ * @param {Widget} parent the parent of the renderer
+ * @param {Object} state the information related to the rendered data
+ * @returns {Renderer} instance of the renderer
+ */
+ getRenderer: function (parent, state) {
+ var Renderer = this.config.Renderer;
+ return new Renderer(parent, state, this.rendererParams);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Loads initial data from the model
+ *
+ * @private
+ * @param {Model} model a Model instance
+ * @param {Object} [options={}]
+ * @param {boolean} [options.withSampleData=true]
+ * @returns {Promise<*>} a promise that resolves to the value returned by
+ * the get method from the model
+ * @todo: get rid of loadParams (use modelParams instead)
+ */
+ _loadData: function (model, options = {}) {
+ options.withSampleData = 'withSampleData' in options ? options.withSampleData : true;
+ return model.load(this.loadParams).then(function (handle) {
+ return { state: model.get(handle, options), handle };
+ });
+ },
+});
+
+
+return {
+ Factory: Factory,
+ Model: Model,
+ Renderer: Renderer,
+ Controller: Controller,
+};
+
+});
diff --git a/addons/web/static/src/js/core/owl_dialog.js b/addons/web/static/src/js/core/owl_dialog.js
new file mode 100644
index 00000000..62c6d239
--- /dev/null
+++ b/addons/web/static/src/js/core/owl_dialog.js
@@ -0,0 +1,275 @@
+odoo.define('web.OwlDialog', function (require) {
+ "use strict";
+
+ const patchMixin = require('web.patchMixin');
+
+ const { Component, hooks, misc } = owl;
+ const { Portal } = misc;
+ const { useExternalListener, useRef } = hooks;
+ const SIZE_CLASSES = {
+ 'extra-large': 'modal-xl',
+ 'large': 'modal-lg',
+ 'small': 'modal-sm',
+ };
+
+ /**
+ * Dialog (owl version)
+ *
+ * Represents a bootstrap-styled dialog handled with pure JS. Its implementation
+ * is roughly the same as the legacy dialog, the only exception being the buttons.
+ * @extends Component
+ **/
+ class Dialog extends Component {
+ /**
+ * @param {Object} [props]
+ * @param {(boolean|string)} [props.backdrop='static'] The kind of modal backdrop
+ * to use (see Bootstrap documentation).
+ * @param {string} [props.contentClass] Class to add to the dialog
+ * @param {boolean} [props.fullscreen=false] Whether the dialog should be
+ * open in fullscreen mode (the main usecase is mobile).
+ * @param {boolean} [props.renderFooter=true] Whether the dialog footer
+ * should be rendered.
+ * @param {boolean} [props.renderHeader=true] Whether the dialog header
+ * should be rendered.
+ * @param {string} [props.size='large'] 'extra-large', 'large', 'medium'
+ * or 'small'.
+ * @param {string} [props.stopClicks=true] whether the dialog should stop
+ * the clicks propagation outside of itself.
+ * @param {string} [props.subtitle='']
+ * @param {string} [props.title='Odoo']
+ * @param {boolean} [props.technical=true] If set to false, the modal will have
+ * the standard frontend style (use this for non-editor frontend features).
+ */
+ constructor() {
+ super(...arguments);
+
+ this.modalRef = useRef('modal');
+ this.footerRef = useRef('modal-footer');
+
+ useExternalListener(window, 'keydown', this._onKeydown);
+ }
+
+ mounted() {
+ this.constructor.display(this);
+
+ this.env.bus.on('close_dialogs', this, this._close);
+
+ if (this.props.renderFooter) {
+ // Set up main button : will first look for an element with the
+ // 'btn-primary' class, then a 'btn' class, then the first button
+ // element.
+ let mainButton = this.footerRef.el.querySelector('.btn.btn-primary');
+ if (!mainButton) {
+ mainButton = this.footerRef.el.querySelector('.btn');
+ }
+ if (!mainButton) {
+ mainButton = this.footerRef.el.querySelector('button');
+ }
+ if (mainButton) {
+ this.mainButton = mainButton;
+ this.mainButton.addEventListener('keydown', this._onMainButtonKeydown.bind(this));
+ this.mainButton.focus();
+ }
+ }
+
+ this._removeTooltips();
+ }
+
+ willUnmount() {
+ this.env.bus.off('close_dialogs', this, this._close);
+
+ this._removeTooltips();
+
+ this.constructor.hide(this);
+ }
+
+ //--------------------------------------------------------------------------
+ // Getters
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get size() {
+ return SIZE_CLASSES[this.props.size];
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Send an event signaling that the dialog must be closed.
+ * @private
+ */
+ _close() {
+ this.trigger('dialog-closed');
+ }
+
+ /**
+ * Remove any existing tooltip present in the DOM.
+ * @private
+ */
+ _removeTooltips() {
+ for (const tooltip of document.querySelectorAll('.tooltip')) {
+ tooltip.remove(); // remove open tooltip if any to prevent them staying when modal is opened
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onBackdropClick(ev) {
+ if (!this.props.backdrop || ev.target !== ev.currentTarget) {
+ return;
+ }
+ if (this.props.backdrop === 'static') {
+ if (this.mainButton) {
+ this.mainButton.focus();
+ }
+ } else {
+ this._close();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (this.props.stopClicks) {
+ ev.stopPropagation();
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onFocus() {
+ if (this.mainButton) {
+ this.mainButton.focus();
+ }
+ }
+
+ /**
+ * Manage the TAB key on the main button. If the focus is on a primary
+ * button and the user tries to tab to go to the next button : a tooltip
+ * will be displayed.
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onMainButtonKeydown(ev) {
+ if (ev.key === 'Tab' && !ev.shiftKey) {
+ ev.preventDefault();
+ $(this.mainButton)
+ .tooltip({
+ delay: { show: 200, hide: 0 },
+ title: () => this.env.qweb.renderToString('web.DialogButton.tooltip', {
+ title: this.mainButton.innerText.toUpperCase(),
+ }),
+ trigger: 'manual',
+ })
+ .tooltip('show');
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ if (
+ ev.key === 'Escape' &&
+ !['INPUT', 'TEXTAREA'].includes(ev.target.tagName) &&
+ this.constructor.displayed[this.constructor.displayed.length - 1] === this
+ ) {
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+ ev.stopPropagation();
+ this._close();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Static
+ //--------------------------------------------------------------------------
+
+ /**
+ * Push the given dialog at the end of the displayed list then set it as
+ * active and all the others as passive.
+ * @param {(LegacyDialog|OwlDialog)} dialog
+ */
+ static display(dialog) {
+ const activeDialog = this.displayed[this.displayed.length - 1];
+ if (activeDialog) {
+ // Deactivate previous dialog
+ const activeDialogEl = activeDialog instanceof this ?
+ // Owl dialog
+ activeDialog.modalRef.el :
+ // Legacy dialog
+ activeDialog.$modal[0];
+ activeDialogEl.classList.add('o_inactive_modal');
+ }
+ // Push dialog
+ this.displayed.push(dialog);
+ // Update body class
+ document.body.classList.add('modal-open');
+ }
+
+ /**
+ * Set the given displayed dialog as passive and the last added displayed dialog
+ * as active, then remove it from the displayed list.
+ * @param {(LegacyDialog|OwlDialog)} dialog
+ */
+ static hide(dialog) {
+ // Remove given dialog from the list
+ this.displayed.splice(this.displayed.indexOf(dialog), 1);
+ // Activate last dialog and update body class
+ const lastDialog = this.displayed[this.displayed.length - 1];
+ if (lastDialog) {
+ lastDialog.el.focus();
+ const modalEl = lastDialog instanceof this ?
+ // Owl dialog
+ lastDialog.modalRef.el :
+ // Legacy dialog
+ lastDialog.$modal[0];
+ modalEl.classList.remove('o_inactive_modal');
+ } else {
+ document.body.classList.remove('modal-open');
+ }
+ }
+ }
+
+ Dialog.displayed = [];
+
+ Dialog.components = { Portal };
+ Dialog.defaultProps = {
+ backdrop: 'static',
+ renderFooter: true,
+ renderHeader: true,
+ size: 'large',
+ stopClicks: true,
+ technical: true,
+ title: "Odoo",
+ };
+ Dialog.props = {
+ backdrop: { validate: b => ['static', true, false].includes(b) },
+ contentClass: { type: String, optional: 1 },
+ fullscreen: { type: Boolean, optional: 1 },
+ renderFooter: Boolean,
+ renderHeader: Boolean,
+ size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s) },
+ stopClicks: Boolean,
+ subtitle: { type: String, optional: 1 },
+ technical: Boolean,
+ title: String,
+ };
+ Dialog.template = 'web.OwlDialog';
+
+ return patchMixin(Dialog);
+});
diff --git a/addons/web/static/src/js/core/patch_mixin.js b/addons/web/static/src/js/core/patch_mixin.js
new file mode 100644
index 00000000..99f06f56
--- /dev/null
+++ b/addons/web/static/src/js/core/patch_mixin.js
@@ -0,0 +1,80 @@
+odoo.define("web.patchMixin", function () {
+ "use strict";
+
+ /**
+ * This module defines and exports the 'patchMixin' function. This function
+ * returns a 'monkey-patchable' version of the ES6 Class given in arguments.
+ *
+ * const patchMixin = require('web.patchMixin');
+ * class MyClass {
+ * print() {
+ * console.log('MyClass');
+ * }
+ * }
+ * const MyPatchedClass = patchMixin(MyClass);
+ *
+ *
+ * A patchable class has a 'patch' function, allowing to define a patch:
+ *
+ * MyPatchedClass.patch("module_name.key", T =>
+ * class extends T {
+ * print() {
+ * console.log('MyPatchedClass');
+ * super.print();
+ * }
+ * }
+ * );
+ *
+ * const myPatchedClass = new MyPatchedClass();
+ * myPatchedClass.print(); // displays "MyPatchedClass" and "MyClass"
+ *
+ *
+ * The 'unpatch' function can be used to remove a patch, given its key:
+ *
+ * MyPatchedClass.unpatch("module_name.key");
+ */
+ function patchMixin(OriginalClass) {
+ let unpatchList = [];
+ class PatchableClass extends OriginalClass {}
+
+ PatchableClass.patch = function (name, patch) {
+ if (unpatchList.find(x => x.name === name)) {
+ throw new Error(`Class ${OriginalClass.name} already has a patch ${name}`);
+ }
+ if (!Object.prototype.hasOwnProperty.call(this, 'patch')) {
+ throw new Error(`Class ${this.name} is not patchable`);
+ }
+ const SubClass = patch(Object.getPrototypeOf(this));
+ unpatchList.push({
+ name: name,
+ elem: this,
+ prototype: this.prototype,
+ origProto: Object.getPrototypeOf(this),
+ origPrototype: Object.getPrototypeOf(this.prototype),
+ patch: patch,
+ });
+ Object.setPrototypeOf(this, SubClass);
+ Object.setPrototypeOf(this.prototype, SubClass.prototype);
+ };
+
+ PatchableClass.unpatch = function (name) {
+ if (!unpatchList.find(x => x.name === name)) {
+ throw new Error(`Class ${OriginalClass.name} does not have any patch ${name}`);
+ }
+ const toUnpatch = unpatchList.reverse();
+ unpatchList = [];
+ for (let unpatch of toUnpatch) {
+ Object.setPrototypeOf(unpatch.elem, unpatch.origProto);
+ Object.setPrototypeOf(unpatch.prototype, unpatch.origPrototype);
+ }
+ for (let u of toUnpatch.reverse()) {
+ if (u.name !== name) {
+ PatchableClass.patch(u.name, u.patch);
+ }
+ }
+ };
+ return PatchableClass;
+ }
+
+ return patchMixin;
+});
diff --git a/addons/web/static/src/js/core/popover.js b/addons/web/static/src/js/core/popover.js
new file mode 100644
index 00000000..2400f075
--- /dev/null
+++ b/addons/web/static/src/js/core/popover.js
@@ -0,0 +1,328 @@
+odoo.define('web.Popover', function (require) {
+ 'use strict';
+
+ const patchMixin = require('web.patchMixin');
+
+ const { Component, hooks, misc, QWeb } = owl;
+ const { Portal } = misc;
+ const { useRef, useState } = hooks;
+
+ /**
+ * Popover
+ *
+ * Represents a bootstrap-style popover handled with pure JS. The popover
+ * will be visually bound to its `target` using an arrow-like '::before'
+ * CSS pseudo-element.
+ * @extends Component
+ **/
+ class Popover extends Component {
+ /**
+ * @param {Object} props
+ * @param {String} [props.position='bottom']
+ * @param {String} [props.title]
+ */
+ constructor() {
+ super(...arguments);
+ this.popoverRef = useRef('popover');
+ this.orderedPositions = ['top', 'bottom', 'left', 'right'];
+ this.state = useState({
+ displayed: false,
+ });
+
+ this._onClickDocument = this._onClickDocument.bind(this);
+ this._onScrollDocument = this._onScrollDocument.bind(this);
+ this._onResizeWindow = this._onResizeWindow.bind(this);
+
+ this._onScrollDocument = _.throttle(this._onScrollDocument, 50);
+ this._onResizeWindow = _.debounce(this._onResizeWindow, 250);
+
+ /**
+ * Those events are only necessary if the popover is currently open,
+ * so we decided for performance reasons to avoid binding them while
+ * it is closed. This allows to have many popover instantiated while
+ * keeping the count of global handlers low.
+ */
+ this._hasGlobalEventListeners = false;
+ }
+
+ mounted() {
+ this._compute();
+ }
+
+ patched() {
+ this._compute();
+ }
+
+ willUnmount() {
+ if (this._hasGlobalEventListeners) {
+ this._removeGlobalEventListeners();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _addGlobalEventListeners() {
+ /**
+ * Use capture for the following events to ensure no other part of
+ * the code can stop its propagation from reaching here.
+ */
+ document.addEventListener('click', this._onClickDocument, {
+ capture: true,
+ });
+ document.addEventListener('scroll', this._onScrollDocument, {
+ capture: true,
+ });
+ window.addEventListener('resize', this._onResizeWindow);
+ this._hasGlobalEventListeners = true;
+ }
+
+ _close() {
+ this.state.displayed = false;
+ }
+
+ /**
+ * Computes the popover according to its props. This method will try to position the
+ * popover as requested (according to the `position` props). If the requested position
+ * does not fit the viewport, other positions will be tried in a clockwise order starting
+ * a the requested position (e.g. starting from left: top, right, bottom). If no position
+ * is found that fits the viewport, 'bottom' is used.
+ *
+ * @private
+ */
+ _compute() {
+ if (!this._hasGlobalEventListeners && this.state.displayed) {
+ this._addGlobalEventListeners();
+ }
+ if (this._hasGlobalEventListeners && !this.state.displayed) {
+ this._removeGlobalEventListeners();
+ }
+ if (!this.state.displayed) {
+ return;
+ }
+
+ // copy the default ordered position to avoid updating them in place
+ const possiblePositions = [...this.orderedPositions];
+ const positionIndex = possiblePositions.indexOf(
+ this.props.position
+ );
+
+ const positioningData = this.constructor.computePositioningData(
+ this.popoverRef.el,
+ this.el
+ );
+
+ // check if the requested position fits the viewport; if not,
+ // try all other positions and find one that does
+ const position = possiblePositions
+ .slice(positionIndex)
+ .concat(possiblePositions.slice(0, positionIndex))
+ .map((pos) => positioningData[pos])
+ .find((pos) => {
+ this.popoverRef.el.style.top = `${pos.top}px`;
+ this.popoverRef.el.style.left = `${pos.left}px`;
+ const rect = this.popoverRef.el.getBoundingClientRect();
+ const html = document.documentElement;
+ return (
+ rect.top >= 0 &&
+ rect.left >= 0 &&
+ rect.bottom <= (window.innerHeight || html.clientHeight) &&
+ rect.right <= (window.innerWidth || html.clientWidth)
+ );
+ });
+
+ // remove all existing positioning classes
+ possiblePositions.forEach((pos) => {
+ this.popoverRef.el.classList.remove(`o_popover--${pos}`);
+ });
+
+ if (position) {
+ // apply the preferred found position that fits the viewport
+ this.popoverRef.el.classList.add(`o_popover--${position.name}`);
+ } else {
+ // use the given `position` props because no position fits
+ this.popoverRef.el.style.top = `${positioningData[this.props.position].top}px`;
+ this.popoverRef.el.style.left = `${positioningData[this.props.position].left}px`;
+ this.popoverRef.el.classList.add(`o_popover--${this.props.position}`);
+ }
+ }
+
+ /**
+ * @private
+ */
+ _removeGlobalEventListeners() {
+ document.removeEventListener('click', this._onClickDocument, true);
+ document.removeEventListener('scroll', this._onScrollDocument, true);
+ window.removeEventListener('resize', this._onResizeWindow);
+ this._hasGlobalEventListeners = false;
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Toggles the popover depending on its current state.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.state.displayed = !this.state.displayed;
+ }
+
+ /**
+ * A click outside the popover will dismiss the current popover.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDocument(ev) {
+ // Handled by `_onClick`.
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ // Ignore click inside the popover.
+ if (this.popoverRef.el && this.popoverRef.el.contains(ev.target)) {
+ return;
+ }
+ this._close();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onPopoverClose(ev) {
+ this._close();
+ }
+
+ /**
+ * Popover must recompute its position when children content changes.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onPopoverCompute(ev) {
+ this._compute();
+ }
+
+ /**
+ * A resize event will need to 'reposition' the popover close to its
+ * target.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onResizeWindow(ev) {
+ if (this.__owl__.status === 5 /* destroyed */) {
+ return;
+ }
+ this._compute();
+ }
+
+ /**
+ * A scroll event will need to 'reposition' the popover close to its
+ * target.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onScrollDocument(ev) {
+ if (this.__owl__.status === 5 /* destroyed */) {
+ return;
+ }
+ this._compute();
+ }
+
+ //----------------------------------------------------------------------
+ // Static
+ //----------------------------------------------------------------------
+
+ /**
+ * Compute the expected positioning coordinates for each possible
+ * positioning based on the target and popover sizes.
+ * In particular the popover must not overflow the viewport in any
+ * direction, it should actually stay at `margin` distance from the
+ * border to look good.
+ *
+ * @static
+ * @param {HTMLElement} popoverElement The popover element
+ * @param {HTMLElement} targetElement The target element, to which
+ * the popover will be visually 'bound'
+ * @param {integer} [margin=16] Minimal accepted margin from the border
+ * of the viewport.
+ * @returns {Object}
+ */
+ static computePositioningData(popoverElement, targetElement, margin = 16) {
+ // set target position, possible position
+ const boundingRectangle = targetElement.getBoundingClientRect();
+ const targetTop = boundingRectangle.top;
+ const targetLeft = boundingRectangle.left;
+ const targetHeight = targetElement.offsetHeight;
+ const targetWidth = targetElement.offsetWidth;
+ const popoverHeight = popoverElement.offsetHeight;
+ const popoverWidth = popoverElement.offsetWidth;
+ const windowWidth = window.innerWidth || document.documentElement.clientWidth;
+ const windowHeight = window.innerHeight || document.documentElement.clientHeight;
+ const leftOffsetForVertical = Math.max(
+ margin,
+ Math.min(
+ Math.round(targetLeft - (popoverWidth - targetWidth) / 2),
+ windowWidth - popoverWidth - margin,
+ ),
+ );
+ const topOffsetForHorizontal = Math.max(
+ margin,
+ Math.min(
+ Math.round(targetTop - (popoverHeight - targetHeight) / 2),
+ windowHeight - popoverHeight - margin,
+ ),
+ );
+ return {
+ top: {
+ name: 'top',
+ top: Math.round(targetTop - popoverHeight),
+ left: leftOffsetForVertical,
+ },
+ right: {
+ name: 'right',
+ top: topOffsetForHorizontal,
+ left: Math.round(targetLeft + targetWidth),
+ },
+ bottom: {
+ name: 'bottom',
+ top: Math.round(targetTop + targetHeight),
+ left: leftOffsetForVertical,
+ },
+ left: {
+ name: 'left',
+ top: topOffsetForHorizontal,
+ left: Math.round(targetLeft - popoverWidth),
+ },
+ };
+ }
+
+ }
+
+ Popover.components = { Portal };
+ Popover.template = 'Popover';
+ Popover.defaultProps = {
+ position: 'bottom',
+ };
+ Popover.props = {
+ position: {
+ type: String,
+ validate: (p) => ['top', 'bottom', 'left', 'right'].includes(p),
+ },
+ title: { type: String, optional: true },
+ };
+
+ QWeb.registerComponent('Popover', Popover);
+
+ return patchMixin(Popover);
+});
diff --git a/addons/web/static/src/js/core/py_utils.js b/addons/web/static/src/js/core/py_utils.js
new file mode 100644
index 00000000..1cf1150a
--- /dev/null
+++ b/addons/web/static/src/js/core/py_utils.js
@@ -0,0 +1,562 @@
+odoo.define('web.py_utils', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+var _t = core._t;
+var py = window.py; // to silence linters
+
+// 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];
+ // Don't add a test case like ``val === undefined``
+ // this is intended to prevent letting crap pass
+ // on the context without even knowing it.
+ // If you face an issue from here, try to sanitize
+ // the context upstream instead
+ 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;
+ 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;
+}
+
+/**
+ * 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(
+ py.extras.datetime.date, [d.getFullYear(), d.getMonth() + 1, d.getDate()]);
+}
+
+/**
+ * Returns a timedelta object which represents the timezone offset between the
+ * local timezone and the UTC time.
+ *
+ * This is very useful to generate datetime strings which are 'timezone'
+ * dependant. For example, we can now write this to generate the correct
+ * datetime string representing "this morning in the user timezone":
+ *
+ * "datetime.datetime.now().replace(hour=0,minute=0,second=0) + tz_offset()).strftime('%Y-%m-%d %H:%M:%S')"
+ * @returns {datetime.timedelta}
+ */
+function tz_offset() {
+ var offset= new Date().getTimezoneOffset();
+ var kwargs = {minutes: py.float.fromJSON(offset)};
+ return py.PY_call(py.extras.datetime.timedelta,[],kwargs);
+}
+
+
+function pycontext() {
+ const d = new Date();
+ const today = `${
+ String(d.getFullYear()).padStart(4, "0")}-${
+ String(d.getMonth() + 1).padStart(2, "0")}-${
+ String(d.getDate()).padStart(2, "0")}`;
+ const now = `${
+ String(d.getUTCFullYear()).padStart(4, "0")}-${
+ String(d.getUTCMonth() + 1).padStart(2, "0")}-${
+ String(d.getUTCDate()).padStart(2, "0")} ${
+ String(d.getUTCHours()).padStart(2, "0")}:${
+ String(d.getUTCMinutes()).padStart(2, "0")}:${
+ String(d.getUTCSeconds()).padStart(2, "0")}`;
+
+ const { datetime, relativedelta, time } = py.extras;
+ return {
+ current_date: today,
+ datetime,
+ time,
+ now,
+ today,
+ relativedelta,
+ context_today,
+ tz_offset,
+ };
+}
+
+/**
+ * @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':
+ 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}));
+}
+
+/**
+ * Assemble domains into a single domains using an 'OR' or an 'AND' operator.
+ *
+ * .. note:
+ *
+ * - this function does not evaluate anything inside the domain. This
+ * is actually quite critical because this allows the manipulation of
+ * unevaluated (dynamic) domains.
+ * - this function gives a normalized domain as result,
+ * - applied on a list of length 1, it returns the domain normalized.
+ *
+ * @param {string[]} domains list of string representing domains
+ * @param {"AND" | "OR"} operator used to combine domains (default "AND")
+ * @returns {string} normalized domain
+ */
+function assembleDomains(domains, operator) {
+ var ASTs = domains.map(_getPyJSAST);
+ if (operator === "OR") {
+ operator = py.tokenize("'|'")[0];
+ } else {
+ operator = py.tokenize("'&'")[0];
+ }
+ var result = _getPyJSAST("[]");
+ var normalizedDomains = ASTs
+ .filter(function (AST) {
+ return AST.first.length > 0;
+ })
+ .map(_normalizeDomainAST);
+ if (normalizedDomains.length > 0) {
+ result.first = normalizedDomains.reduce(function (acc, ast) {
+ return acc.concat(ast.first);
+ },
+ _.times(normalizedDomains.length - 1, _.constant(operator))
+ );
+ }
+ return _formatAST(result);
+}
+/**
+ * Normalize a domain via its string representation.
+ *
+ * Note: this function does not evaluate anything inside the domain. This is
+ * actually quite critical because this allows the manipulation of unevaluated
+ * (dynamic) domains.
+ *
+ * @param {string} domain string representing a domain
+ * @returns {string} normalized domain
+ */
+function normalizeDomain (domain) {
+ return _formatAST(_normalizeDomainAST(_getPyJSAST(domain)));
+}
+
+//--------------------------------------------------------------------------
+// Private
+//--------------------------------------------------------------------------
+
+// Binding power for prefix operator is not accessible in the AST generated by
+// py.js, so we have to hardcode some values here
+var BINDING_POWERS = {
+ or: 30,
+ and: 40,
+ not: 50,
+};
+
+/**
+ * @private
+ * Convert a python AST (generated by py.js) to a string form, which should
+ * represent the same AST.
+ *
+ * @param {Object} ast a valid AST obtained by py.js, which represent a python
+ * expression
+ * @param {integer} [lbp=0] a binding power. This is necessary to be able to
+ * format sub expressions: the + sub expression in "3 * (a + 2)" should be
+ * formatted with parenthesis, because its binding power is lower than the
+ * binding power of *.
+ * @returns {string}
+ */
+function _formatAST(ast, lbp) {
+ lbp = lbp || 0;
+ switch (ast.id) {
+ // basic values
+ case "(number)":
+ return String(ast.value);
+ case "(string)":
+ return JSON.stringify(ast.value);
+ case "(constant)":
+ return ast.value;
+ case "(name)":
+ return ast.value;
+ case "[":
+ if (ast.second) {
+ // read a value in a dictionary: d['a']
+ return _formatAST(ast.first) + '[' + _formatAST(ast.second) + ']';
+ } else {
+ // list: [1, 2]
+ var values = ast.first.map(_formatAST);
+ return '[' + values.join(', ') + ']';
+ }
+ case "{":
+ var keyValues = ast.first.map(function (kv) {
+ return _formatAST(kv[0]) + ': ' + _formatAST(kv[1]);
+ });
+ return '{' + keyValues.join(', ') + '}';
+
+ // relations
+ case "=":
+ return _formatAST(ast.first) + ' ' + ast.id + ' ' + _formatAST(ast.second);
+ // operators
+ case "-":
+ case "+":
+ case "~":
+ case "*":
+ case "**":
+ case "%":
+ case "//":
+ case "and":
+ case "or":
+ if (ast.second) {
+ // infix
+ var r = _formatAST(ast.first, ast.lbp) + ' ' + ast.id + ' ' + _formatAST(ast.second, ast.lbp);
+ if (ast.lbp < lbp) {
+ r = '(' + r + ')';
+ }
+ return r;
+ }
+ // prefix
+ // real lbp is not accessible, it is inside a closure
+ var actualBP = BINDING_POWERS[ast.id] || 130;
+ return ast.id + _formatAST(ast.first, actualBP);
+ case "if":
+ var t = _formatAST(ast.ifTrue)
+ + ' if ' + _formatAST(ast.condition)
+ + ' else ' + _formatAST(ast.ifFalse);
+ return ast.lbp < lbp ? '(' + t + ')' : t;
+ case ".":
+ return _formatAST(ast.first, ast.lbp) + '.' + _formatAST(ast.second);
+ case "not":
+ return "not " + _formatAST(ast.first);
+ case "(comparator)":
+ var operator = ast.operators[0];
+ return _formatAST(ast.expressions[0]) + ' ' + operator + ' ' + _formatAST(ast.expressions[1]);
+
+ // function call
+ case "(":
+ if (ast.second) {
+ // this is a function call: f(a, b)
+ return _formatAST(ast.first) + '(' + ast.second.map(_formatAST).join(', ') + ')';
+ } else {
+ // this is a tuple
+ return '(' + ast.first.map(_formatAST).join(', ') + ')';
+ }
+ }
+ throw new Error("Unimplemented python construct");
+}
+
+/**
+ * @private
+ * Get the PyJs AST representing a domain starting from is string representation
+ *
+ * @param {string} domain string representing a domain
+ * @returns {PyJS AST} PyJS AST representation of domain
+ */
+function _getPyJSAST(domain) {
+ return py.parse(py.tokenize(domain));
+}
+
+/**
+ * @private
+ *
+ * Normalize a domain, at the level of the AST.
+ *
+ * Note: this function does not evaluate anything inside the domain. This is
+ * actually quite critical because this allows the manipulation of unevaluated
+ * (dynamic) domains.
+ *
+ * @param {PyJS AST} domain valid AST representing a domain
+ * @returns {PyJS AST} normalized domain AST
+ */
+function _normalizeDomainAST(domain) {
+ var expected = 1;
+ for (var i = 0; i < domain.first.length; i++) {
+ var value = domain.first[i].value;
+ if (value === '&' || value === '|') {
+ expected++;
+ } else if (value !== '!') {
+ expected--;
+ }
+ }
+ var andOperator = py.tokenize("'&'")[0];
+
+ if (expected < 0) {
+ domain.first.unshift.apply(domain.first, _.times(Math.abs(expected), _.constant(andOperator)));
+ }
+
+ return domain;
+}
+
+return {
+ context: pycontext,
+ ensure_evaluated: ensure_evaluated,
+ eval: pyeval,
+ eval_domains_and_contexts: eval_domains_and_contexts,
+ py_eval: py_eval,
+ normalizeDomain: normalizeDomain,
+ assembleDomains: assembleDomains,
+ _getPyJSAST: _getPyJSAST,
+ _formatAST: _formatAST,
+ _normalizeDomainAST: _normalizeDomainAST,
+};
+});
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 00000000..0261abc5
--- /dev/null
+++ b/addons/web/static/src/js/core/qweb.js
@@ -0,0 +1,62 @@
+odoo.define('web.QWeb', function (require) {
+"use strict";
+
+var translation = require('web.translation');
+
+var _t = translation._t;
+
+/**
+ * @param {boolean} debug
+ * @param {Object} default_dict
+ * @param {boolean} [enableTranslation=true] if true (this is the default),
+ * the rendering will translate all strings that are not marked with
+ * t-translation=off. This is useful for the kanban view, which uses a
+ * template which is already translated by the server
+ */
+function QWeb(debug, default_dict, enableTranslation) {
+ if (enableTranslation === undefined) {
+ enableTranslation = true;
+ }
+ 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 = enableTranslation ? preprocess_node : function () {};
+ 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', 'aria-label'];
+ 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/ram_storage.js b/addons/web/static/src/js/core/ram_storage.js
new file mode 100644
index 00000000..abad86c4
--- /dev/null
+++ b/addons/web/static/src/js/core/ram_storage.js
@@ -0,0 +1,82 @@
+odoo.define('web.RamStorage', function (require) {
+'use strict';
+
+/**
+ * This module defines an alternative of the Storage objects (localStorage,
+ * sessionStorage), stored in RAM. It is used when those native Storage objects
+ * are unavailable (e.g. in private browsing on Safari).
+ */
+
+var Class = require('web.Class');
+var mixins = require('web.mixins');
+
+
+var RamStorage = Class.extend(mixins.EventDispatcherMixin, {
+ /**
+ * @constructor
+ */
+ init: function () {
+ mixins.EventDispatcherMixin.init.call(this);
+ if (!this.storage) {
+ this.clear();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Removes all data from the storage
+ */
+ clear: function () {
+ this.storage = Object.create(null);
+ this.length = 0;
+ },
+ /**
+ * Returns the value associated with a given key in the storage
+ *
+ * @param {string} key
+ * @returns {string}
+ */
+ getItem: function (key) {
+ return this.storage[key];
+ },
+ /**
+ * @param {integer} index
+ * @return {string}
+ */
+ key: function (index) {
+ return _.keys(this.storage)[index];
+ },
+ /**
+ * Removes the given key from the storage
+ *
+ * @param {string} key
+ */
+ removeItem: function (key) {
+ if (key in this.storage) {
+ this.length--;
+ }
+ delete this.storage[key];
+ this.trigger('storage', {key: key, newValue: null});
+ },
+ /**
+ * Adds a given key-value pair to the storage, or update the value of the
+ * given key if it already exists
+ *
+ * @param {string} key
+ * @param {string} value
+ */
+ setItem: function (key, value) {
+ if (!(key in this.storage)) {
+ this.length++;
+ }
+ this.storage[key] = value;
+ this.trigger('storage', {key: key, newValue: value});
+ },
+});
+
+return RamStorage;
+
+});
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 00000000..1ab16236
--- /dev/null
+++ b/addons/web/static/src/js/core/registry.js
@@ -0,0 +1,154 @@
+odoo.define("web.Registry", function (require) {
+ "use strict";
+
+ const { sortBy } = require("web.utils");
+
+ /**
+ * 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.
+ */
+ class Registry {
+ /**
+ * @function predicate
+ * @param {any} value
+ * @returns {boolean}
+ */
+ /**
+ * @param {Object} [mapping] the initial data in the registry
+ * @param {predicate} [predicate=(() => true)] predicate that each
+ * added value must pass to be registered.
+ */
+ constructor(mapping, predicate = () => true) {
+ this.map = Object.create(mapping || null);
+ this._scoreMapping = Object.create(null);
+ this._sortedKeys = null;
+ this.listeners = []; // listening callbacks on newly added items.
+ this.predicate = predicate;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a key (and a value) to the registry.
+ * Notify the listeners on newly added item in the registry.
+ * @param {string} key
+ * @param {any} value
+ * @param {number} [score] if given, this value will be used to order keys
+ * @returns {Registry} can be used to chain add calls.
+ */
+ add(key, value, score) {
+ if (!this.predicate(value)) {
+ throw new Error(`Value of key "${key}" does not pass the addition predicate.`);
+ }
+ this._scoreMapping[key] = score === undefined ? key : score;
+ this._sortedKeys = null;
+ this.map[key] = value;
+ for (const callback of this.listeners) {
+ callback(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Check if the registry contains the key
+ * @param {string} key
+ * @returns {boolean}
+ */
+ contains(key) {
+ return key in this.map;
+ }
+
+ /**
+ * Returns the content of the registry (an object mapping keys to values)
+ * @returns {Object}
+ */
+ entries() {
+ const entries = {};
+ const keys = this.keys();
+ for (const key of keys) {
+ entries[key] = this.map[key];
+ }
+ return entries;
+ }
+
+ /**
+ * Returns the value associated to the given key.
+ * @param {string} key
+ * @returns {any}
+ */
+ get(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(keys) {
+ for (const key of keys) {
+ if (key in this.map) {
+ return this.map[key];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the list of keys in map object.
+ *
+ * The registry guarantees that the keys have a consistent order, defined by
+ * the 'score' value when the item has been added.
+ * @returns {string[]}
+ */
+ keys() {
+ if (!this._sortedKeys) {
+ const keys = [];
+ for (const key in this.map) {
+ keys.push(key);
+ }
+ this._sortedKeys = sortBy(keys,
+ key => this._scoreMapping[key] || 0
+ );
+ }
+ return this._sortedKeys;
+ }
+
+ /**
+ * @function onAddCallback
+ * @param {string} key
+ * @param {any} value
+ */
+ /**
+ * Register a callback to execute when items are added to the registry.
+ * @param {onAddCallback} callback function with parameters (key, value).
+ */
+ onAdd(callback) {
+ this.listeners.push(callback);
+ }
+
+ /**
+ * Return the list of values in map object
+ * @returns {string[]}
+ */
+ values() {
+ return this.keys().map((key) => this.map[key]);
+ }
+ }
+
+ return Registry;
+});
diff --git a/addons/web/static/src/js/core/rpc.js b/addons/web/static/src/js/core/rpc.js
new file mode 100644
index 00000000..e0d33355
--- /dev/null
+++ b/addons/web/static/src/js/core/rpc.js
@@ -0,0 +1,128 @@
+odoo.define('web.rpc', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+
+const rpc = {
+ /**
+ * Perform a RPC. Please note that this is not the preferred way to do a
+ * rpc if you are in the context of a widget. In that case, you should use
+ * the this._rpc method.
+ *
+ * @param {Object} params @see buildQuery for a description
+ * @param {Object} options
+ * @returns {Promise<any>}
+ */
+ query: function (params, options) {
+ var query = rpc.buildQuery(params);
+ return ajax.rpc(query.route, query.params, options);
+ },
+ /**
+ * @param {Object} options
+ * @param {any[]} [options.args]
+ * @param {Object} [options.context]
+ * @param {any[]} [options.domain]
+ * @param {string[]} [options.fields]
+ * @param {string[]} [options.groupBy]
+ * @param {Object} [options.kwargs]
+ * @param {integer|false} [options.limit]
+ * @param {string} [options.method]
+ * @param {string} [options.model]
+ * @param {integer} [options.offset]
+ * @param {string[]} [options.orderBy]
+ * @param {Object} [options.params]
+ * @param {string} [options.route]
+ * @returns {Object} with 2 keys: route and params
+ */
+ buildQuery: function (options) {
+ var route;
+ var params = options.params || {};
+ var orderBy;
+ if (options.route) {
+ route = options.route;
+ } else if (options.model && options.method) {
+ route = '/web/dataset/call_kw/' + options.model + '/' + options.method;
+ }
+ if (options.method) {
+ params.args = options.args || [];
+ params.model = options.model;
+ params.method = options.method;
+ params.kwargs = _.extend(params.kwargs || {}, options.kwargs);
+ params.kwargs.context = options.context || params.context || params.kwargs.context;
+ }
+
+ if (options.method === 'read_group' || options.method === 'web_read_group') {
+ if (!(params.args && params.args[0] !== undefined)) {
+ params.kwargs.domain = options.domain || params.domain || params.kwargs.domain || [];
+ }
+ if (!(params.args && params.args[1] !== undefined)) {
+ params.kwargs.fields = options.fields || params.fields || params.kwargs.fields || [];
+ }
+ if (!(params.args && params.args[2] !== undefined)) {
+ params.kwargs.groupby = options.groupBy || params.groupBy || params.kwargs.groupby || [];
+ }
+ params.kwargs.offset = options.offset || params.offset || params.kwargs.offset;
+ params.kwargs.limit = options.limit || params.limit || params.kwargs.limit;
+ // In kwargs, we look for "orderby" rather than "orderBy" (note the absence of capital B),
+ // since the Python argument to the actual function is "orderby".
+ orderBy = options.orderBy || params.orderBy || params.kwargs.orderby;
+ params.kwargs.orderby = orderBy ? rpc._serializeSort(orderBy) : orderBy;
+ params.kwargs.lazy = 'lazy' in options ? options.lazy : params.lazy;
+
+ if (options.method === 'web_read_group') {
+ params.kwargs.expand = options.expand || params.expand || params.kwargs.expand;
+ params.kwargs.expand_limit = options.expand_limit || params.expand_limit || params.kwargs.expand_limit;
+ var expandOrderBy = options.expand_orderby || params.expand_orderby || params.kwargs.expand_orderby;
+ params.kwargs.expand_orderby = expandOrderBy ? rpc._serializeSort(expandOrderBy) : expandOrderBy;
+ }
+ }
+
+ if (options.method === 'search_read') {
+ // call the model method
+ params.kwargs.domain = options.domain || params.domain || params.kwargs.domain;
+ params.kwargs.fields = options.fields || params.fields || params.kwargs.fields;
+ params.kwargs.offset = options.offset || params.offset || params.kwargs.offset;
+ params.kwargs.limit = options.limit || params.limit || params.kwargs.limit;
+ // In kwargs, we look for "order" rather than "orderBy" since the Python
+ // argument to the actual function is "order".
+ orderBy = options.orderBy || params.orderBy || params.kwargs.order;
+ params.kwargs.order = orderBy ? rpc._serializeSort(orderBy) : orderBy;
+ }
+
+ if (options.route === '/web/dataset/search_read') {
+ // specifically call the controller
+ params.model = options.model || params.model;
+ params.domain = options.domain || params.domain;
+ params.fields = options.fields || params.fields;
+ params.limit = options.limit || params.limit;
+ params.offset = options.offset || params.offset;
+ orderBy = options.orderBy || params.orderBy;
+ params.sort = orderBy ? rpc._serializeSort(orderBy) : orderBy;
+ params.context = options.context || params.context || {};
+ }
+
+ return {
+ route: route,
+ params: JSON.parse(JSON.stringify(params)),
+ };
+ },
+ /**
+ * Helper method, generates a string to describe a ordered by sequence for
+ * SQL.
+ *
+ * For example, [{name: 'foo'}, {name: 'bar', asc: false}] will
+ * be converted into 'foo ASC, bar DESC'
+ *
+ * @param {Object[]} orderBy list of objects {name:..., [asc: ...]}
+ * @returns {string}
+ */
+ _serializeSort: function (orderBy) {
+ return _.map(orderBy, function (order) {
+ return order.name + (order.asc !== false ? ' ASC' : ' DESC');
+ }).join(', ');
+ },
+};
+
+return rpc;
+
+});
diff --git a/addons/web/static/src/js/core/service_mixins.js b/addons/web/static/src/js/core/service_mixins.js
new file mode 100644
index 00000000..198d0099
--- /dev/null
+++ b/addons/web/static/src/js/core/service_mixins.js
@@ -0,0 +1,282 @@
+odoo.define('web.ServiceProviderMixin', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+// ServiceProviderMixin is deprecated. It is only used by the ProjectTimesheet
+// app. As soon as it no longer uses it, we can remove it.
+var ServiceProviderMixin = {
+ services: {}, // dict containing deployed service instances
+ UndeployedServices: {}, // dict containing classes of undeployed services
+ /**
+ * @override
+ */
+ init: function (parent) {
+ var self = this;
+ // to properly instantiate services with this as parent, this mixin
+ // assumes that it is used along the EventDispatcherMixin, and that
+ // EventDispatchedMixin's init is called first
+ // as EventDispatcherMixin's init is already called, this handler has
+ // to be bound manually
+ this.on('call_service', this, this._call_service.bind(this));
+
+ // add already registered services from the service registry
+ _.each(core.serviceRegistry.map, function (Service, serviceName) {
+ if (serviceName in self.UndeployedServices) {
+ throw new Error('Service "' + serviceName + '" is already loaded.');
+ }
+ self.UndeployedServices[serviceName] = Service;
+ });
+ this._deployServices();
+
+ // listen on newly added services
+ core.serviceRegistry.onAdd(function (serviceName, Service) {
+ if (serviceName in self.services || serviceName in self.UndeployedServices) {
+ throw new Error('Service "' + serviceName + '" is already loaded.');
+ }
+ self.UndeployedServices[serviceName] = Service;
+ self._deployServices();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _deployServices: function () {
+ var self = this;
+ var done = false;
+ while (!done) {
+ var serviceName = _.findKey(this.UndeployedServices, function (Service) {
+ // no missing dependency
+ return !_.some(Service.prototype.dependencies, function (depName) {
+ return !self.services[depName];
+ });
+ });
+ if (serviceName) {
+ var service = new this.UndeployedServices[serviceName](this);
+ this.services[serviceName] = service;
+ delete this.UndeployedServices[serviceName];
+ service.start();
+ } else {
+ done = true;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Call the 'service', using data from the 'event' that
+ * has triggered the service call.
+ *
+ * For the ajax service, the arguments are extended with
+ * the target so that it can call back the caller.
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _call_service: function (event) {
+ var args = event.data.args || [];
+ if (event.data.service === 'ajax' && event.data.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(event.target);
+ }
+ var service = this.services[event.data.service];
+ var result = service[event.data.method].apply(service, args);
+ event.data.callback(result);
+ },
+};
+
+return ServiceProviderMixin;
+
+});
+
+odoo.define('web.ServicesMixin', function (require) {
+"use strict";
+
+var rpc = require('web.rpc');
+
+/**
+ * @mixin
+ * @name ServicesMixin
+ */
+var ServicesMixin = {
+ /**
+ * @param {string} service
+ * @param {string} method
+ * @return {any} result of the service called
+ */
+ 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;
+ },
+ /**
+ * @private
+ * @param {Object} libs - @see ajax.loadLibs
+ * @param {Object} [context] - @see ajax.loadLibs
+ * @param {Object} [tplRoute=this._loadLibsTplRoute] - @see ajax.loadLibs
+ * @returns {Promise}
+ */
+ _loadLibs: function (libs, context, tplRoute) {
+ return this.call('ajax', 'loadLibs', libs, context, tplRoute || this._loadLibsTplRoute);
+ },
+ /**
+ * Builds and executes RPC query. Returns a promise resolved with
+ * the RPC result.
+ *
+ * @param {string} params either a route or a model
+ * @param {string} options if a model is given, this argument is a method
+ * @returns {Promise}
+ */
+ _rpc: function (params, options) {
+ var query = rpc.buildQuery(params);
+ var prom = this.call('ajax', 'rpc', query.route, query.params, options, this);
+ if (!prom) {
+ prom = new Promise(function () {});
+ prom.abort = function () {};
+ }
+ var abort = prom.abort ? prom.abort : prom.reject;
+ if (!abort) {
+ throw new Error("a rpc promise should always have a reject function");
+ }
+ prom.abort = abort.bind(prom);
+ return prom;
+ },
+ loadFieldView: function (modelName, context, view_id, view_type, options) {
+ return this.loadViews(modelName, context, [[view_id, view_type]], options).then(function (result) {
+ return result[view_type];
+ });
+ },
+ loadViews: function (modelName, context, views, options) {
+ var self = this;
+ return new Promise(function (resolve) {
+ self.trigger_up('load_views', {
+ modelName: modelName,
+ context: context,
+ views: views,
+ options: options,
+ on_success: resolve,
+ });
+ });
+ },
+ loadFilters: function (modelName, actionId, context) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.trigger_up('load_filters', {
+ modelName: modelName,
+ actionId: actionId,
+ context: context,
+ on_success: resolve,
+ });
+ });
+ },
+ createFilter: function (filter) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.trigger_up('create_filter', {
+ filter: filter,
+ on_success: resolve,
+ });
+ });
+ },
+ deleteFilter: function (filterId) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.trigger_up('delete_filter', {
+ filterId: filterId,
+ on_success: resolve,
+ });
+ });
+ },
+ // 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 an unresolved
+ * promise.
+ *
+ * @param {any} action
+ * @param {any} options
+ * @returns {Promise}
+ */
+ do_action: function (action, options) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.trigger_up('do_action', {
+ action: action,
+ options: options,
+ on_success: resolve,
+ on_fail: reject,
+ });
+ });
+ },
+ /**
+ * Displays a notification.
+ *
+ * @param {Object} options
+ * @param {string} options.title
+ * @param {string} [options.subtitle]
+ * @param {string} [options.message]
+ * @param {string} [options.type='warning'] 'info', 'success', 'warning', 'danger' or ''
+ * @param {boolean} [options.sticky=false]
+ * @param {string} [options.className]
+ */
+ displayNotification: function (options) {
+ return this.call('notification', 'notify', options);
+ },
+ /**
+ * @deprecated will be removed as soon as the notification system is reviewed
+ * @see displayNotification
+ */
+ do_notify: function (title = false, message, sticky, className) {
+ return this.displayNotification({
+ type: 'warning',
+ title: title,
+ message: message,
+ sticky: sticky,
+ className: className,
+ });
+ },
+ /**
+ * @deprecated will be removed as soon as the notification system is reviewed
+ * @see displayNotification
+ */
+ do_warn: function (title = false, message, sticky, className) {
+ console.warn(title, message);
+ return this.displayNotification({
+ type: 'danger',
+ title: title,
+ message: message,
+ sticky: sticky,
+ className: className,
+ });
+ },
+};
+
+return ServicesMixin;
+
+});
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 00000000..c8a879e2
--- /dev/null
+++ b/addons/web/static/src/js/core/session.js
@@ -0,0 +1,414 @@
+odoo.define('web.Session', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var mixins = require('web.mixins');
+var utils = require('web.utils');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+// 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:
+
+ * "modules"
+ * "use_cors"
+ */
+ init: function (parent, origin, options) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+ options = options || {};
+ this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || [];
+ this.server = null;
+ this.avoid_recursion = false;
+ this.use_cors = options.use_cors || false;
+ this.setup(origin);
+
+ // 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 = {};
+ core.bus.on('invalidate_session', this, this._onInvalidateSession);
+ },
+ 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
+ options = options || {};
+ if ('use_cors' in options) {
+ this.use_cors = options.use_cors;
+ }
+ },
+ /**
+ * Setup a session
+ */
+ session_bind: function (origin) {
+ this.setup(origin);
+ qweb.default_dict._s = this.origin;
+ this.uid = null;
+ this.username = null;
+ this.user_context= {};
+ this.db = null;
+ this.active_id = null;
+ return this.session_init();
+ },
+ /**
+ * Init a session, reloads from cookie, if it exists
+ */
+ session_init: function () {
+ var self = this;
+ var prom = this.session_reload();
+
+ if (this.is_frontend) {
+ return prom.then(function () {
+ return self.load_translations();
+ });
+ }
+
+ return prom.then(function () {
+ var modules = self.module_list.join(',');
+ var promise = self.load_qweb(modules);
+ if (self.session_is_valid()) {
+ return promise.then(function () { return self.load_modules(); });
+ }
+ return Promise.all([
+ promise,
+ 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 Promise.resolve(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 Promise.reject();
+ }
+ _.extend(self, result);
+ });
+ },
+ session_logout: function () {
+ $.bbq.removeState();
+ return this.rpc("/web/session/destroy", {});
+ },
+ user_has_group: function (group) {
+ if (!this.uid) {
+ return Promise.resolve(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 = Promise.resolve(self.load_translations());
+ var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
+ var file_list = [ locale ];
+ if(to_load.length) {
+ loaded = Promise.all([
+ loaded,
+ self.rpc('/web/webclient/csslist', {mods: to_load})
+ .then(self.load_css.bind(self)),
+ self.load_qweb(to_load),
+ self.rpc('/web/webclient/jslist', {mods: to_load})
+ .then(function (files) {
+ file_list = file_list.concat(files);
+ })
+ ]);
+ }
+ return loaded.then(function () {
+ return self.load_js(file_list);
+ }).then(function () {
+ self._configureLocale();
+ });
+ },
+ load_translations: function () {
+ var lang = this.user_context.lang
+ /* We need to get the website lang at this level.
+ The only way is to get it is to take the HTML tag lang
+ Without it, we will always send undefined if there is no lang
+ in the user_context. */
+ var html = document.documentElement,
+ htmlLang = html.getAttribute('lang');
+ if (!this.user_context.lang && htmlLang) {
+ lang = htmlLang.replace('-', '_');
+ }
+
+ return _t.database.load_translations(this, this.module_list, lang, this.translationURL);
+ },
+ load_css: function (files) {
+ var self = this;
+ _.each(files, function (file) {
+ ajax.loadCSS(self.url(file, null));
+ });
+ },
+ load_js: function (files) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ if (files.length !== 0) {
+ var file = files.shift();
+ var url = self.url(file, null);
+ ajax.loadJS(url).then(resolve);
+ } else {
+ resolve();
+ }
+ });
+ },
+ load_qweb: function (mods) {
+ var self = this;
+ var lock = this.qweb_mutex.exec(function () {
+ var cacheId = self.cache_hashes && self.cache_hashes.qweb;
+ var route = '/web/webclient/qweb/' + (cacheId ? cacheId : Date.now()) + '?mods=' + mods;
+ return $.get(route).then(function (doc) {
+ if (!doc) { return; }
+ const owlTemplates = [];
+ for (let child of doc.querySelectorAll("templates > [owl]")) {
+ child.removeAttribute('owl');
+ owlTemplates.push(child.outerHTML);
+ child.remove();
+ }
+ qweb.add_template(doc);
+ self.owlTemplates = `<templates> ${owlTemplates.join('\n')} </templates>`;
+ });
+ });
+ return lock;
+ },
+ get_currency: function (currency_id) {
+ return this.currencies[currency_id];
+ },
+ get_file: function (options) {
+ options.session = this;
+ return 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 {Promise} promise indicating the session is done reloading
+ */
+ session_reload: function () {
+ var result = _.extend({}, window.odoo.session_info);
+ _.extend(this, result);
+ return Promise.resolve();
+ },
+ /**
+ * 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
+ * @returns {Promise}
+ */
+ rpc: function (url, params, options) {
+ var self = this;
+ options = _.clone(options || {});
+ options.headers = _.extend({}, options.headers);
+
+ // we add here the user context for ALL queries, mainly to pass
+ // the allowed_company_ids key
+ if (params && params.kwargs) {
+ params.kwargs.context = _.extend(params.kwargs.context || {}, this.user_context);
+ }
+
+ // TODO: remove
+ if (! _.isString(url)) {
+ _.extend(options, url);
+ url = url.url;
+ }
+ if (self.use_cors) {
+ url = self.url(url, null);
+ }
+
+ return ajax.jsonRpc(url, "call", params, options);
+ },
+ url: function (path, params) {
+ params = _.extend(params || {});
+ 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;
+ },
+ /**
+ * Returns the time zone difference (in minutes) from the current locale
+ * (host system settings) to UTC, for a given date. The offset is positive
+ * if the local timezone is behind UTC, and negative if it is ahead.
+ *
+ * @param {string | moment} date a valid string date or moment instance
+ * @returns {integer}
+ */
+ getTZOffset: function (date) {
+ return -new Date(date).getTimezoneOffset();
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Replaces the value of a key in cache_hashes (the hash of some resource computed on the back-end by a unique value
+ * @param {string} key the key in the cache_hashes to invalidate
+ */
+ invalidateCacheKey: function(key) {
+ if (this.cache_hashes && this.cache_hashes[key]) {
+ this.cache_hashes[key] = Date.now();
+ }
+ },
+
+ /**
+ * Reload the currencies (initially given in session_info). This is meant to
+ * be called when changes are made on 'res.currency' records (e.g. when
+ * (de-)activating a currency). For the sake of simplicity, we reload all
+ * session_info.
+ *
+ * FIXME: this whole currencies handling should be moved out of session.
+ *
+ * @returns {$.promise}
+ */
+ reloadCurrencies: function () {
+ var self = this;
+ return this.rpc('/web/session/get_session_info').then(function (result) {
+ self.currencies = result.currencies;
+ });
+ },
+
+ setCompanies: function (main_company_id, company_ids) {
+ var hash = $.bbq.getState()
+ hash.cids = company_ids.sort(function(a, b) {
+ if (a === main_company_id) {
+ return -1;
+ } else if (b === main_company_id) {
+ return 1;
+ } else {
+ return a - b;
+ }
+ }).join(',');
+ utils.set_cookie('cids', hash.cids || String(main_company_id));
+ $.bbq.pushState({'cids': hash.cids}, 0);
+ location.reload();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets first day of week in current locale according to the user language.
+ *
+ * @private
+ */
+ _configureLocale: function () {
+ moment.updateLocale(moment.locale(), {
+ week: {
+ dow: (_t.database.parameters.week_start || 0) % 7,
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInvalidateSession: function () {
+ this.uid = false;
+ },
+});
+
+return Session;
+
+});
diff --git a/addons/web/static/src/js/core/session_storage.js b/addons/web/static/src/js/core/session_storage.js
new file mode 100644
index 00000000..c0787f4d
--- /dev/null
+++ b/addons/web/static/src/js/core/session_storage.js
@@ -0,0 +1,56 @@
+odoo.define('web.sessionStorage', function (require) {
+'use strict';
+
+var RamStorage = require('web.RamStorage');
+var mixins = require('web.mixins');
+
+// use a fake sessionStorage in RAM if the native sessionStorage is unavailable
+// (e.g. private browsing in Safari)
+var storage;
+var sessionStorage = window.sessionStorage;
+try {
+ var uid = new Date();
+ sessionStorage.setItem(uid, uid);
+ sessionStorage.removeItem(uid);
+
+ /*
+ * We create an intermediate object in order to triggered the storage on
+ * this object. the sessionStorage. This simplifies testing and usage as
+ * starages are commutable in services without change. Also, objects
+ * that use storage do not have to know that events go through window,
+ * it's not up to them to handle these cases.
+ */
+ storage = (function () {
+ var storage = Object.create(_.extend({
+ getItem: sessionStorage.getItem.bind(sessionStorage),
+ setItem: sessionStorage.setItem.bind(sessionStorage),
+ removeItem: sessionStorage.removeItem.bind(sessionStorage),
+ clear: sessionStorage.clear.bind(sessionStorage),
+ },
+ mixins.EventDispatcherMixin
+ ));
+ storage.init();
+ $(window).on('storage', function (e) {
+ var key = e.originalEvent.key;
+ var newValue = e.originalEvent.newValue;
+ try {
+ JSON.parse(newValue);
+ if (sessionStorage.getItem(key) === newValue) {
+ storage.trigger('storage', {
+ key: key,
+ newValue: newValue,
+ });
+ }
+ } catch (error) {}
+ });
+ return storage;
+ })();
+
+} catch (exception) {
+ console.warn('Fail to load sessionStorage');
+ storage = new RamStorage();
+}
+
+return storage;
+
+});
diff --git a/addons/web/static/src/js/core/smooth_scroll_on_drag.js b/addons/web/static/src/js/core/smooth_scroll_on_drag.js
new file mode 100644
index 00000000..37aae5f1
--- /dev/null
+++ b/addons/web/static/src/js/core/smooth_scroll_on_drag.js
@@ -0,0 +1,389 @@
+odoo.define('web/static/src/js/core/smooth_scroll_on_drag.js', function (require) {
+"use strict";
+
+const Class = require('web.Class');
+const mixins = require('web.mixins');
+
+/**
+ * Provides a helper for SmoothScrollOnDrag options.offsetElements
+ */
+const OffsetElementsHelper = Class.extend({
+
+ /**
+ * @constructor
+ * @param {Object} offsetElements
+ * @param {jQuery} [offsetElements.$top] top offset element
+ * @param {jQuery} [offsetElements.$right] right offset element
+ * @param {jQuery} [offsetElements.$bottom] bottom offset element
+ * @param {jQuery} [offsetElements.$left] left offset element
+ */
+ init: function (offsetElements) {
+ this.offsetElements = offsetElements;
+ },
+ top: function () {
+ if (!this.offsetElements.$top || !this.offsetElements.$top.length) {
+ return 0;
+ }
+ return this.offsetElements.$top.get(0).getBoundingClientRect().bottom;
+ },
+ right: function () {
+ if (!this.offsetElements.$right || !this.offsetElements.$right.length) {
+ return 0;
+ }
+ return this.offsetElements.$right.get(0).getBoundingClientRect().left;
+ },
+ bottom: function () {
+ if (!this.offsetElements.$bottom || !this.offsetElements.$bottom.length) {
+ return 0;
+ }
+ return this.offsetElements.$bottom.get(0).getBoundingClientRect().top;
+ },
+ left: function () {
+ if (!this.offsetElements.$left || !this.offsetElements.$left.length) {
+ return 0;
+ }
+ return this.offsetElements.$left.get(0).getBoundingClientRect().right;
+ },
+});
+
+/**
+ * Provides smooth scroll behaviour on drag.
+ */
+const SmoothScrollOnDrag = Class.extend(mixins.ParentedMixin, {
+
+ /**
+ * @constructor
+ * @param {Object} parent The parent widget that uses this class.
+ * @param {jQuery} $element The element the smooth scroll on drag has to be set on.
+ * @param {jQuery} $scrollTarget The element the scroll will be triggered on.
+ * @param {Object} [options={}]
+ * @param {Object} [options.jQueryDraggableOptions={}] The configuration to be passed to
+ * the jQuery draggable function (all will be passed except scroll which will
+ * be overridden to false).
+ * @param {Number} [options.scrollOffsetThreshold=150] (Integer) The distance from the
+ * bottom/top of the options.$scrollTarget from which the smooth scroll will be
+ * triggered.
+ * @param {Number} [options.scrollStep=20] (Integer) The step of the scroll.
+ * @param {Number} [options.scrollTimerInterval=5] (Integer) The interval (in ms) the
+ * scrollStep will be applied.
+ * @param {Object} [options.scrollBoundaries = {}] Specifies whether scroll can still be triggered
+ * when dragging $element outside of target.
+ * @param {Object} [options.scrollBoundaries.top = true] Specifies whether scroll can still be triggered
+ * when dragging $element above the top edge of target.
+ * @param {Object} [options.scrollBoundaries.right = true] Specifies whether scroll can still be triggered
+ * when dragging $element after the right edge of target.
+ * @param {Object} [options.scrollBoundaries.bottom = true] Specifies whether scroll can still be triggered
+ * when dragging $element bellow the bottom edge of target.
+ * @param {Object} [options.scrollBoundaries.left = true] Specifies whether scroll can still be triggered
+ * when dragging $element before the left edge of target.
+ * @param {Object} [options.offsetElements={}] Visible elements in $scrollTarget that
+ * reduce $scrollTarget drag visible area (scroll will be triggered sooner than
+ * normally). A selector is passed so that elements such as automatically hidden
+ * menu can then be correctly handled.
+ * @param {jQuery} [options.offsetElements.$top] Visible top offset element which height will
+ * be taken into account when triggering scroll at the top of the $scrollTarget.
+ * @param {jQuery} [options.offsetElements.$right] Visible right offset element which width
+ * will be taken into account when triggering scroll at the right side of the
+ * $scrollTarget.
+ * @param {jQuery} [options.offsetElements.$bottom] Visible bottom offset element which height
+ * will be taken into account when triggering scroll at bottom of the $scrollTarget.
+ * @param {jQuery} [options.offsetElements.$left] Visible left offset element which width
+ * will be taken into account when triggering scroll at the left side of the
+ * $scrollTarget.
+ * @param {boolean} [options.disableHorizontalScroll = false] Disable horizontal scroll if not needed.
+ */
+ init(parent, $element, $scrollTarget, options = {}) {
+ mixins.ParentedMixin.init.call(this);
+ this.setParent(parent);
+
+ this.$element = $element;
+ this.$scrollTarget = $scrollTarget;
+ this.options = options;
+
+ // Setting optional options to their default value if not provided
+ this.options.jQueryDraggableOptions = this.options.jQueryDraggableOptions || {};
+ if (!this.options.jQueryDraggableOptions.cursorAt) {
+ this.$element.on('mousedown.smooth_scroll', this._onElementMouseDown.bind(this));
+ }
+ this.options.scrollOffsetThreshold = this.options.scrollOffsetThreshold || 150;
+ this.options.scrollStep = this.options.scrollStep || 20;
+ this.options.scrollTimerInterval = this.options.scrollTimerInterval || 5;
+ this.options.offsetElements = this.options.offsetElements || {};
+ this.options.offsetElementsManager = new OffsetElementsHelper(this.options.offsetElements);
+ this.options.scrollBoundaries = Object.assign({
+ top: true,
+ right: true,
+ bottom: true,
+ left: true
+ }, this.options.scrollBoundaries);
+
+ this.autoScrollHandler = null;
+
+ this.scrollStepDirectionEnum = {
+ up: -1,
+ right: 1,
+ down: 1,
+ left: -1,
+ };
+
+ this.options.jQueryDraggableOptions.scroll = false;
+ this.options.disableHorizontalScroll = this.options.disableHorizontalScroll || false;
+ const draggableOptions = Object.assign({}, this.options.jQueryDraggableOptions, {
+ start: (ev, ui) => this._onSmoothDragStart(ev, ui, this.options.jQueryDraggableOptions.start),
+ drag: (ev, ui) => this._onSmoothDrag(ev, ui, this.options.jQueryDraggableOptions.drag),
+ stop: (ev, ui) => this._onSmoothDragStop(ev, ui, this.options.jQueryDraggableOptions.stop),
+ });
+ this.$element.draggable(draggableOptions);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.ParentedMixin.destroy.call(this);
+ this.$element.off('.smooth_scroll');
+ this._stopSmoothScroll();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Starts the scroll process using the options.
+ * The options will be updated dynamically when the handler _onSmoothDrag
+ * will be called. The interval will be cleared when the handler
+ * _onSmoothDragStop will be called.
+ *
+ * @private
+ * @param {Object} ui The jQuery drag handler ui parameter.
+ */
+ _startSmoothScroll(ui) {
+ this._stopSmoothScroll();
+ this.autoScrollHandler = setInterval(
+ () => {
+ // Prevents Delta's from being different from 0 when scroll should not occur (except when
+ // helper is dragged outside of this.$scrollTarget's visible area as it increases
+ // this.$scrollTarget's scrollHeight).
+ // Also, this code prevents the helper from being incorrectly repositioned when target is
+ // a child of this.$scrollTarget.
+ this.verticalDelta = Math.min(
+ // Ensures scrolling stops when dragging bellow this.$scrollTarget bottom.
+ Math.max(
+ 0,
+ this.$scrollTarget.get(0).scrollHeight
+ - (this.$scrollTarget.scrollTop() + this.$scrollTarget.innerHeight())
+ ),
+ // Ensures scrolling stops when dragging above this.$scrollTarget top.
+ Math.max(
+ this.verticalDelta,
+ -this.$scrollTarget.scrollTop()
+ )
+ );
+ this.horizontalDelta = Math.min(
+ //Ensures scrolling stops when dragging left to this.$scrollTarget.
+ Math.max(
+ 0,
+ this.$scrollTarget.get(0).scrollWidth
+ - (this.$scrollTarget.scrollLeft() + this.$scrollTarget.innerWidth())
+ ),
+ //Ensures scrolling stops when dragging right to this.$scrollTarget.
+ Math.max(
+ this.horizontalDelta,
+ -this.$scrollTarget.scrollLeft()
+ )
+ );
+
+ // Keep helper at right position while scrolling when helper is a child of this.$scrollTarget.
+ if (this.scrollTargetIsParent) {
+ const offset = ui.helper.offset();
+ ui.helper.offset({
+ top: offset.top + this.verticalDelta,
+ left: offset.left + this.horizontalDelta
+ });
+ }
+ this.$scrollTarget.scrollTop(
+ this.$scrollTarget.scrollTop() +
+ this.verticalDelta
+ );
+ if (!this.options.disableHorizontalScroll) {
+ this.$scrollTarget.scrollLeft(
+ this.$scrollTarget.scrollLeft() +
+ this.horizontalDelta
+ );
+ }
+ },
+ this.options.scrollTimerInterval
+ );
+ },
+ /**
+ * Stops the scroll process if any is running.
+ *
+ * @private
+ */
+ _stopSmoothScroll() {
+ clearInterval(this.autoScrollHandler);
+ },
+ /**
+ * Updates the options depending on the offset position of the draggable
+ * helper. In the same time options are used by an interval to trigger
+ * scroll behaviour.
+ * @see {@link _startSmoothScroll} for interval implementation details.
+ *
+ * @private
+ * @param {Object} ui The jQuery drag handler ui parameter.
+ */
+ _updatePositionOptions(ui) {
+ const draggableHelperOffset = ui.offset;
+ const scrollTargetOffset = this.$scrollTarget.offset();
+ let visibleOffset = {
+ top: draggableHelperOffset.top
+ - scrollTargetOffset.top
+ + this.options.jQueryDraggableOptions.cursorAt.top
+ - this.options.offsetElementsManager.top(),
+ right: scrollTargetOffset.left + this.$scrollTarget.outerWidth()
+ - draggableHelperOffset.left
+ - this.options.jQueryDraggableOptions.cursorAt.left
+ - this.options.offsetElementsManager.right(),
+ bottom: scrollTargetOffset.top + this.$scrollTarget.outerHeight()
+ - draggableHelperOffset.top
+ - this.options.jQueryDraggableOptions.cursorAt.top
+ - this.options.offsetElementsManager.bottom(),
+ left: draggableHelperOffset.left
+ - scrollTargetOffset.left
+ + this.options.jQueryDraggableOptions.cursorAt.left
+ - this.options.offsetElementsManager.left(),
+ };
+
+ // If this.$scrollTarget is the html tag, we need to take the scroll position in to account
+ // as offsets positions are calculated relative to the document (thus <html>).
+ if (this.scrollTargetIsDocument) {
+ const scrollTargetScrollTop = this.$scrollTarget.scrollTop();
+ const scrollTargetScrollLeft = this.$scrollTarget.scrollLeft();
+ visibleOffset.top -= scrollTargetScrollTop;
+ visibleOffset.right += scrollTargetScrollLeft;
+ visibleOffset.bottom += scrollTargetScrollTop;
+ visibleOffset.left -= scrollTargetScrollLeft;
+ }
+
+ const scrollDecelerator = {
+ vertical: 0,
+ horizontal: 0,
+ };
+
+ const scrollStepDirection = {
+ vertical: this.scrollStepDirectionEnum.down,
+ horizontal: this.scrollStepDirectionEnum.right,
+ };
+
+ // Prevent scroll if outside of scroll boundaries
+ if ((!this.options.scrollBoundaries.top && visibleOffset.top < 0) ||
+ (!this.options.scrollBoundaries.right && visibleOffset.right < 0) ||
+ (!this.options.scrollBoundaries.bottom && visibleOffset.bottom < 0) ||
+ (!this.options.scrollBoundaries.left && visibleOffset.left < 0)) {
+ scrollDecelerator.horizontal = 1;
+ scrollDecelerator.vertical = 1;
+ } else {
+ // Manage vertical scroll
+ if (visibleOffset.bottom <= this.options.scrollOffsetThreshold) {
+ scrollDecelerator.vertical = Math.max(0, visibleOffset.bottom)
+ / this.options.scrollOffsetThreshold;
+ } else if (visibleOffset.top <= this.options.scrollOffsetThreshold) {
+ scrollDecelerator.vertical = Math.max(0, visibleOffset.top)
+ / this.options.scrollOffsetThreshold;
+ scrollStepDirection.vertical = this.scrollStepDirectionEnum.up;
+ } else {
+ scrollDecelerator.vertical = 1;
+ }
+
+ // Manage horizontal scroll
+ if (visibleOffset.right <= this.options.scrollOffsetThreshold) {
+ scrollDecelerator.horizontal = Math.max(0, visibleOffset.right)
+ / this.options.scrollOffsetThreshold;
+ } else if (visibleOffset.left <= this.options.scrollOffsetThreshold) {
+ scrollDecelerator.horizontal = Math.max(0, visibleOffset.left)
+ / this.options.scrollOffsetThreshold;
+ scrollStepDirection.horizontal = this.scrollStepDirectionEnum.left;
+ } else {
+ scrollDecelerator.horizontal = 1;
+ }
+ }
+
+ this.verticalDelta = Math.ceil(scrollStepDirection.vertical *
+ this.options.scrollStep *
+ (1 - Math.sqrt(scrollDecelerator.vertical)));
+ this.horizontalDelta = Math.ceil(scrollStepDirection.horizontal *
+ this.options.scrollStep *
+ (1 - Math.sqrt(scrollDecelerator.horizontal)));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when mouse button is down on this.$element.
+ * Updates the mouse cursor position variable.
+ *
+ * @private
+ * @param {Object} ev The jQuery mousedown handler event parameter.
+ */
+ _onElementMouseDown(ev) {
+ const elementOffset = $(ev.target).offset();
+ this.options.jQueryDraggableOptions.cursorAt = {
+ top: ev.pageY - elementOffset.top,
+ left: ev.pageX - elementOffset.left,
+ };
+ },
+ /**
+ * Called when dragging the element.
+ * Updates the position options and call the provided callback if any.
+ *
+ * @private
+ * @param {Object} ev The jQuery drag handler event parameter.
+ * @param {Object} ui The jQuery drag handler ui parameter.
+ * @param {Function} onDragCallback The jQuery drag callback.
+ */
+ _onSmoothDrag(ev, ui, onDragCallback) {
+ this._updatePositionOptions(ui);
+ if (typeof onDragCallback === 'function') {
+ onDragCallback.call(ui.helper, ev, ui);
+ }
+ },
+ /**
+ * Called when starting to drag the element.
+ * Updates the position params, starts smooth scrolling process and call the
+ * provided callback if any.
+ *
+ * @private
+ * @param {Object} ev The jQuery drag handler event parameter.
+ * @param {Object} ui The jQuery drag handler ui parameter.
+ * @param {Function} onDragStartCallBack The jQuery drag callback.
+ */
+ _onSmoothDragStart(ev, ui, onDragStartCallBack) {
+ this.scrollTargetIsDocument = this.$scrollTarget.is('html');
+ this.scrollTargetIsParent = this.$scrollTarget.get(0).contains(this.$element.get(0));
+ this._updatePositionOptions(ui);
+ this._startSmoothScroll(ui);
+ if (typeof onDragStartCallBack === 'function') {
+ onDragStartCallBack.call(ui.helper, ev, ui);
+ }
+ },
+ /**
+ * Called when stopping to drag the element.
+ * Stops the smooth scrolling process and call the provided callback if any.
+ *
+ * @private
+ * @param {Object} ev The jQuery drag handler event parameter.
+ * @param {Object} ui The jQuery drag handler ui parameter.
+ * @param {Function} onDragEndCallBack The jQuery drag callback.
+ */
+ _onSmoothDragStop(ev, ui, onDragEndCallBack) {
+ this._stopSmoothScroll();
+ if (typeof onDragEndCallBack === 'function') {
+ onDragEndCallBack.call(ui.helper, ev, ui);
+ }
+ },
+});
+
+return SmoothScrollOnDrag;
+});
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 00000000..9a640d23
--- /dev/null
+++ b/addons/web/static/src/js/core/time.js
@@ -0,0 +1,352 @@
+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/views/res_lang_views.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);
+
+/**
+ * Get date format of the user's language
+ */
+function getLangDateFormat() {
+ return strftime_to_moment_format(_t.database.parameters.date_format);
+}
+
+/**
+ * Get time format of the user's language
+ */
+function getLangTimeFormat() {
+ return strftime_to_moment_format(_t.database.parameters.time_format);
+}
+
+/**
+ * Get date time format of the user's language
+ */
+function getLangDatetimeFormat() {
+ return strftime_to_moment_format(_t.database.parameters.date_format + " " + _t.database.parameters.time_format);
+}
+
+const dateFormatWoZeroCache = {};
+/**
+ * Get date format of the user's language - allows non padded
+ */
+function getLangDateFormatWoZero() {
+ const dateFormat = getLangDateFormat();
+ if (!(dateFormat in dateFormatWoZeroCache)) {
+ dateFormatWoZeroCache[dateFormat] = dateFormat
+ .replace('MM', 'M')
+ .replace('DD', 'D');
+ }
+ return dateFormatWoZeroCache[dateFormat];
+}
+
+const timeFormatWoZeroCache = {};
+/**
+ * Get time format of the user's language - allows non padded
+ */
+function getLangTimeFormatWoZero() {
+ const timeFormat = getLangTimeFormat();
+ if (!(timeFormat in timeFormatWoZeroCache)) {
+ timeFormatWoZeroCache[timeFormat] = timeFormat
+ .replace('HH', 'H')
+ .replace('mm', 'm')
+ .replace('ss', 's');
+ }
+ return timeFormatWoZeroCache[timeFormat];
+}
+
+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,
+ getLangDateFormat: getLangDateFormat,
+ getLangTimeFormat: getLangTimeFormat,
+ getLangDateFormatWoZero: getLangDateFormatWoZero,
+ getLangTimeFormatWoZero: getLangTimeFormatWoZero,
+ getLangDatetimeFormat: getLangDatetimeFormat,
+};
+
+});
+
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 00000000..5335f471
--- /dev/null
+++ b/addons/web/static/src/js/core/translation.js
@@ -0,0 +1,132 @@
+
+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": ",",
+ "code": "en_US"};
+ },
+ 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.
+ @param {string} [url='/web/webclient/translations']
+ @returns {Promise}
+ */
+ load_translations: function(session, modules, lang, url) {
+ var self = this;
+ var cacheId = session.cache_hashes && session.cache_hashes.translations;
+ url = url || '/web/webclient/translations';
+ url += '/' + (cacheId ? cacheId : Date.now());
+ return $.get(url, {
+ mods: modules ? modules.join(',') : null,
+ lang: lang || null,
+ }).then(function (trans) {
+ self.set_bundle(trans);
+ });
+ }
+});
+
+/**
+ * Eager translation function, performs translation immediately at call
+ * site. Beware using this outside of method bodies (before the
+ * translation database is loaded), you probably want :func:`_lt`
+ * instead.
+ *
+ * @function _t
+ * @param {String} source string to translate
+ * @returns {String} source translated into the current locale
+ */
+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 00000000..50e54273
--- /dev/null
+++ b/addons/web/static/src/js/core/utils.js
@@ -0,0 +1,1028 @@
+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 diacriticsMap = {
+'\u0041': 'A','\u24B6': 'A','\uFF21': 'A','\u00C0': 'A','\u00C1': 'A','\u00C2': 'A','\u1EA6': 'A','\u1EA4': 'A','\u1EAA': 'A','\u1EA8': 'A',
+'\u00C3': 'A','\u0100': 'A','\u0102': 'A','\u1EB0': 'A','\u1EAE': 'A','\u1EB4': 'A','\u1EB2': 'A','\u0226': 'A','\u01E0': 'A','\u00C4': 'A',
+'\u01DE': 'A','\u1EA2': 'A','\u00C5': 'A','\u01FA': 'A','\u01CD': 'A','\u0200': 'A','\u0202': 'A','\u1EA0': 'A','\u1EAC': 'A','\u1EB6': 'A',
+'\u1E00': 'A','\u0104': 'A','\u023A': 'A','\u2C6F': 'A',
+
+'\uA732': 'AA',
+'\u00C6': 'AE','\u01FC': 'AE','\u01E2': 'AE',
+'\uA734': 'AO',
+'\uA736': 'AU',
+'\uA738': 'AV','\uA73A': 'AV',
+'\uA73C': 'AY',
+'\u0042': 'B','\u24B7': 'B','\uFF22': 'B','\u1E02': 'B','\u1E04': 'B','\u1E06': 'B','\u0243': 'B','\u0182': 'B','\u0181': 'B',
+
+'\u0043': 'C','\u24B8': 'C','\uFF23': 'C','\u0106': 'C','\u0108': 'C','\u010A': 'C','\u010C': 'C','\u00C7': 'C','\u1E08': 'C','\u0187': 'C',
+'\u023B': 'C','\uA73E': 'C',
+
+'\u0044': 'D','\u24B9': 'D','\uFF24': 'D','\u1E0A': 'D','\u010E': 'D','\u1E0C': 'D','\u1E10': 'D','\u1E12': 'D','\u1E0E': 'D','\u0110': 'D',
+'\u018B': 'D','\u018A': 'D','\u0189': 'D','\uA779': 'D',
+
+'\u01F1': 'DZ','\u01C4': 'DZ',
+'\u01F2': 'Dz','\u01C5': 'Dz',
+
+'\u0045': 'E','\u24BA': 'E','\uFF25': 'E','\u00C8': 'E','\u00C9': 'E','\u00CA': 'E','\u1EC0': 'E','\u1EBE': 'E','\u1EC4': 'E','\u1EC2': 'E',
+'\u1EBC': 'E','\u0112': 'E','\u1E14': 'E','\u1E16': 'E','\u0114': 'E','\u0116': 'E','\u00CB': 'E','\u1EBA': 'E','\u011A': 'E','\u0204': 'E',
+'\u0206': 'E','\u1EB8': 'E','\u1EC6': 'E','\u0228': 'E','\u1E1C': 'E','\u0118': 'E','\u1E18': 'E','\u1E1A': 'E','\u0190': 'E','\u018E': 'E',
+
+'\u0046': 'F','\u24BB': 'F','\uFF26': 'F','\u1E1E': 'F','\u0191': 'F','\uA77B': 'F',
+
+'\u0047': 'G','\u24BC': 'G','\uFF27': 'G','\u01F4': 'G','\u011C': 'G','\u1E20': 'G','\u011E': 'G','\u0120': 'G','\u01E6': 'G','\u0122': 'G',
+'\u01E4': 'G','\u0193': 'G','\uA7A0': 'G','\uA77D': 'G','\uA77E': 'G',
+
+'\u0048': 'H','\u24BD': 'H','\uFF28': 'H','\u0124': 'H','\u1E22': 'H','\u1E26': 'H','\u021E': 'H','\u1E24': 'H','\u1E28': 'H','\u1E2A': 'H',
+'\u0126': 'H','\u2C67': 'H','\u2C75': 'H','\uA78D': 'H',
+
+'\u0049': 'I','\u24BE': 'I','\uFF29': 'I','\u00CC': 'I','\u00CD': 'I','\u00CE': 'I','\u0128': 'I','\u012A': 'I','\u012C': 'I','\u0130': 'I',
+'\u00CF': 'I','\u1E2E': 'I','\u1EC8': 'I','\u01CF': 'I','\u0208': 'I','\u020A': 'I','\u1ECA': 'I','\u012E': 'I','\u1E2C': 'I','\u0197': 'I',
+
+'\u004A': 'J','\u24BF': 'J','\uFF2A': 'J','\u0134': 'J','\u0248': 'J',
+
+'\u004B': 'K','\u24C0': 'K','\uFF2B': 'K','\u1E30': 'K','\u01E8': 'K','\u1E32': 'K','\u0136': 'K','\u1E34': 'K','\u0198': 'K','\u2C69': 'K',
+'\uA740': 'K','\uA742': 'K','\uA744': 'K','\uA7A2': 'K',
+
+'\u004C': 'L','\u24C1': 'L','\uFF2C': 'L','\u013F': 'L','\u0139': 'L','\u013D': 'L','\u1E36': 'L','\u1E38': 'L','\u013B': 'L','\u1E3C': 'L',
+'\u1E3A': 'L','\u0141': 'L','\u023D': 'L','\u2C62': 'L','\u2C60': 'L','\uA748': 'L','\uA746': 'L','\uA780': 'L',
+
+'\u01C7': 'LJ',
+'\u01C8': 'Lj',
+'\u004D': 'M','\u24C2': 'M','\uFF2D': 'M','\u1E3E': 'M','\u1E40': 'M','\u1E42': 'M','\u2C6E': 'M','\u019C': 'M',
+
+'\u004E': 'N','\u24C3': 'N','\uFF2E': 'N','\u01F8': 'N','\u0143': 'N','\u00D1': 'N','\u1E44': 'N','\u0147': 'N','\u1E46': 'N','\u0145': 'N',
+'\u1E4A': 'N','\u1E48': 'N','\u0220': 'N','\u019D': 'N','\uA790': 'N','\uA7A4': 'N',
+
+'\u01CA': 'NJ',
+'\u01CB': 'Nj',
+
+'\u004F': 'O','\u24C4': 'O','\uFF2F': 'O','\u00D2': 'O','\u00D3': 'O','\u00D4': 'O','\u1ED2': 'O','\u1ED0': 'O','\u1ED6': 'O','\u1ED4': 'O',
+'\u00D5': 'O','\u1E4C': 'O','\u022C': 'O','\u1E4E': 'O','\u014C': 'O','\u1E50': 'O','\u1E52': 'O','\u014E': 'O','\u022E': 'O','\u0230': 'O',
+'\u00D6': 'O','\u022A': 'O','\u1ECE': 'O','\u0150': 'O','\u01D1': 'O','\u020C': 'O','\u020E': 'O','\u01A0': 'O','\u1EDC': 'O','\u1EDA': 'O',
+'\u1EE0': 'O','\u1EDE': 'O','\u1EE2': 'O','\u1ECC': 'O','\u1ED8': 'O','\u01EA': 'O','\u01EC': 'O','\u00D8': 'O','\u01FE': 'O','\u0186': 'O',
+'\u019F': 'O','\uA74A': 'O','\uA74C': 'O',
+
+'\u01A2': 'OI',
+'\uA74E': 'OO',
+'\u0222': 'OU',
+'\u0050': 'P','\u24C5': 'P','\uFF30': 'P','\u1E54': 'P','\u1E56': 'P','\u01A4': 'P','\u2C63': 'P','\uA750': 'P','\uA752': 'P','\uA754': 'P',
+'\u0051': 'Q','\u24C6': 'Q','\uFF31': 'Q','\uA756': 'Q','\uA758': 'Q','\u024A': 'Q',
+
+'\u0052': 'R','\u24C7': 'R','\uFF32': 'R','\u0154': 'R','\u1E58': 'R','\u0158': 'R','\u0210': 'R','\u0212': 'R','\u1E5A': 'R','\u1E5C': 'R',
+'\u0156': 'R','\u1E5E': 'R','\u024C': 'R','\u2C64': 'R','\uA75A': 'R','\uA7A6': 'R','\uA782': 'R',
+
+'\u0053': 'S','\u24C8': 'S','\uFF33': 'S','\u1E9E': 'S','\u015A': 'S','\u1E64': 'S','\u015C': 'S','\u1E60': 'S','\u0160': 'S','\u1E66': 'S',
+'\u1E62': 'S','\u1E68': 'S','\u0218': 'S','\u015E': 'S','\u2C7E': 'S','\uA7A8': 'S','\uA784': 'S',
+
+'\u0054': 'T','\u24C9': 'T','\uFF34': 'T','\u1E6A': 'T','\u0164': 'T','\u1E6C': 'T','\u021A': 'T','\u0162': 'T','\u1E70': 'T','\u1E6E': 'T',
+'\u0166': 'T','\u01AC': 'T','\u01AE': 'T','\u023E': 'T','\uA786': 'T',
+
+'\uA728': 'TZ',
+
+'\u0055': 'U','\u24CA': 'U','\uFF35': 'U','\u00D9': 'U','\u00DA': 'U','\u00DB': 'U','\u0168': 'U','\u1E78': 'U','\u016A': 'U','\u1E7A': 'U',
+'\u016C': 'U','\u00DC': 'U','\u01DB': 'U','\u01D7': 'U','\u01D5': 'U','\u01D9': 'U','\u1EE6': 'U','\u016E': 'U','\u0170': 'U','\u01D3': 'U',
+'\u0214': 'U','\u0216': 'U','\u01AF': 'U','\u1EEA': 'U','\u1EE8': 'U','\u1EEE': 'U','\u1EEC': 'U','\u1EF0': 'U','\u1EE4': 'U','\u1E72': 'U',
+'\u0172': 'U','\u1E76': 'U','\u1E74': 'U','\u0244': 'U',
+
+'\u0056': 'V','\u24CB': 'V','\uFF36': 'V','\u1E7C': 'V','\u1E7E': 'V','\u01B2': 'V','\uA75E': 'V','\u0245': 'V',
+'\uA760': 'VY',
+'\u0057': 'W','\u24CC': 'W','\uFF37': 'W','\u1E80': 'W','\u1E82': 'W','\u0174': 'W','\u1E86': 'W','\u1E84': 'W','\u1E88': 'W','\u2C72': 'W',
+'\u0058': 'X','\u24CD': 'X','\uFF38': 'X','\u1E8A': 'X','\u1E8C': 'X',
+
+'\u0059': 'Y','\u24CE': 'Y','\uFF39': 'Y','\u1EF2': 'Y','\u00DD': 'Y','\u0176': 'Y','\u1EF8': 'Y','\u0232': 'Y','\u1E8E': 'Y','\u0178': 'Y',
+'\u1EF6': 'Y','\u1EF4': 'Y','\u01B3': 'Y','\u024E': 'Y','\u1EFE': 'Y',
+
+'\u005A': 'Z','\u24CF': 'Z','\uFF3A': 'Z','\u0179': 'Z','\u1E90': 'Z','\u017B': 'Z','\u017D': 'Z','\u1E92': 'Z','\u1E94': 'Z','\u01B5': 'Z',
+'\u0224': 'Z','\u2C7F': 'Z','\u2C6B': 'Z','\uA762': 'Z',
+
+'\u0061': 'a','\u24D0': 'a','\uFF41': 'a','\u1E9A': 'a','\u00E0': 'a','\u00E1': 'a','\u00E2': 'a','\u1EA7': 'a','\u1EA5': 'a','\u1EAB': 'a',
+'\u1EA9': 'a','\u00E3': 'a','\u0101': 'a','\u0103': 'a','\u1EB1': 'a','\u1EAF': 'a','\u1EB5': 'a','\u1EB3': 'a','\u0227': 'a','\u01E1': 'a',
+'\u00E4': 'a','\u01DF': 'a','\u1EA3': 'a','\u00E5': 'a','\u01FB': 'a','\u01CE': 'a','\u0201': 'a','\u0203': 'a','\u1EA1': 'a','\u1EAD': 'a',
+'\u1EB7': 'a','\u1E01': 'a','\u0105': 'a','\u2C65': 'a','\u0250': 'a',
+
+'\uA733': 'aa',
+'\u00E6': 'ae','\u01FD': 'ae','\u01E3': 'ae',
+'\uA735': 'ao',
+'\uA737': 'au',
+'\uA739': 'av','\uA73B': 'av',
+'\uA73D': 'ay',
+'\u0062': 'b','\u24D1': 'b','\uFF42': 'b','\u1E03': 'b','\u1E05': 'b','\u1E07': 'b','\u0180': 'b','\u0183': 'b','\u0253': 'b',
+
+'\u0063': 'c','\u24D2': 'c','\uFF43': 'c','\u0107': 'c','\u0109': 'c','\u010B': 'c','\u010D': 'c','\u00E7': 'c','\u1E09': 'c','\u0188': 'c',
+'\u023C': 'c','\uA73F': 'c','\u2184': 'c',
+
+'\u0064': 'd','\u24D3': 'd','\uFF44': 'd','\u1E0B': 'd','\u010F': 'd','\u1E0D': 'd','\u1E11': 'd','\u1E13': 'd','\u1E0F': 'd','\u0111': 'd',
+'\u018C': 'd','\u0256': 'd','\u0257': 'd','\uA77A': 'd',
+
+'\u01F3': 'dz','\u01C6': 'dz',
+
+'\u0065': 'e','\u24D4': 'e','\uFF45': 'e','\u00E8': 'e','\u00E9': 'e','\u00EA': 'e','\u1EC1': 'e','\u1EBF': 'e','\u1EC5': 'e','\u1EC3': 'e',
+'\u1EBD': 'e','\u0113': 'e','\u1E15': 'e','\u1E17': 'e','\u0115': 'e','\u0117': 'e','\u00EB': 'e','\u1EBB': 'e','\u011B': 'e','\u0205': 'e',
+'\u0207': 'e','\u1EB9': 'e','\u1EC7': 'e','\u0229': 'e','\u1E1D': 'e','\u0119': 'e','\u1E19': 'e','\u1E1B': 'e','\u0247': 'e','\u025B': 'e',
+'\u01DD': 'e',
+
+'\u0066': 'f','\u24D5': 'f','\uFF46': 'f','\u1E1F': 'f','\u0192': 'f','\uA77C': 'f',
+
+'\u0067': 'g','\u24D6': 'g','\uFF47': 'g','\u01F5': 'g','\u011D': 'g','\u1E21': 'g','\u011F': 'g','\u0121': 'g','\u01E7': 'g','\u0123': 'g',
+'\u01E5': 'g','\u0260': 'g','\uA7A1': 'g','\u1D79': 'g','\uA77F': 'g',
+
+'\u0068': 'h','\u24D7': 'h','\uFF48': 'h','\u0125': 'h','\u1E23': 'h','\u1E27': 'h','\u021F': 'h','\u1E25': 'h','\u1E29': 'h','\u1E2B': 'h',
+'\u1E96': 'h','\u0127': 'h','\u2C68': 'h','\u2C76': 'h','\u0265': 'h',
+
+'\u0195': 'hv',
+
+'\u0069': 'i','\u24D8': 'i','\uFF49': 'i','\u00EC': 'i','\u00ED': 'i','\u00EE': 'i','\u0129': 'i','\u012B': 'i','\u012D': 'i','\u00EF': 'i',
+'\u1E2F': 'i','\u1EC9': 'i','\u01D0': 'i','\u0209': 'i','\u020B': 'i','\u1ECB': 'i','\u012F': 'i','\u1E2D': 'i','\u0268': 'i','\u0131': 'i',
+
+'\u006A': 'j','\u24D9': 'j','\uFF4A': 'j','\u0135': 'j','\u01F0': 'j','\u0249': 'j',
+
+'\u006B': 'k','\u24DA': 'k','\uFF4B': 'k','\u1E31': 'k','\u01E9': 'k','\u1E33': 'k','\u0137': 'k','\u1E35': 'k','\u0199': 'k','\u2C6A': 'k',
+'\uA741': 'k','\uA743': 'k','\uA745': 'k','\uA7A3': 'k',
+
+'\u006C': 'l','\u24DB': 'l','\uFF4C': 'l','\u0140': 'l','\u013A': 'l','\u013E': 'l','\u1E37': 'l','\u1E39': 'l','\u013C': 'l','\u1E3D': 'l',
+'\u1E3B': 'l','\u017F': 'l','\u0142': 'l','\u019A': 'l','\u026B': 'l','\u2C61': 'l','\uA749': 'l','\uA781': 'l','\uA747': 'l',
+
+'\u01C9': 'lj',
+'\u006D': 'm','\u24DC': 'm','\uFF4D': 'm','\u1E3F': 'm','\u1E41': 'm','\u1E43': 'm','\u0271': 'm','\u026F': 'm',
+
+'\u006E': 'n','\u24DD': 'n','\uFF4E': 'n','\u01F9': 'n','\u0144': 'n','\u00F1': 'n','\u1E45': 'n','\u0148': 'n','\u1E47': 'n','\u0146': 'n',
+'\u1E4B': 'n','\u1E49': 'n','\u019E': 'n','\u0272': 'n','\u0149': 'n','\uA791': 'n','\uA7A5': 'n',
+
+'\u01CC': 'nj',
+
+'\u006F': 'o','\u24DE': 'o','\uFF4F': 'o','\u00F2': 'o','\u00F3': 'o','\u00F4': 'o','\u1ED3': 'o','\u1ED1': 'o','\u1ED7': 'o','\u1ED5': 'o',
+'\u00F5': 'o','\u1E4D': 'o','\u022D': 'o','\u1E4F': 'o','\u014D': 'o','\u1E51': 'o','\u1E53': 'o','\u014F': 'o','\u022F': 'o','\u0231': 'o',
+'\u00F6': 'o','\u022B': 'o','\u1ECF': 'o','\u0151': 'o','\u01D2': 'o','\u020D': 'o','\u020F': 'o','\u01A1': 'o','\u1EDD': 'o','\u1EDB': 'o',
+'\u1EE1': 'o','\u1EDF': 'o','\u1EE3': 'o','\u1ECD': 'o','\u1ED9': 'o','\u01EB': 'o','\u01ED': 'o','\u00F8': 'o','\u01FF': 'o','\u0254': 'o',
+'\uA74B': 'o','\uA74D': 'o','\u0275': 'o',
+
+'\u01A3': 'oi',
+'\u0223': 'ou',
+'\uA74F': 'oo',
+'\u0070': 'p','\u24DF': 'p','\uFF50': 'p','\u1E55': 'p','\u1E57': 'p','\u01A5': 'p','\u1D7D': 'p','\uA751': 'p','\uA753': 'p','\uA755': 'p',
+'\u0071': 'q','\u24E0': 'q','\uFF51': 'q','\u024B': 'q','\uA757': 'q','\uA759': 'q',
+
+'\u0072': 'r','\u24E1': 'r','\uFF52': 'r','\u0155': 'r','\u1E59': 'r','\u0159': 'r','\u0211': 'r','\u0213': 'r','\u1E5B': 'r','\u1E5D': 'r',
+'\u0157': 'r','\u1E5F': 'r','\u024D': 'r','\u027D': 'r','\uA75B': 'r','\uA7A7': 'r','\uA783': 'r',
+
+'\u0073': 's','\u24E2': 's','\uFF53': 's','\u00DF': 's','\u015B': 's','\u1E65': 's','\u015D': 's','\u1E61': 's','\u0161': 's','\u1E67': 's',
+'\u1E63': 's','\u1E69': 's','\u0219': 's','\u015F': 's','\u023F': 's','\uA7A9': 's','\uA785': 's','\u1E9B': 's',
+
+'\u0074': 't','\u24E3': 't','\uFF54': 't','\u1E6B': 't','\u1E97': 't','\u0165': 't','\u1E6D': 't','\u021B': 't','\u0163': 't','\u1E71': 't',
+'\u1E6F': 't','\u0167': 't','\u01AD': 't','\u0288': 't','\u2C66': 't','\uA787': 't',
+
+'\uA729': 'tz',
+
+'\u0075': 'u','\u24E4': 'u','\uFF55': 'u','\u00F9': 'u','\u00FA': 'u','\u00FB': 'u','\u0169': 'u','\u1E79': 'u','\u016B': 'u','\u1E7B': 'u',
+'\u016D': 'u','\u00FC': 'u','\u01DC': 'u','\u01D8': 'u','\u01D6': 'u','\u01DA': 'u','\u1EE7': 'u','\u016F': 'u','\u0171': 'u','\u01D4': 'u',
+'\u0215': 'u','\u0217': 'u','\u01B0': 'u','\u1EEB': 'u','\u1EE9': 'u','\u1EEF': 'u','\u1EED': 'u','\u1EF1': 'u','\u1EE5': 'u','\u1E73': 'u',
+'\u0173': 'u','\u1E77': 'u','\u1E75': 'u','\u0289': 'u',
+
+'\u0076': 'v','\u24E5': 'v','\uFF56': 'v','\u1E7D': 'v','\u1E7F': 'v','\u028B': 'v','\uA75F': 'v','\u028C': 'v',
+'\uA761': 'vy',
+'\u0077': 'w','\u24E6': 'w','\uFF57': 'w','\u1E81': 'w','\u1E83': 'w','\u0175': 'w','\u1E87': 'w','\u1E85': 'w','\u1E98': 'w','\u1E89': 'w',
+'\u2C73': 'w',
+'\u0078': 'x','\u24E7': 'x','\uFF58': 'x','\u1E8B': 'x','\u1E8D': 'x',
+
+'\u0079': 'y','\u24E8': 'y','\uFF59': 'y','\u1EF3': 'y','\u00FD': 'y','\u0177': 'y','\u1EF9': 'y','\u0233': 'y','\u1E8F': 'y','\u00FF': 'y',
+'\u1EF7': 'y','\u1E99': 'y','\u1EF5': 'y','\u01B4': 'y','\u024F': 'y','\u1EFF': 'y',
+
+'\u007A': 'z','\u24E9': 'z','\uFF5A': 'z','\u017A': 'z','\u1E91': 'z','\u017C': 'z','\u017E': 'z','\u1E93': 'z','\u1E95': 'z','\u01B6': 'z',
+'\u0225': 'z','\u0240': 'z','\u2C6C': 'z','\uA763': 'z',
+};
+
+const patchMap = new WeakMap();
+
+/**
+ * Helper function returning an extraction handler to use on array elements to
+ * return a certain attribute or mutated form of the element.
+ *
+ * @private
+ * @param {string | function} criterion
+ * @returns {(element: any) => any}
+ */
+function _getExtractorFrom(criterion) {
+ if (criterion) {
+ switch (typeof criterion) {
+ case 'string': return element => element[criterion];
+ case 'function': return criterion;
+ default: throw new Error(
+ `Expected criterion of type 'string' or 'function' and got '${typeof criterion}'`
+ );
+ }
+ } else {
+ return element => element;
+ }
+}
+
+var utils = {
+
+ /**
+ * Throws an error if the given condition is not true
+ *
+ * @param {any} bool
+ */
+ assert: function (bool) {
+ if (!bool) {
+ throw new Error("AssertionError");
+ }
+ },
+ /**
+ * Check if the value is a bin_size or not.
+ * If not, compute an approximate size out of the base64 encoded string.
+ *
+ * @param {string} value original format
+ * @return {string} bin_size (human-readable)
+ */
+ binaryToBinsize: function (value) {
+ if (!this.is_bin_size(value)) {
+ // Computing approximate size out of base64 encoded string
+ // http://en.wikipedia.org/wiki/Base64#MIME
+ return this.human_size(value.length / 1.37);
+ }
+ // already bin_size
+ return value;
+ },
+ /**
+ * 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));
+ },
+ /**
+ * Looks through the list and returns the first value that matches all
+ * of the key-value pairs listed in properties.
+ * If no match is found, or if list is empty, undefined will be returned.
+ *
+ * @param {Array} list
+ * @param {Object} props
+ * @returns {any|undefined} first element in list that matches all props
+ */
+ findWhere: function (list, props) {
+ if (!Array.isArray(list) || !props) {
+ return;
+ }
+ return list.filter((item) => item !== undefined).find((item) => {
+ return Object.keys(props).every((key) => {
+ return item[key] === props[key];
+ })
+ });
+ },
+ /**
+ * @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 "";
+ },
+ /**
+ * Gets dataURL (base64 data) from the given file or blob.
+ * Technically wraps FileReader.readAsDataURL in Promise.
+ *
+ * @param {Blob|File} file
+ * @returns {Promise} resolved with the dataURL, or rejected if the file is
+ * empty or if an error occurs.
+ */
+ getDataURLFromFile: function (file) {
+ if (!file) {
+ return Promise.reject();
+ }
+ return new Promise(function (resolve, reject) {
+ var reader = new FileReader();
+ reader.addEventListener('load', function () {
+ resolve(reader.result);
+ });
+ reader.addEventListener('abort', reject);
+ reader.addEventListener('error', reject);
+ reader.readAsDataURL(file);
+ });
+ },
+ /**
+ * Returns an object holding different groups defined by a given criterion
+ * or a default one. Each group is a subset of the original given list.
+ * The given criterion can either be:
+ * - a string: a property name on the list elements which value will be the
+ * group name,
+ * - a function: a handler that will return the group name from a given
+ * element.
+ *
+ * @param {any[]} list
+ * @param {string | function} [criterion]
+ * @returns {Object}
+ */
+ groupBy: function (list, criterion) {
+ const extract = _getExtractorFrom(criterion);
+ const groups = {};
+ for (const element of list) {
+ const group = String(extract(element));
+ if (!(group in groups)) {
+ groups[group] = [];
+ }
+ groups[group].push(element);
+ }
+ return groups;
+ },
+ /**
+ * Returns a human readable number (e.g. 34000 -> 34k).
+ *
+ * @param {number} number
+ * @param {integer} [decimals=0]
+ * maximum number of decimals to use in human readable representation
+ * @param {integer} [minDigits=1]
+ * the minimum number of digits to preserve when switching to another
+ * level of thousands (e.g. with a value of '2', 4321 will still be
+ * represented as 4321 otherwise it will be down to one digit (4k))
+ * @param {function} [formatterCallback]
+ * a callback to transform the final number before adding the
+ * thousands symbol (default to adding thousands separators (useful
+ * if minDigits > 1))
+ * @returns {string}
+ */
+ human_number: function (number, decimals, minDigits, formatterCallback) {
+ number = Math.round(number);
+ decimals = decimals | 0;
+ minDigits = minDigits || 1;
+ formatterCallback = formatterCallback || utils.insert_thousand_seps;
+
+ var d2 = Math.pow(10, decimals);
+ var val = _t('kMGTPE');
+ var symbol = '';
+ var numberMagnitude = number.toExponential().split('e')[1];
+ // the case numberMagnitude >= 21 corresponds to a number
+ // better expressed in the scientific format.
+ if (numberMagnitude >= 21) {
+ // we do not use number.toExponential(decimals) because we want to
+ // avoid the possible useless O decimals: 1e.+24 preferred to 1.0e+24
+ number = Math.round(number * Math.pow(10, decimals - numberMagnitude)) / d2;
+ // formatterCallback seems useless here.
+ return number + 'e' + numberMagnitude;
+ }
+ var sign = Math.sign(number);
+ number = Math.abs(number);
+ for (var i = val.length; i > 0 ; i--) {
+ var s = Math.pow(10, i * 3);
+ if (s <= number / Math.pow(10, minDigits - 1)) {
+ number = Math.round(number * d2 / s) / d2;
+ symbol = val[i - 1];
+ break;
+ }
+ }
+ number = sign * number;
+ return formatterCallback('' + number) + symbol;
+ },
+ /**
+ * Returns a human readable size
+ *
+ * @param {Number} size 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].trim();
+ },
+ /**
+ * 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*)? [^0-9]+$/).test(v);
+ },
+ /**
+ * Checks if a class is an extension of owl.Component.
+ *
+ * @param {any} value A class reference
+ */
+ isComponent: function (value) {
+ return value.prototype instanceof owl.Component;
+ },
+ /**
+ * Returns whether the given anchor is valid.
+ *
+ * This test is useful to prevent a crash that would happen if using an invalid
+ * anchor as a selector.
+ *
+ * @param {string} anchor
+ * @returns {boolean}
+ */
+ isValidAnchor: function (anchor) {
+ return /^#[\w-]+$/.test(anchor);
+ },
+ /**
+ * @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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ } 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ if (human_readable) {
+ vattr = vattr.replace(/&quot;/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;
+ },
+ /**
+ * @param {any[]} arr
+ * @param {Function} fn
+ * @returns {any[]}
+ */
+ partitionBy(arr, fn) {
+ let lastGroup = false;
+ let lastValue;
+ return arr.reduce((acc, cur) => {
+ let curVal = fn(cur);
+ if (lastGroup) {
+ if (curVal === lastValue) {
+ lastGroup.push(cur);
+ } else {
+ lastGroup = false;
+ }
+ }
+ if (!lastGroup) {
+ lastGroup = [cur];
+ acc.push(lastGroup);
+ }
+ lastValue = curVal;
+ return acc;
+ }, []);
+ },
+ /**
+ * Patch a class and return a function that remove the patch
+ * when called.
+ *
+ * This function is the last resort solution for monkey-patching an
+ * ES6 Class, for people that do not control the code defining the Class
+ * to patch (e.g. partners), and when that Class isn't patchable already
+ * (i.e. when it doesn't have a 'patch' function, defined by the 'web.patchMixin').
+ *
+ * @param {Class} C Class to patch
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {Function}
+ */
+ patch: function (C, patchName, patch) {
+ let metadata = patchMap.get(C.prototype);
+ if (!metadata) {
+ metadata = {
+ origMethods: {},
+ patches: {},
+ current: []
+ };
+ patchMap.set(C.prototype, metadata);
+ }
+ const proto = C.prototype;
+ if (metadata.patches[patchName]) {
+ throw new Error(`Patch [${patchName}] already exists`);
+ }
+ metadata.patches[patchName] = patch;
+ applyPatch(proto, patch);
+ metadata.current.push(patchName);
+
+ function applyPatch(proto, patch) {
+ Object.keys(patch).forEach(function (methodName) {
+ const method = patch[methodName];
+ if (typeof method === "function") {
+ const original = proto[methodName];
+ if (!(methodName in metadata.origMethods)) {
+ metadata.origMethods[methodName] = original;
+ }
+ proto[methodName] = function (...args) {
+ const previousSuper = this._super;
+ this._super = original;
+ const res = method.call(this, ...args);
+ this._super = previousSuper;
+ return res;
+ };
+ }
+ });
+ }
+
+ return utils.unpatch.bind(null, C, patchName);
+ },
+ /**
+ * 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} value the value to be rounded
+ * @param {Number} decimals the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
+ */
+ round_decimals: function (value, decimals) {
+ /**
+ * The following decimals introduce numerical errors:
+ * Math.pow(10, -4) = 0.00009999999999999999
+ * Math.pow(10, -5) = 0.000009999999999999999
+ *
+ * Such errors will propagate in round_precision and lead to inconsistencies between Python
+ * and JavaScript. To avoid this, we parse the scientific notation.
+ */
+ return utils.round_precision(value, parseFloat('1e' + -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 - 52);
+ 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(';');
+ },
+ /**
+ * Return a shallow copy of a given array sorted by a given criterion or a default one.
+ * The given criterion can either be:
+ * - a string: a property name on the array elements returning the sortable primitive
+ * - a function: a handler that will return the sortable primitive from a given element.
+ *
+ * @param {any[]} array
+ * @param {string | function} [criterion]
+ */
+ sortBy: function (array, criterion) {
+ const extract = _getExtractorFrom(criterion);
+ return array.slice().sort((elA, elB) => {
+ const a = extract(elA);
+ const b = extract(elB);
+ if (isNaN(a) && isNaN(b)) {
+ return a > b ? 1 : a < b ? -1 : 0;
+ } else {
+ return a - b;
+ }
+ });
+ },
+ /**
+ * Returns a string formatted using given values.
+ * If the value is an object, its keys will replace `%(key)s` expressions.
+ * If the values are a set of strings, they will replace `%s` expressions.
+ * If no value is given, the string will not be formatted.
+ *
+ * @param {string} string
+ * @param {(Object|...string)} values
+ */
+ sprintf: function (string, ...values) {
+ if (values.length === 1 && typeof values[0] === 'object') {
+ const valuesDict = values[0];
+ for (const value in valuesDict) {
+ string = string.replace(`%(${value})s`, valuesDict[value]);
+ }
+ } else {
+ for (const value of values) {
+ string = string.replace(/%s/, value);
+ }
+ }
+ return string;
+ },
+ /**
+ * Sort an array in place, keeping the initial order for identical values.
+ *
+ * @param {Array} array
+ * @param {function} iteratee
+ */
+ stableSort: function (array, iteratee) {
+ var stable = array.slice();
+ return array.sort(function stableCompare (a, b) {
+ var order = iteratee(a, b);
+ if (order !== 0) {
+ return order;
+ } else {
+ return stable.indexOf(a) - stable.indexOf(b);
+ }
+ });
+ },
+ /**
+ * @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 {string} value
+ * @param {boolean} allow_mailto
+ * @returns boolean
+ */
+ is_email: function (value, allow_mailto) {
+ // http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
+ var re;
+ if (allow_mailto) {
+ re = /^(mailto:)?(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ } else {
+ re = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ }
+ return re.test(value);
+ },
+
+ /**
+ * @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);
+ }
+ }
+ },
+ /**
+ * Replace diacritics character with ASCII character
+ *
+ * @param {string} str diacritics string
+ * @param {boolean} casesensetive
+ * @returns {string} ASCII string
+ */
+ unaccent: function (str, casesensetive) {
+ str = str.replace(/[^\u0000-\u007E]/g, function (accented) {
+ return diacriticsMap[accented] || accented;
+ });
+ return casesensetive ? str : str.toLowerCase();
+ },
+ /**
+ * We define here an unpatch function. This is mostly useful if we want to
+ * remove a patch. For example, for testing purposes
+ *
+ * @param {Class} C
+ * @param {string} patchName
+ */
+ unpatch: function (C, patchName) {
+ const proto = C.prototype;
+ let metadata = patchMap.get(proto);
+ if (!metadata) {
+ return;
+ }
+ patchMap.delete(proto);
+
+ // reset to original
+ for (let k in metadata.origMethods) {
+ proto[k] = metadata.origMethods[k];
+ }
+
+ // apply other patches
+ for (let name of metadata.current) {
+ if (name !== patchName) {
+ utils.patch(C, name, metadata.patches[name]);
+ }
+ }
+ },
+ /**
+ * @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();
+ 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;
+ },
+ /**
+ * Visit a tree of objects, where each children are in an attribute 'children'.
+ * For each children, we call the callback function given in arguments.
+ *
+ * @param {Object} tree an object describing a tree structure
+ * @param {function} f a callback
+ */
+ traverse: function (tree, f) {
+ if (f(tree)) {
+ _.each(tree.children, function (c) { utils.traverse(c, f); });
+ }
+ },
+ /**
+ * Enhanced traverse function with 'path' building on traverse.
+ *
+ * @param {Object} tree an object describing a tree structure
+ * @param {function} f a callback
+ * @param {Object} path the path to the current 'tree' object
+ */
+ traversePath: function (tree, f, path) {
+ path = path || [];
+ f(tree, path);
+ _.each(tree.children, function (node) {
+ utils.traversePath(node, f, path.concat(tree));
+ });
+ },
+ /**
+ * Visit a tree of objects and freeze all
+ *
+ * @param {Object} obj
+ */
+ deepFreeze: function (obj) {
+ var propNames = Object.getOwnPropertyNames(obj);
+ propNames.forEach(function(name) {
+ var prop = obj[name];
+ if (typeof prop == 'object' && prop !== null)
+ utils.deepFreeze(prop);
+ });
+ return Object.freeze(obj);
+ },
+
+ /**
+ * Find the closest value of the given one in the provided array
+ *
+ * @param {Number} num
+ * @param {Array} arr
+ * @returns {Number|undefined}
+ */
+ closestNumber: function (num, arr) {
+ var curr = arr[0];
+ var diff = Math.abs (num - curr);
+ for (var val = 0; val < arr.length; val++) {
+ var newdiff = Math.abs (num - arr[val]);
+ if (newdiff < diff) {
+ diff = newdiff;
+ curr = arr[val];
+ }
+ }
+ return curr;
+ },
+ /**
+ * Returns the domain targeting assets files.
+ *
+ * @returns {Array} Domain of assets files
+ */
+ assetsDomain: function () {
+ return [
+ '&',
+ ['res_model', '=', 'ir.ui.view'],
+ '|',
+ ['name', '=like', '%.assets\_%.css'],
+ ['name', '=like', '%.assets\_%.js'],
+ ];
+ },
+};
+
+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 00000000..8b827147
--- /dev/null
+++ b/addons/web/static/src/js/core/widget.js
@@ -0,0 +1,447 @@
+odoo.define('web.Widget', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+
+/**
+ * Base class for all visual components. Provides a lot of functions 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::
+ *
+ * var MyWidget = 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
+ * },
+ * willStart: function () {
+ * // async work that need to be done before the widget is ready
+ * // this method should return a promise
+ * },
+ * 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(). Note that this is quite rare, and if you
+ * // need to fetch some data, this should probably be done in the
+ * // willStart method
+ * var promise = this._rpc(...);
+ * return promise;
+ * }
+ * });
+ *
+ * Now this class can simply be used with the following syntax::
+ *
+ * var myWidget = new MyWidget(this);
+ * myWidget.appendTo($(".some-div"));
+ *
+ * With these two lines, the MyWidget instance was initialized, rendered,
+ * inserted into the DOM inside the ``.some-div`` div and its events were
+ * bound.
+ *
+ * And of course, when you don't need that widget anymore, just do::
+ *
+ * myWidget.destroy();
+ *
+ * That will kill the widget in a clean way and erase its content from the dom.
+ */
+
+var Widget = core.Class.extend(mixins.PropertiesMixin, ServicesMixin, {
+ // Backbone-ish API
+ tagName: 'div',
+ id: null,
+ className: null,
+ attributes: {},
+ 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 {null|string}
+ */
+ template: null,
+ /**
+ * List of paths to xml files that need to be loaded before the widget can
+ * be rendered. This will not induce loading anything that has already been
+ * loaded.
+ *
+ * @type {null|string[]}
+ */
+ xmlDependencies: null,
+ /**
+ * List of paths to css files that need to be loaded before the widget can
+ * be rendered. This will not induce loading anything that has already been
+ * loaded.
+ *
+ * @type {null|string[]}
+ */
+ cssLibs: null,
+ /**
+ * List of paths to js files that need to be loaded before the widget can
+ * be rendered. This will not induce loading anything that has already been
+ * loaded.
+ *
+ * @type {null|string[]}
+ */
+ jsLibs: null,
+ /**
+ * List of xmlID that need to be loaded before the widget can be rendered.
+ * The content css (link file or style tag) and js (file or inline) of the
+ * assets are loaded.
+ * This will not induce loading anything that has already been
+ * loaded.
+ *
+ * @type {null|string[]}
+ */
+ assetLibs: null,
+
+ /**
+ * Constructs the widget and sets its parent if a parent is given.
+ *
+ * @param {Widget|null} 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] = this[name].bind(this);
+ }
+ }
+ }
+ },
+ /**
+ * Method called between @see init and @see start. Performs asynchronous
+ * calls required by the rendering and the start method.
+ *
+ * This method should return a Promose which is resolved when start can be
+ * executed.
+ *
+ * @returns {Promise}
+ */
+ willStart: function () {
+ var proms = [];
+ if (this.xmlDependencies) {
+ proms.push.apply(proms, _.map(this.xmlDependencies, function (xmlPath) {
+ return ajax.loadXML(xmlPath, core.qweb);
+ }));
+ }
+ if (this.jsLibs || this.cssLibs || this.assetLibs) {
+ proms.push(this._loadLibs(this));
+ }
+ return Promise.all(proms);
+ },
+ /**
+ * 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
+ * Promise.resolve() to inform the caller when this widget has been initialized.
+ *
+ * Note that, for historic reasons, many widgets still do work in the start
+ * method that would be more suited to the willStart method.
+ *
+ * @returns {Promise}
+ */
+ start: function () {
+ return Promise.resolve();
+ },
+ /**
+ * Destroys the current widget, also destroys all its children before
+ * destroying itself.
+ */
+ destroy: function () {
+ mixins.PropertiesMixin.destroy.call(this);
+ if (this.$el) {
+ this.$el.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders the current widget and appends it to the given jQuery object.
+ *
+ * @param {jQuery} target
+ * @returns {Promise}
+ */
+ appendTo: function (target) {
+ var self = this;
+ return this._widgetRenderAndInsert(function (t) {
+ self.$el.appendTo(t);
+ }, target);
+ },
+ /**
+ * Attach the current widget to a dom element
+ *
+ * @param {jQuery} target
+ * @returns {Promise}
+ */
+ attachTo: function (target) {
+ var self = this;
+ this.setElement(target.$el || target);
+ return this.willStart().then(function () {
+ if (self.__parentedDestroyed) {
+ return;
+ }
+ return self.start();
+ });
+ },
+ /**
+ * Hides the widget
+ */
+ do_hide: function () {
+ if (this.$el) {
+ this.$el.addClass('o_hidden');
+ }
+ },
+ /**
+ * Displays the widget
+ */
+ do_show: function () {
+ if (this.$el) {
+ this.$el.removeClass('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 if (this.$el) {
+ this.$el.hasClass('o_hidden') ? this.do_show() : this.do_hide();
+ }
+ },
+ /**
+ * Renders the current widget and inserts it after to the given jQuery
+ * object.
+ *
+ * @param {jQuery} target
+ * @returns {Promise}
+ */
+ 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.
+ *
+ * @param {jQuery} target
+ * @returns {Promise}
+ */
+ insertBefore: function (target) {
+ var self = this;
+ return this._widgetRenderAndInsert(function (t) {
+ self.$el.insertBefore(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and prepends it to the given jQuery object.
+ *
+ * @param {jQuery} target
+ * @returns {Promise}
+ */
+ prependTo: function (target) {
+ var self = this;
+ return this._widgetRenderAndInsert(function (t) {
+ self.$el.prependTo(t);
+ }, target);
+ },
+ /**
+ * 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._makeDescriptive();
+ }
+ this._replaceElement($el);
+ },
+ /**
+ * Renders the current widget and replaces the given jQuery object.
+ *
+ * @param target A jQuery object or a Widget instance.
+ * @returns {Promise}
+ */
+ replace: function (target) {
+ return this._widgetRenderAndInsert(_.bind(function (t) {
+ this.$el.replaceAll(t);
+ }, this), target);
+ },
+ /**
+ * 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 {Widget} this
+ */
+ setElement: function (element) {
+ if (this.$el) {
+ this._undelegateEvents();
+ }
+
+ this.$el = (element instanceof $) ? element : $(element);
+ this.el = this.$el[0];
+
+ this._delegateEvents();
+
+ return this;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Helper method, for ``this.$el.find(selector)``
+ *
+ * @private
+ * @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);
+ },
+ /**
+ * Attach event handlers for events described in the 'events' key
+ *
+ * @private
+ */
+ _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);
+ }
+ }
+ },
+ /**
+ * Makes a potential root element from the declarative builder of the
+ * widget
+ *
+ * @private
+ * @return {jQuery}
+ */
+ _makeDescriptive: function () {
+ var attrs = _.extend({}, this.attributes || {});
+ if (this.id) {
+ attrs.id = this.id;
+ }
+ if (this.className) {
+ attrs['class'] = this.className;
+ }
+ var $el = $(document.createElement(this.tagName));
+ if (!_.isEmpty(attrs)) {
+ $el.attr(attrs);
+ }
+ return $el;
+ },
+ /**
+ * Re-sets the widget's root element and replaces the old root element
+ * (if any) by the new one in the DOM.
+ *
+ * @private
+ * @param {HTMLElement | jQuery} $el
+ * @returns {Widget} this instance, so it can be chained
+ */
+ _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;
+ },
+ /**
+ * Remove all handlers registered on this.$el
+ *
+ * @private
+ */
+ _undelegateEvents: function () {
+ this.$el.off('.widget_events');
+ },
+ /**
+ * Render the widget. This is a private method, and should really never be
+ * called by anyone (except this widget). It assumes that the widget was
+ * not willStarted yet.
+ *
+ * @private
+ * @param {function: jQuery -> any} insertion
+ * @param {jQuery} target
+ * @returns {Promise}
+ */
+ _widgetRenderAndInsert: function (insertion, target) {
+ var self = this;
+ return this.willStart().then(function () {
+ if (self.__parentedDestroyed) {
+ return;
+ }
+ self.renderElement();
+ insertion(target);
+ return self.start();
+ });
+ },
+});
+
+return Widget;
+
+});