diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/core | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/core')
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('​'); // 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) { + throw new Error( + _.str.sprintf(_t("Node [%s] is not a JSONified XML node"), + JSON.stringify(node))); + } + for (var attr in node.attrs) { + var vattr = node.attrs[attr]; + if (typeof(vattr) !== 'string') { + // domains, ... + vattr = JSON.stringify(vattr); + } + vattr = vattr.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + if (human_readable) { + vattr = vattr.replace(/"/g, "'"); + } + r += ' ' + attr + '="' + vattr + '"'; + } + if (node.children && node.children.length) { + r += '>' + cr; + var childs = []; + for (var i = 0, ii = node.children.length; i < ii; i++) { + childs.push(utils.json_node_to_xml(node.children[i], human_readable, indent + 1)); + } + r += childs.join(cr); + r += cr + sindent + '</' + node.tag + '>'; + return r; + } else { + return r + '/>'; + } + }, + /** + * Left-pad provided arg 1 with zeroes until reaching size provided by second + * argument. + * + * @see rpad + * + * @param {number|string} str value to pad + * @param {number} size size to reach on the final padded value + * @returns {string} padded string + */ + lpad: function (str, size) { + str = "" + str; + return new Array(size - str.length + 1).join('0') + str; + }, + /** + * @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; + +}); |
