diff options
Diffstat (limited to 'addons/web/static/src/js')
205 files changed, 71535 insertions, 0 deletions
diff --git a/addons/web/static/src/js/_deprecated/basic_fields.js b/addons/web/static/src/js/_deprecated/basic_fields.js new file mode 100644 index 00000000..2a70c379 --- /dev/null +++ b/addons/web/static/src/js/_deprecated/basic_fields.js @@ -0,0 +1,154 @@ +//////////////////////////////////////////////////////////////////////////////// +// /!\ DEPRECATED +// +// Legacy Field Widgets are added in this file when they are converted into +// Owl Component. +//////////////////////////////////////////////////////////////////////////////// + +odoo.define('web.basic_fields.deprecated', function (require) { +"use strict"; + +/** + * This module contains most of the basic (meaning: non relational) field + * widgets. Field widgets are supposed to be used in views inheriting from + * BasicView, so, they can work with the records obtained from a BasicModel. + */ + +var AbstractField = require('web.AbstractField'); +var core = require('web.core'); + +var _lt = core._lt; + +var FieldBoolean = AbstractField.extend({ + className: 'o_field_boolean', + description: _lt("Checkbox"), + events: _.extend({}, AbstractField.prototype.events, { + change: '_onChange', + }), + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Toggle the checkbox if it is activated due to a click on itself. + * + * @override + */ + activate: function (options) { + var activated = this._super.apply(this, arguments); + // The formatValue of boolean fields renders HTML elements similar to + // the one rendered by the widget itself. Even though the event might + // have been fired on the non-widget version of this field, we can still + // test the presence of its custom class. + if (activated && options && options.event && $(options.event.target).closest('.custom-control.custom-checkbox').length) { + this._setValue(!this.value); // Toggle the checkbox + } + return activated; + }, + + /** + * @override + * @returns {jQuery} the focusable checkbox input + */ + getFocusableElement: function () { + return this.mode === 'readonly' ? $() : this.$input; + }, + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + /** + * When the checkbox is rerendered, we need to check if it was the actual + * origin of the reset. If it is, we need to activate it back so it looks + * like it was not rerendered but is still the same input. + * + * @override + */ + reset: function (record, event) { + var rendered = this._super.apply(this, arguments); + if (event && event.target.name === this.name) { + this.activate(); + } + return rendered; + }, + /** + * Associates the 'for' attribute of the internal label. + * + * @override + */ + setIDForLabel: function (id) { + this._super.apply(this, arguments); + this.$('.custom-control-label').attr('for', id); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * The actual checkbox is designed in css to have full control over its + * appearance, as opposed to letting the browser and the os decide how + * a checkbox should look. The actual input is disabled and hidden. In + * readonly mode, the checkbox is disabled. + * + * @override + * @private + */ + _render: function () { + var $checkbox = this._formatValue(this.value); + this.$input = $checkbox.find('input'); + this.$input.prop('disabled', this.mode === 'readonly'); + this.$el.addClass($checkbox.attr('class')); + this.$el.empty().append($checkbox.contents()); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Properly update the value when the checkbox is (un)ticked to trigger + * possible onchanges. + * + * @private + */ + _onChange: function () { + this._setValue(this.$input[0].checked); + }, + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @private + * @param {KeyEvent} ev + */ + _onKeydown: function (ev) { + switch (ev.which) { + case $.ui.keyCode.ENTER: + // prevent subsequent 'click' event (see _onKeydown of AbstractField) + ev.preventDefault(); + this.$input.prop('checked', !this.value); + this._setValue(!this.value); + return; + case $.ui.keyCode.UP: + case $.ui.keyCode.RIGHT: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.LEFT: + ev.preventDefault(); + } + this._super.apply(this, arguments); + }, +}); + +return { + FieldBoolean: FieldBoolean, +}; + +}); diff --git a/addons/web/static/src/js/_deprecated/data.js b/addons/web/static/src/js/_deprecated/data.js new file mode 100644 index 00000000..71ae101d --- /dev/null +++ b/addons/web/static/src/js/_deprecated/data.js @@ -0,0 +1,852 @@ +odoo.define('web.data', function (require) { +"use strict"; + +var Class = require('web.Class'); +var Context = require('web.Context'); +var concurrency = require('web.concurrency'); +var mixins = require('web.mixins'); +var session = require('web.session'); +var translation = require('web.translation'); +var pyUtils = require('web.py_utils'); + +var _t = translation._t; + +/** + * Serializes the sort criterion array of a dataset into a form which can be + * consumed by OpenERP's RPC APIs. + * + * @param {Array} criterion array of fields, from first to last criteria, prefixed with '-' for reverse sorting + * @returns {String} SQL-like sorting string (``ORDER BY``) clause + */ +function serialize_sort(criterion) { + return _.map(criterion, + function (criteria) { + if (criteria[0] === '-') { + return criteria.slice(1) + ' DESC'; + } + return criteria + ' ASC'; + }).join(', '); +} + +/** + * Reverse of the serialize_sort function: convert an array of SQL-like sort + * descriptors into a list of fields prefixed with '-' if necessary. + */ +function deserialize_sort(criterion) { + return _.map(criterion, function (criteria) { + var split = _.without(criteria.split(' '), ''); + return (split[1] && split[1].toLowerCase() === 'desc' ? '-' : '') + split[0]; + }); +} + +var Query = Class.extend({ + init: function (model, fields) { + this._model = model; + this._fields = fields; + this._filter = []; + this._context = {}; + this._lazy = true; + this._limit = false; + this._offset = 0; + this._order_by = []; + }, + clone: function (to_set) { + to_set = to_set || {}; + var q = new Query(this._model, this._fields); + q._context = this._context; + q._filter = this._filter; + q._lazy = this._lazy; + q._limit = this._limit; + q._offset = this._offset; + q._order_by = this._order_by; + + for(var key in to_set) { + if (!to_set.hasOwnProperty(key)) { continue; } + switch(key) { + case 'filter': + q._filter = (q._filter || []).concat(to_set.filter || []); + break; + case 'context': + q._context = new Context( + q._context, to_set.context); + break; + case 'lazy': + case 'limit': + case 'offset': + case 'order_by': + q['_' + key] = to_set[key]; + } + } + return q; + }, + _execute: function (options) { + var self = this; + options = options || {}; + return session.rpc('/web/dataset/search_read', { + model: this._model.name, + fields: this._fields || false, + domain: pyUtils.eval('domains', + [this._model.domain(this._filter)]), + context: pyUtils.eval('contexts', + [this._model.context(this._context)]), + offset: this._offset, + limit: this._limit, + sort: serialize_sort(this._order_by) + }, options).then(function (results) { + self._count = results.length; + return results.records; + }, null); + }, + /** + * Fetches the first record matching the query, or null + * + * @param {Object} [options] additional options for the rpc() method + * @returns {Promise<Object|null>} + */ + first: function (options) { + var self = this; + return this.clone({limit: 1})._execute(options).then(function (records) { + delete self._count; + if (records.length) { return records[0]; } + return null; + }); + }, + /** + * Fetches all records matching the query + * + * @param {Object} [options] additional options for the rpc() method + * @returns {Promise<Array<>>} + */ + all: function (options) { + return this._execute(options); + }, + /** + * Fetches the number of records matching the query in the database + * + * @returns {Promise<Number>} + */ + count: function () { + if (this._count !== undefined) { return Promise.resolve(this._count); } + return this._model.call( + 'search_count', [this._filter], { + context: this._model.context(this._context)}); + }, + /** + * Performs a groups read according to the provided grouping criterion + * + * @param {String|Array<String>} grouping + * @returns {jQuery.Deferred<Array<openerp.web.QueryGroup>> | null} + */ + group_by: function (grouping) { + var ctx = pyUtils.eval( + 'context', this._model.context(this._context)); + + // undefined passed in explicitly (!) + if (_.isUndefined(grouping)) { + grouping = []; + } + + if (!(grouping instanceof Array)) { + grouping = _.toArray(arguments); + } + if (_.isEmpty(grouping) && !ctx.group_by_no_leaf) { + return null; + } + var raw_fields = _.map(grouping.concat(this._fields || []), function (field) { + return field.split(':')[0]; + }); + + var self = this; + return this._model.call('read_group', { + groupby: grouping, + fields: _.uniq(raw_fields), + domain: this._model.domain(this._filter), + context: ctx, + offset: this._offset, + lazy: this._lazy, + limit: this._limit, + orderby: serialize_sort(this._order_by) || false + }).then(function (results) { + return _(results).map(function (result) { + // FIX: querygroup initialization + result.__context = result.__context || {}; + result.__context.group_by = result.__context.group_by || []; + _.defaults(result.__context, ctx); + var grouping_fields = self._lazy ? [grouping[0]] : grouping; + return new QueryGroup( + self._model.name, grouping_fields, result); + }); + }); + }, + /** + * Creates a new query with the union of the current query's context and + * the new context. + * + * @param context context data to add to the query + * @returns {openerp.web.Query} + */ + context: function (context) { + if (!context) { return this; } + return this.clone({context: context}); + }, + /** + * Creates a new query with the union of the current query's filter and + * the new domain. + * + * @param domain domain data to AND with the current query filter + * @returns {openerp.web.Query} + */ + filter: function (domain) { + if (!domain) { return this; } + return this.clone({filter: domain}); + }, + /** + * Creates a new query with the provided parameter lazy replacing the current + * query's own. + * + * @param {Boolean} lazy indicates if the read_group should return only the + * first level of groupby records, or should return the records grouped by + * all levels at once (so, it makes only 1 db request). + * @returns {openerp.web.Query} + */ + lazy: function (lazy) { + return this.clone({lazy: lazy}); + }, + /** + * Creates a new query with the provided limit replacing the current + * query's own limit + * + * @param {Number} limit maximum number of records the query should retrieve + * @returns {openerp.web.Query} + */ + limit: function (limit) { + return this.clone({limit: limit}); + }, + /** + * Creates a new query with the provided offset replacing the current + * query's own offset + * + * @param {Number} offset number of records the query should skip before starting its retrieval + * @returns {openerp.web.Query} + */ + offset: function (offset) { + return this.clone({offset: offset}); + }, + /** + * Creates a new query with the provided ordering parameters replacing + * those of the current query + * + * @param {String...} fields ordering clauses + * @returns {openerp.web.Query} + */ + order_by: function (fields) { + if (fields === undefined) { return this; } + if (!(fields instanceof Array)) { + fields = _.toArray(arguments); + } + if (_.isEmpty(fields)) { return this; } + return this.clone({order_by: fields}); + } +}); + +var QueryGroup = Class.extend({ + init: function (model, grouping_fields, read_group_group) { + // In cases where group_by_no_leaf and no group_by, the result of + // read_group has aggregate fields but no __context or __domain. + // Create default (empty) values for those so that things don't break + var fixed_group = _.extend( + {__context: {group_by: []}, __domain: []}, + read_group_group); + + var count_key = (grouping_fields[0] && grouping_fields[0].split(':')[0]) + '_count'; + var aggregates = {}; + for (var key in fixed_group) { + if (fixed_group.hasOwnProperty(key)) { + if (!(key.indexOf('__') === 0 + || _.contains(grouping_fields, key) + || (key === count_key))) { + aggregates[key] = fixed_group[key] || 0; + } + } + } + + this.model = new Model( + model, fixed_group.__context, fixed_group.__domain); + + var group_size = fixed_group[count_key] || fixed_group.__count || 0; + var leaf_group = fixed_group.__context.group_by.length === 0; + + var value = (grouping_fields.length === 1) + ? fixed_group[grouping_fields[0]] + : _.map(grouping_fields, function (field) { return fixed_group[field]; }); + var grouped_on = (grouping_fields.length === 1) + ? grouping_fields[0] + : grouping_fields; + this.attributes = { + folded: !!(fixed_group.__fold), + grouped_on: grouped_on, + // if terminal group (or no group) and group_by_no_leaf => use group.__count + length: group_size, + value: value, + // A group is open-able if it's not a leaf in group_by_no_leaf mode + has_children: !(leaf_group && fixed_group.__context.group_by_no_leaf), + + aggregates: aggregates + }; + }, + get: function (key) { + return this.attributes[key]; + }, + subgroups: function () { + return this.model.query().group_by(this.model.context().group_by); + }, + query: function () { + return this.model.query.apply(this.model, arguments); + } +}); + +var DataSet = Class.extend(mixins.PropertiesMixin, { + /** + * Collection of OpenERP records, used to share records and the current selection between views. + * + * @constructs instance.web.DataSet + * + * @param {String} model the OpenERP model this dataset will manage + */ + init: function (parent, model, context) { + mixins.PropertiesMixin.init.call(this); + this.setParent(parent); + this.model = model; + this.context = context || {}; + this.index = null; + this._sort = []; + this._model = new Model(model, context); + this.orderer = new concurrency.DropMisordered(); + }, + previous: function () { + this.index -= 1; + if (!this.ids.length) { + this.index = null; + } else if (this.index < 0) { + this.index = this.ids.length - 1; + } + return this; + }, + next: function () { + this.index += 1; + if (!this.ids.length) { + this.index = null; + } else if (this.index >= this.ids.length) { + this.index = 0; + } + return this; + }, + select_id: function (id) { + var idx = this.get_id_index(id); + if (idx === null) { + return false; + } else { + this.index = idx; + return true; + } + }, + get_id_index: function (id) { + for (var i=0, ii=this.ids.length; i<ii; i++) { + // Here we use type coercion because of the mess potentially caused by + // OpenERP ids fetched from the DOM as string. (eg: dhtmlxcalendar) + // OpenERP ids can be non-numeric too ! (eg: recursive events in calendar) + if (id == this.ids[i]) { + return i; + } + } + return null; + }, + /** + * Read records. + * + * @param {Array} ids identifiers of the records to read + * @param {Array} [fields] fields to read and return, by default all fields are returned + * @param {Object} [options] + * @returns {Promise} + */ + read_ids: function (ids, fields, options) { + if (_.isEmpty(ids)) { + return Promise.resolve([]); + } + + options = options || {}; + var method = 'read'; + var ids_arg = ids; + var context = this.get_context(options.context); + if (options.check_access_rule === true){ + method = 'search_read'; + ids_arg = [['id', 'in', ids]]; + context = new Context(context, {active_test: false}); + } + return this._model.call(method, + [ids_arg, fields || false], + {context: context}) + .then(function (records) { + if (records.length <= 1) { return records; } + var indexes = {}; + for (var i = 0; i < ids.length; i++) { + indexes[ids[i]] = i; + } + records.sort(function (a, b) { + return indexes[a.id] - indexes[b.id]; + }); + return records; + }); + }, + /** + * Read a slice of the records represented by this DataSet, based on its + * domain and context. + * + * @param {Array} [fields] fields to read and return, by default all fields are returned + * @params {Object} [options] + * @param {Number} [options.offset=0] The index from which selected records should be returned + * @param {Number} [options.limit=null] The maximum number of records to return + * @returns {Promise} + */ + read_slice: function (fields, options) { + var self = this; + options = options || {}; + var query = this._model.query(fields) + .limit(options.limit || false) + .offset(options.offset || 0) + .all(); + var prom = this.orderer.add(query); + prom.then(function (records) { + self.ids = _(records).pluck('id'); + }); + return prom; + }, + /** + * Reads the current dataset record (from its index) + * + * @params {Array} [fields] fields to read and return, by default all fields are returned + * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context + * @returns {Promise} + */ + read_index: function (fields, options) { + options = options || {}; + return this.read_ids([this.ids[this.index]], fields, options).then(function (records) { + if (_.isEmpty(records)) { return Promise.reject(); } + return records[0]; + }); + }, + /** + * Reads default values for the current model + * + * @param {Array} [fields] fields to get default values for, by default all defaults are read + * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context + * @returns {Promise} + */ + default_get: function (fields, options) { + options = options || {}; + return this._model.call('default_get', + [fields], {context: this.get_context(options.context)}); + }, + /** + * Creates a new record in db + * + * @param {Object} data field values to set on the new record + * @param {Object} options Dictionary that can contain the following keys: + * - readonly_fields: Values from readonly fields that were updated by + * on_changes. Only used by the BufferedDataSet to make the o2m work correctly. + * @returns {Promise} + */ + create: function (data, options) { + var self = this; + var prom = this._model.call('create', [data], { + context: this.get_context() + }); + prom.then(function () { + self.trigger('dataset_changed', data, options); + }); + return prom; + }, + /** + * Saves the provided data in an existing db record + * + * @param {Number|String} id identifier for the record to alter + * @param {Object} data field values to write into the record + * @param {Object} options Dictionary that can contain the following keys: + * - context: The context to use in the server-side call. + * - readonly_fields: Values from readonly fields that were updated by + * on_changes. Only used by the BufferedDataSet to make the o2m work correctly. + * @returns {Promise} + */ + write: function (id, data, options) { + options = options || {}; + var self = this; + var prom = this._model.call('write', [[id], data], { + context: this.get_context(options.context) + }); + prom.then(function () { + self.trigger('dataset_changed', id, data, options); + }); + return prom; + }, + /** + * Deletes an existing record from the database + * + * @param {Number|String} ids identifier of the record to delete + */ + unlink: function (ids) { + var self = this; + var prom = this._model.call('unlink', [ids], { + context: this.get_context() + }); + prom.then(function () { + self.trigger('dataset_changed', ids); + }); + return prom; + }, + /** + * Calls an arbitrary RPC method + * + * @param {String} method name of the method (on the current model) to call + * @param {Array} [args] arguments to pass to the method + * @param {Function} callback + * @param {Function} error_callback + * @returns {Promise} + */ + call: function (method, args) { + return this._model.call(method, args); + }, + /** + * Calls a button method, usually returning some sort of action + * + * @param {String} method + * @param {Array} [args] + * @returns {Promise} + */ + call_button: function (method, args) { + return this._model.call_button(method, args); + }, + /** + * Fetches the "readable name" for records, based on intrinsic rules + * + * @param {Array} ids + * @returns {Promise} + */ + name_get: function (ids) { + return this._model.call('name_get', [ids], {context: this.get_context()}); + }, + /** + * + * @param {String} name name to perform a search for/on + * @param {Array} [domain=[]] filters for the objects returned, OpenERP domain + * @param {String} [operator='ilike'] matching operator to use with the provided name value + * @param {Number} [limit=0] maximum number of matches to return + * @param {Function} callback function to call with name_search result + * @returns {Promise} + */ + name_search: function (name, domain, operator, limit) { + return this._model.call('name_search', { + name: name || '', + args: domain || false, + operator: operator || 'ilike', + context: this._model.context(), + limit: limit || 0 + }); + }, + /** + * @param name + */ + name_create: function (name, context) { + return this._model.call('name_create', [name], {context: this.get_context(context)}); + }, + get_context: function (request_context) { + return this._model.context(request_context); + }, + /** + * Reads or changes sort criteria on the dataset. + * + * If not provided with any argument, serializes the sort criteria to + * an SQL-like form usable by OpenERP's ORM. + * + * If given a field, will set that field as first sorting criteria or, + * if the field is already the first sorting criteria, will reverse it. + * + * @param {String} [field] field to sort on, reverses it (toggle from ASC to DESC) if already the main sort criteria + * @param {Boolean} [force_reverse=false] forces inserting the field as DESC + * @returns {String|undefined} + */ + sort: function (field, force_reverse) { + if (!field) { + return serialize_sort(this._sort); + } + var reverse = force_reverse || (this._sort[0] === field); + this._sort.splice.apply( + this._sort, [0, this._sort.length].concat( + _.without(this._sort, field, '-' + field))); + + this._sort.unshift((reverse ? '-' : '') + field); + return undefined; + }, + /** + * Set the sort criteria on the dataset. + * + * @param {Array} fields_list: list of fields order descriptors, as used by + * Odoo's ORM (such as 'name desc', 'product_id', 'order_date asc') + */ + set_sort: function (fields_list) { + this._sort = deserialize_sort(fields_list); + }, + size: function () { + return this.ids.length; + }, + alter_ids: function (n_ids) { + this.ids = n_ids; + }, + remove_ids: function (ids) { + this.alter_ids(_(this.ids).difference(ids)); + }, + add_ids: function (ids, at) { + var args = [at, 0].concat(_.difference(ids, this.ids)); + this.ids.splice.apply(this.ids, args); + }, + /** + * Resequence records. + * + * @param {Array} ids identifiers of the records to resequence + * @returns {Promise} + */ + resequence: function (ids, options) { + options = options || {}; + return session.rpc('/web/dataset/resequence', { + model: this.model, + ids: ids, + context: pyUtils.eval( + 'context', this.get_context(options.context)), + }).then(function (results) { + return results; + }); + }, +}); + +var DataSetStatic = DataSet.extend({ + init: function (parent, model, context, ids) { + this._super(parent, model, context); + // all local records + this.ids = ids || []; + }, + read_slice: function (fields, options) { + options = options || {}; + fields = fields || {}; + var offset = options.offset || 0, + limit = options.limit || false; + var end_pos = limit && limit !== -1 ? offset + limit : this.ids.length; + return this.read_ids(this.ids.slice(offset, end_pos), fields, options); + }, + set_ids: function (ids) { + this.ids = ids; + if (ids.length === 0) { + this.index = null; + } else if (this.index >= ids.length - 1) { + this.index = ids.length - 1; + } + }, + unlink: function (ids) { + this.set_ids(_.without.apply(null, [this.ids].concat(ids))); + this.trigger('unlink', ids); + return Promise.resolve({result: true}); + }, +}); + +var DataSetSearch = DataSet.extend({ + /** + * @constructs instance.web.DataSetSearch + * @extends instance.web.DataSet + * + * @param {Object} parent + * @param {String} model + * @param {Object} context + * @param {Array} domain + */ + init: function (parent, model, context, domain) { + this._super(parent, model, context); + this.domain = domain || []; + this._length = null; + this.ids = []; + this._model = new Model(model, context, domain); + }, + /** + * Read a slice of the records represented by this DataSet, based on its + * domain and context. + * + * @params {Object} options + * @param {Array} [options.fields] fields to read and return, by default all fields are returned + * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context + * @param {Array} [options.domain] domain data to add to the request payload, ANDed with the dataset's domain + * @param {Number} [options.offset=0] The index from which selected records should be returned + * @param {Number} [options.limit=null] The maximum number of records to return + * @returns {Promise} + */ + read_slice: function (fields, options) { + options = options || {}; + var self = this; + var q = this._model.query(fields || false) + .filter(options.domain) + .context(options.context) + .offset(options.offset || 0) + .limit(options.limit || false); + q = q.order_by.apply(q, this._sort); + + var prom = this.orderer.add(q.all()); + prom.then(function (records) { + // FIXME: not sure about that one, *could* have discarded count + q.count().then(function (count) { self._length = count; }); + self.ids = _(records).pluck('id'); + }); + return prom; + }, + get_domain: function (other_domain) { + return this._model.domain(other_domain); + }, + alter_ids: function (ids) { + this._super(ids); + if (this.index !== null && this.index >= this.ids.length) { + this.index = this.ids.length > 0 ? this.ids.length - 1 : 0; + } + }, + remove_ids: function (ids) { + var before = this.ids.length; + this._super(ids); + if (this._length) { + this._length -= (before - this.ids.length); + } + }, + add_ids: function (ids, at) { + var before = this.ids.length; + this._super(ids, at); + if(this._length){ + this._length += (this.ids.length - before); + } + }, + unlink: function (ids, callback, error_callback) { + var self = this; + var prom = this._super(ids); + prom.then(function () { + self.remove_ids( ids); + self.trigger("dataset_changed", ids, callback, error_callback); + }); + return prom; + }, + size: function () { + if (this._length !== null) { + return this._length; + } + return this._super(); + } +}); + +var data = { + Query: Query, + DataSet: DataSet, + DataSetStatic: DataSetStatic, + DataSetSearch: DataSetSearch, + /** @type String */ + noDisplayContent: "<em class=\"text-warning\">" + _t("Unnamed") + "</em>", +}; + + +var Model = Class.extend({ + /** + new openerp.web.Model(name[, context[, domain]]) + + @constructs instance.web.Model + @extends instance.web.Class + + @param {String} name name of the OpenERP model this object is bound to + @param {Object} [context] + @param {Array} [domain] + */ + init: function(name, context, domain) { + this.name = name; + this._context = context || {}; + this._domain = domain || []; + }, + /** + * @deprecated does not allow to specify kwargs, directly use call() instead + */ + get_func: function (method_name) { + var self = this; + return function () { + return self.call(method_name, _.toArray(arguments)); + }; + }, + /** + * Fetches a Query instance bound to this model, for searching + * + * @param {Array<String>} [fields] fields to ultimately fetch during the search + * @returns {instance.web.Query} + */ + query: function (fields) { + return new data.Query(this, fields); + }, + /** + * Fetches the model's domain, combined with the provided domain if any + * + * @param {Array} [domain] to combine with the model's internal domain + * @returns {Array} The model's internal domain, or the AND-ed union of the model's internal domain and the provided domain + */ + domain: function (domain) { + if (!domain) { return this._domain; } + return this._domain.concat(domain); + }, + /** + * Fetches the combination of the user's context and the domain context, + * combined with the provided context if any + * + * @param {Object} [context] to combine with the model's internal context + * @returns {web.Context} The union of the user's context and the model's internal context, as well as the provided context if any. In that order. + */ + context: function (context) { + return new Context(session.user_context, this._context, context || {}); + }, + /** + * Call a method (over RPC) on the bound OpenERP model. + * + * @param {String} method name of the method to call + * @param {Array} [args] positipyEvalonal arguments + * @param {Object} [kwargs] keyword arguments + * @param {Object} [options] additional options for the rpc() method + * @returns {Promise<>} call result + */ + call: function (method, args, kwargs, options) { + args = args || []; + kwargs = kwargs || {}; + if (!_.isArray(args)) { + // call(method, kwargs) + kwargs = args; + args = []; + } + pyUtils.ensure_evaluated(args, kwargs); + var call_kw = '/web/dataset/call_kw/' + this.name + '/' + method; + return session.rpc(call_kw, { + model: this.name, + method: method, + args: args, + kwargs: kwargs + }, options); + }, + call_button: function (method, args) { + pyUtils.ensure_evaluated(args, {}); + // context should be the last argument + var context = (args || []).length > 0 ? args.pop() : {}; + return session.rpc('/web/dataset/call_button', { + model: this.name, + method: method, + args: args || [], + kwargs: {context: context}, + }); + }, +}); + + +return data; + +}); diff --git a/addons/web/static/src/js/apps.js b/addons/web/static/src/js/apps.js new file mode 100644 index 00000000..364401d4 --- /dev/null +++ b/addons/web/static/src/js/apps.js @@ -0,0 +1,171 @@ +odoo.define('web.Apps', function (require) { +"use strict"; + +var AbstractAction = require('web.AbstractAction'); +var config = require('web.config'); +var core = require('web.core'); +var framework = require('web.framework'); +var session = require('web.session'); + +var _t = core._t; + +var apps_client = null; + +var Apps = AbstractAction.extend({ + contentTemplate: 'EmptyComponent', + remote_action_tag: 'loempia.embed', + failback_action_id: 'base.open_module_tree', + + init: function(parent, action) { + this._super(parent, action); + var options = action.params || {}; + this.params = options; // NOTE forwarded to embedded client action + }, + + get_client: function() { + // return the client via a promise, resolved or rejected depending if + // the remote host is available or not. + var check_client_available = function(client) { + var i = new Image(); + var def = new Promise(function (resolve, reject) { + i.onerror = function() { + reject(client); + }; + i.onload = function() { + resolve(client); + }; + }); + var ts = new Date().getTime(); + i.src = _.str.sprintf('%s/web/static/src/img/sep-a.gif?%s', client.origin, ts); + return def; + }; + if (apps_client) { + return check_client_available(apps_client); + } else { + return this._rpc({model: 'ir.module.module', method: 'get_apps_server'}) + .then(function(u) { + var link = $(_.str.sprintf('<a href="%s"></a>', u))[0]; + var host = _.str.sprintf('%s//%s', link.protocol, link.host); + var dbname = link.pathname; + if (dbname[0] === '/') { + dbname = dbname.substr(1); + } + var client = { + origin: host, + dbname: dbname + }; + apps_client = client; + return check_client_available(client); + }); + } + }, + + destroy: function() { + $(window).off("message." + this.uniq); + if (this.$ifr) { + this.$ifr.remove(); + this.$ifr = null; + } + return this._super(); + }, + + _on_message: function($e) { + var self = this, client = this.client, e = $e.originalEvent; + + if (e.origin !== client.origin) { + return; + } + + var dispatcher = { + 'event': function(m) { self.trigger('message:' + m.event, m); }, + 'action': function(m) { + self.do_action(m.action).then(function(r) { + var w = self.$ifr[0].contentWindow; + w.postMessage({id: m.id, result: r}, client.origin); + }); + }, + 'rpc': function(m) { + return self._rpc({route: m.args[0], params: m.args[1]}).then(function(r) { + var w = self.$ifr[0].contentWindow; + w.postMessage({id: m.id, result: r}, client.origin); + }); + }, + 'Model': function(m) { + return self._rpc({model: m.model, method: m.args[0], args: m.args[1]}) + .then(function(r) { + var w = self.$ifr[0].contentWindow; + w.postMessage({id: m.id, result: r}, client.origin); + }); + }, + }; + // console.log(e.data); + if (!_.isObject(e.data)) { return; } + if (dispatcher[e.data.type]) { + dispatcher[e.data.type](e.data); + } + }, + + start: function() { + var self = this; + return new Promise(function (resolve, reject) { + self.get_client().then(function (client) { + self.client = client; + + var qs = {db: client.dbname}; + if (config.isDebug()) { + qs.debug = odoo.debug; + } + var u = $.param.querystring(client.origin + "/apps/embed/client", qs); + var css = {width: '100%', height: '750px'}; + self.$ifr = $('<iframe>').attr('src', u); + + self.uniq = _.uniqueId('apps'); + $(window).on("message." + self.uniq, self.proxy('_on_message')); + + self.on('message:ready', self, function(m) { + var w = this.$ifr[0].contentWindow; + var act = { + type: 'ir.actions.client', + tag: this.remote_action_tag, + params: _.extend({}, this.params, { + db: session.db, + origin: session.origin, + }) + }; + w.postMessage({type:'action', action: act}, client.origin); + }); + + self.on('message:set_height', self, function(m) { + this.$ifr.height(m.height); + }); + + self.on('message:blockUI', self, function() { framework.blockUI(); }); + self.on('message:unblockUI', self, function() { framework.unblockUI(); }); + self.on('message:warn', self, function(m) {self.do_warn(m.title, m.message, m.sticky); }); + + self.$ifr.appendTo(self.$('.o_content')).css(css).addClass('apps-client'); + + resolve(); + }, function() { + self.do_warn(_t('Odoo Apps will be available soon'), _t('Showing locally available modules'), true); + return self._rpc({ + route: '/web/action/load', + params: {action_id: self.failback_action_id}, + }).then(function(action) { + return self.do_action(action); + }).then(reject, reject); + }); + }); + } +}); + +var AppsUpdates = Apps.extend({ + remote_action_tag: 'loempia.embed.updates', +}); + +core.action_registry.add("apps", Apps); +core.action_registry.add("apps.updates", AppsUpdates); + +return Apps; + +}); diff --git a/addons/web/static/src/js/boot.js b/addons/web/static/src/js/boot.js new file mode 100644 index 00000000..62130772 --- /dev/null +++ b/addons/web/static/src/js/boot.js @@ -0,0 +1,335 @@ +/** + *------------------------------------------------------------------------------ + * Odoo Web Boostrap Code + *------------------------------------------------------------------------------ + * + * Each module can return a promise. In that case, the module is marked as loaded + * only when the promise is resolved, and its value is equal to the resolved value. + * The module can be rejected (unloaded). This will be logged in the console as info. + * + * logs: + * Missing dependencies: + * These modules do not appear in the page. It is possible that the + * JavaScript file is not in the page or that the module name is wrong + * Failed modules: + * A javascript error is detected + * Rejected modules: + * The module returns a rejected promise. It (and its dependent modules) + * is not loaded. + * Rejected linked modules: + * Modules who depend on a rejected module + * Non loaded modules: + * Modules who depend on a missing or a failed module + * Debug: + * Non loaded or failed module informations for debugging + */ +(function () { + "use strict"; + + var jobUID = Date.now(); + + var jobs = []; + var factories = Object.create(null); + var jobDeps = []; + var jobPromises = []; + + var services = Object.create({}); + + var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/mg; + var cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g; + + if (!window.odoo) { + window.odoo = {}; + } + var odoo = window.odoo; + + var didLogInfoResolve; + var didLogInfoPromise = new Promise(function (resolve) { + didLogInfoResolve = resolve; + }); + + odoo.testing = typeof QUnit === 'object'; + odoo.remainingJobs = jobs; + odoo.__DEBUG__ = { + didLogInfo: didLogInfoPromise, + getDependencies: function (name, transitive) { + var deps = name instanceof Array ? name : [name]; + var changed; + do { + changed = false; + jobDeps.forEach(function (dep) { + if (deps.indexOf(dep.to) >= 0 && deps.indexOf(dep.from) < 0) { + deps.push(dep.from); + changed = true; + } + }); + } while (changed && transitive); + return deps; + }, + getDependents: function (name) { + return jobDeps.filter(function (dep) { + return dep.from === name; + }).map(function (dep) { + return dep.to; + }); + }, + getWaitedJobs: function () { + return jobs.map(function (job) { + return job.name; + }).filter(function (item, index, self) { // uniq + return self.indexOf(item) === index; + }); + }, + getMissingJobs: function () { + var self = this; + var waited = this.getWaitedJobs(); + var missing = []; + waited.forEach(function (job) { + self.getDependencies(job).forEach(function (job) { + if (!(job in self.services)) { + missing.push(job); + } + }); + }); + return missing.filter(function (item, index, self) { + return self.indexOf(item) === index; + }).filter(function (item) { + return waited.indexOf(item) < 0; + }).filter(function (job) { + return !job.error; + }); + }, + getFailedJobs: function () { + return jobs.filter(function (job) { + return !!job.error; + }); + }, + factories: factories, + services: services, + }; + odoo.define = function () { + var args = Array.prototype.slice.call(arguments); + var name = typeof args[0] === 'string' ? args.shift() : ('__odoo_job' + (jobUID++)); + var factory = args[args.length - 1]; + var deps; + if (args[0] instanceof Array) { + deps = args[0]; + } else { + deps = []; + factory.toString() + .replace(commentRegExp, '') + .replace(cjsRequireRegExp, function (match, dep) { + deps.push(dep); + }); + } + + if (odoo.debug) { + if (!(deps instanceof Array)) { + throw new Error('Dependencies should be defined by an array', deps); + } + if (typeof factory !== 'function') { + throw new Error('Factory should be defined by a function', factory); + } + if (typeof name !== 'string') { + throw new Error("Invalid name definition (should be a string", name); + } + if (name in factories) { + throw new Error("Service " + name + " already defined"); + } + } + + factory.deps = deps; + factories[name] = factory; + + jobs.push({ + name: name, + factory: factory, + deps: deps, + }); + + deps.forEach(function (dep) { + jobDeps.push({from: dep, to: name}); + }); + + this.processJobs(jobs, services); + }; + odoo.log = function () { + var missing = []; + var failed = []; + + if (jobs.length) { + var debugJobs = {}; + var rejected = []; + var rejectedLinked = []; + var job; + var jobdep; + + for (var k = 0; k < jobs.length; k++) { + debugJobs[jobs[k].name] = job = { + dependencies: jobs[k].deps, + dependents: odoo.__DEBUG__.getDependents(jobs[k].name), + name: jobs[k].name + }; + if (jobs[k].error) { + job.error = jobs[k].error; + } + if (jobs[k].rejected) { + job.rejected = jobs[k].rejected; + rejected.push(job.name); + } + var deps = odoo.__DEBUG__.getDependencies(job.name); + for (var i = 0; i < deps.length; i++) { + if (job.name !== deps[i] && !(deps[i] in services)) { + jobdep = debugJobs[deps[i]]; + if (!jobdep && deps[i] in factories) { + for (var j = 0; j < jobs.length; j++) { + if (jobs[j].name === deps[i]) { + jobdep = jobs[j]; + break; + } + } + } + if (jobdep && jobdep.rejected) { + if (!job.rejected) { + job.rejected = []; + rejectedLinked.push(job.name); + } + job.rejected.push(deps[i]); + } else { + if (!job.missing) { + job.missing = []; + } + job.missing.push(deps[i]); + } + } + } + } + missing = odoo.__DEBUG__.getMissingJobs(); + failed = odoo.__DEBUG__.getFailedJobs(); + var unloaded = Object.keys(debugJobs) // Object.values is not supported + .map(function (key) { + return debugJobs[key]; + }).filter(function (job) { + return job.missing; + }); + + if (odoo.debug || failed.length || unloaded.length) { + var log = window.console[!failed.length || !unloaded.length ? 'info' : 'error'].bind(window.console); + log((failed.length ? 'error' : (unloaded.length ? 'warning' : 'info')) + ': Some modules could not be started'); + if (missing.length) { + log('Missing dependencies: ', missing); + } + if (failed.length) { + log('Failed modules: ', failed.map(function (fail) { + return fail.name; + })); + } + if (rejected.length) { + log('Rejected modules: ', rejected); + } + if (rejectedLinked.length) { + log('Rejected linked modules: ', rejectedLinked); + } + if (unloaded.length) { + log('Non loaded modules: ', unloaded.map(function (unload) { + return unload.name; + })); + } + if (odoo.debug && Object.keys(debugJobs).length) { + log('Debug: ', debugJobs); + } + } + } + odoo.__DEBUG__.jsModules = { + missing: missing, + failed: failed.map(function (fail) { + return fail.name; + }), + }; + didLogInfoResolve(); + }; + odoo.processJobs = function (jobs, services) { + var job; + + function processJob(job) { + var require = makeRequire(job); + + var jobExec; + var def = new Promise(function (resolve) { + try { + jobExec = job.factory.call(null, require); + jobs.splice(jobs.indexOf(job), 1); + } catch (e) { + job.error = e; + console.error('Error while loading ' + job.name + ': '+ e.stack); + } + if (!job.error) { + Promise.resolve(jobExec).then( + function (data) { + services[job.name] = data; + resolve(); + odoo.processJobs(jobs, services); + }).guardedCatch(function (e) { + job.rejected = e || true; + jobs.push(job); + resolve(); + } + ); + } + }); + jobPromises.push(def); + } + + function isReady(job) { + return !job.error && !job.rejected && job.factory.deps.every(function (name) { + return name in services; + }); + } + + function makeRequire(job) { + var deps = {}; + Object.keys(services).filter(function (item) { + return job.deps.indexOf(item) >= 0; + }).forEach(function (key) { + deps[key] = services[key]; + }); + + return function require(name) { + if (!(name in deps)) { + console.error('Undefined dependency: ', name); + } + return deps[name]; + }; + } + + while (jobs.length) { + job = undefined; + for (var i = 0; i < jobs.length; i++) { + if (isReady(jobs[i])) { + job = jobs[i]; + break; + } + } + if (!job) { + break; + } + processJob(job); + } + + return services; + }; + + // Automatically log errors detected when loading modules + window.addEventListener('load', function logWhenLoaded() { + setTimeout(function () { + var len = jobPromises.length; + Promise.all(jobPromises).then(function () { + if (len === jobPromises.length) { + odoo.log(); + } else { + logWhenLoaded(); + } + }); + }, 9999); + }); +})(); diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js new file mode 100644 index 00000000..fc5c1fd4 --- /dev/null +++ b/addons/web/static/src/js/chrome/abstract_action.js @@ -0,0 +1,192 @@ +odoo.define('web.AbstractAction', function (require) { +"use strict"; + +/** + * We define here the AbstractAction widget, which implements the ActionMixin. + * All client actions must extend this widget. + * + * @module web.AbstractAction + */ + +var ActionMixin = require('web.ActionMixin'); +const ActionModel = require('web/static/src/js/views/action_model.js'); +var ControlPanel = require('web.ControlPanel'); +var Widget = require('web.Widget'); +const { ComponentWrapper } = require('web.OwlCompatibility'); + +var AbstractAction = Widget.extend(ActionMixin, { + config: { + ControlPanel: ControlPanel, + }, + + /** + * If this flag is set to true, the client action will create a control + * panel whenever it is created. + * + * @type boolean + */ + hasControlPanel: false, + + /** + * If true, this flag indicates that the client action should automatically + * fetch the <arch> of a search view (or control panel view). Note that + * to do that, it also needs a specific modelName. + * + * For example, the Discuss application adds the following line in its + * constructor:: + * + * this.searchModelConfig.modelName = 'mail.message'; + * + * @type boolean + */ + loadControlPanel: false, + + /** + * A client action might want to use a search bar in its control panel, or + * it could choose not to use it. + * + * Note that it only makes sense if hasControlPanel is set to true. + * + * @type boolean + */ + withSearchBar: false, + + /** + * This parameter can be set to customize the available sub menus in the + * controlpanel (Filters/Group By/Favorites). This is basically a list of + * the sub menus that we want to use. + * + * Note that it only makes sense if hasControlPanel is set to true. + * + * For example, set ['filter', 'favorite'] to enable the Filters and + * Favorites menus. + * + * @type string[] + */ + searchMenuTypes: [], + + /** + * @override + * + * @param {Widget} parent + * @param {Object} action + * @param {Object} [options] + */ + init: function (parent, action, options) { + this._super(parent); + this._title = action.display_name || action.name; + + this.searchModelConfig = { + context: Object.assign({}, action.context), + domain: action.domain || [], + env: owl.Component.env, + searchMenuTypes: this.searchMenuTypes, + }; + this.extensions = {}; + if (this.hasControlPanel) { + this.extensions.ControlPanel = { + actionId: action.id, + withSearchBar: this.withSearchBar, + }; + + this.viewId = action.search_view_id && action.search_view_id[0]; + + this.controlPanelProps = { + action, + breadcrumbs: options && options.breadcrumbs, + withSearchBar: this.withSearchBar, + searchMenuTypes: this.searchMenuTypes, + }; + } + }, + /** + * The willStart method is actually quite complicated if the client action + * has a controlPanel, because it needs to prepare it. + * + * @override + */ + willStart: async function () { + const superPromise = this._super(...arguments); + if (this.hasControlPanel) { + if (this.loadControlPanel) { + const { context, modelName } = this.searchModelConfig; + const options = { load_filters: this.searchMenuTypes.includes('favorite') }; + const { arch, fields, favoriteFilters } = await this.loadFieldView( + modelName, + context || {}, + this.viewId, + 'search', + options + ); + const archs = { search: arch }; + const { ControlPanel: controlPanelInfo } = ActionModel.extractArchInfo(archs); + Object.assign(this.extensions.ControlPanel, { + archNodes: controlPanelInfo.children, + favoriteFilters, + fields, + }); + this.controlPanelProps.fields = fields; + } + } + this.searchModel = new ActionModel(this.extensions, this.searchModelConfig); + if (this.hasControlPanel) { + this.controlPanelProps.searchModel = this.searchModel; + } + return Promise.all([ + superPromise, + this.searchModel.load(), + ]); + }, + /** + * @override + */ + start: async function () { + await this._super(...arguments); + if (this.hasControlPanel) { + if ('title' in this.controlPanelProps) { + this._setTitle(this.controlPanelProps.title); + } + this.controlPanelProps.title = this.getTitle(); + this._controlPanelWrapper = new ComponentWrapper(this, this.config.ControlPanel, this.controlPanelProps); + await this._controlPanelWrapper.mount(this.el, { position: 'first-child' }); + + } + }, + /** + * @override + */ + destroy: function() { + this._super.apply(this, arguments); + ActionMixin.destroy.call(this); + }, + /** + * @override + */ + on_attach_callback: function () { + ActionMixin.on_attach_callback.call(this); + this.searchModel.on('search', this, this._onSearch); + if (this.hasControlPanel) { + this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); + } + }, + /** + * @override + */ + on_detach_callback: function () { + ActionMixin.on_detach_callback.call(this); + this.searchModel.off('search', this); + if (this.hasControlPanel) { + this.searchModel.off('get-controller-query-params', this); + } + }, + + /** + * @private + * @param {Object} [searchQuery] + */ + _onSearch: function () {}, +}); + +return AbstractAction; + +}); diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js new file mode 100644 index 00000000..032b8706 --- /dev/null +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -0,0 +1,556 @@ +odoo.define('web.AbstractWebClient', function (require) { +"use strict"; + +/** + * AbstractWebClient + * + * This class defines a simple, basic web client. It is mostly functional. + * The WebClient is in some way the most important class for the web framework: + * - this is the class that instantiate everything else, + * - it is the top of the component tree, + * - it coordinates many events bubbling up + */ + +var ActionManager = require('web.ActionManager'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var config = require('web.config'); +var WarningDialog = require('web.CrashManager').WarningDialog; +var data_manager = require('web.data_manager'); +var dom = require('web.dom'); +var KeyboardNavigationMixin = require('web.KeyboardNavigationMixin'); +var Loading = require('web.Loading'); +var RainbowMan = require('web.RainbowMan'); +var session = require('web.session'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +const env = require('web.env'); + +var _t = core._t; + +var AbstractWebClient = Widget.extend(KeyboardNavigationMixin, { + dependencies: ['notification'], + events: _.extend({}, KeyboardNavigationMixin.events), + custom_events: { + call_service: '_onCallService', + clear_uncommitted_changes: function (e) { + this.clear_uncommitted_changes().then(e.data.callback); + }, + toggle_fullscreen: function (event) { + this.toggle_fullscreen(event.data.fullscreen); + }, + current_action_updated: function (ev) { + this.current_action_updated(ev.data.action, ev.data.controller); + }, + // GENERIC SERVICES + // the next events are dedicated to generic services required by + // downstream widgets. Mainly side effects, such as rpcs, notifications + // or cache. + warning: '_onDisplayWarning', + load_action: '_onLoadAction', + load_views: function (event) { + var params = { + model: event.data.modelName, + context: event.data.context, + views_descr: event.data.views, + }; + return data_manager + .load_views(params, event.data.options || {}) + .then(event.data.on_success); + }, + load_filters: function (event) { + return data_manager + .load_filters(event.data) + .then(event.data.on_success); + }, + create_filter: '_onCreateFilter', + delete_filter: '_onDeleteFilter', + push_state: '_onPushState', + show_effect: '_onShowEffect', + // session + get_session: function (event) { + if (event.data.callback) { + event.data.callback(session); + } + }, + do_action: function (event) { + const actionProm = this.do_action(event.data.action, event.data.options || {}); + this.menu_dp.add(actionProm).then(function (result) { + if (event.data.on_success) { + event.data.on_success(result); + } + }).guardedCatch(function (result) { + if (event.data.on_fail) { + event.data.on_fail(result); + } + }); + }, + getScrollPosition: '_onGetScrollPosition', + scrollTo: '_onScrollTo', + set_title_part: '_onSetTitlePart', + webclient_started: '_onWebClientStarted', + }, + init: function (parent) { + // a flag to determine that odoo is fully loaded + odoo.isReady = false; + this.client_options = {}; + this._super(parent); + KeyboardNavigationMixin.init.call(this); + this.origin = undefined; + this._current_state = null; + this.menu_dp = new concurrency.DropPrevious(); + this.action_mutex = new concurrency.Mutex(); + this.set('title_part', {"zopenerp": "Odoo"}); + this.env = env; + this.env.bus.on('set_title_part', this, this._onSetTitlePart); + }, + /** + * @override + */ + start: function () { + KeyboardNavigationMixin.start.call(this); + var self = this; + + // we add the o_touch_device css class to allow CSS to target touch + // devices. This is only for styling purpose, if you need javascript + // specific behaviour for touch device, just use the config object + // exported by web.config + this.$el.toggleClass('o_touch_device', config.device.touch); + this.on("change:title_part", this, this._title_changed); + this._title_changed(); + + var state = $.bbq.getState(); + // If not set on the url, retrieve cids from the local storage + // of from the default company on the user + var current_company_id = session.user_companies.current_company[0] + if (!state.cids) { + state.cids = utils.get_cookie('cids') !== null ? utils.get_cookie('cids') : String(current_company_id); + } + // If a key appears several times in the hash, it is available in the + // bbq state as an array containing all occurrences of that key + const cids = Array.isArray(state.cids) ? state.cids[0] : state.cids; + let stateCompanyIDS = cids.split(',').map(cid => parseInt(cid, 10)); + var userCompanyIDS = _.map(session.user_companies.allowed_companies, function(company) {return company[0]}); + // Check that the user has access to all the companies + if (!_.isEmpty(_.difference(stateCompanyIDS, userCompanyIDS))) { + state.cids = String(current_company_id); + stateCompanyIDS = [current_company_id] + } + // Update the user context with this configuration + session.user_context.allowed_company_ids = stateCompanyIDS; + $.bbq.pushState(state); + // Update favicon + $("link[type='image/x-icon']").attr('href', '/web/image/res.company/' + String(stateCompanyIDS[0]) + '/favicon/') + + return session.is_bound + .then(function () { + self.$el.toggleClass('o_rtl', _t.database.parameters.direction === "rtl"); + self.bind_events(); + return Promise.all([ + self.set_action_manager(), + self.set_loading() + ]); + }).then(function () { + if (session.session_is_valid()) { + return self.show_application(); + } else { + // database manager needs the webclient to keep going even + // though it has no valid session + return Promise.resolve(); + } + }); + }, + /** + * @override + */ + destroy: function () { + KeyboardNavigationMixin.destroy.call(this); + return this._super(...arguments); + }, + bind_events: function () { + var self = this; + $('.oe_systray').show(); + this.$el.on('mouseenter', '.oe_systray > div:not([data-toggle=tooltip])', function () { + $(this).attr('data-toggle', 'tooltip').tooltip().trigger('mouseenter'); + }); + // TODO: this handler seems useless since 11.0, should be removed + this.$el.on('click', '.oe_dropdown_toggle', function (ev) { + ev.preventDefault(); + var $toggle = $(this); + var doc_width = $(document).width(); + var $menu = $toggle.siblings('.oe_dropdown_menu'); + $menu = $menu.length >= 1 ? $menu : $toggle.find('.oe_dropdown_menu'); + var state = $menu.is('.oe_opened'); + setTimeout(function () { + // Do not alter propagation + $toggle.add($menu).toggleClass('oe_opened', !state); + if (!state) { + // Move $menu if outside window's edge + var offset = $menu.offset(); + var menu_width = $menu.width(); + var x = doc_width - offset.left - menu_width - 2; + if (x < 0) { + $menu.offset({ left: offset.left + x }).width(menu_width); + } + } + }, 0); + }); + core.bus.on('click', this, function (ev) { + $('.tooltip').remove(); + if (!$(ev.target).is('input[type=file]')) { + $(this.el.getElementsByClassName('oe_dropdown_menu oe_opened')).removeClass('oe_opened'); + $(this.el.getElementsByClassName('oe_dropdown_toggle oe_opened')).removeClass('oe_opened'); + } + }); + core.bus.on('connection_lost', this, this._onConnectionLost); + core.bus.on('connection_restored', this, this._onConnectionRestored); + }, + set_action_manager: function () { + var self = this; + this.action_manager = new ActionManager(this, session.user_context); + this.env.bus.on('do-action', this, payload => { + this.do_action(payload.action, payload.options || {}) + .then(payload.on_success || (() => {})) + .guardedCatch(payload.on_fail || (() => {})); + }); + var fragment = document.createDocumentFragment(); + return this.action_manager.appendTo(fragment).then(function () { + dom.append(self.$el, fragment, { + in_DOM: true, + callbacks: [{widget: self.action_manager}], + }); + }); + }, + set_loading: function () { + this.loading = new Loading(this); + return this.loading.appendTo(this.$el); + }, + show_application: function () { + }, + clear_uncommitted_changes: function () { + return this.action_manager.clearUncommittedChanges(); + }, + destroy_content: function () { + _.each(_.clone(this.getChildren()), function (el) { + el.destroy(); + }); + this.$el.children().remove(); + }, + // -------------------------------------------------------------- + // Window title handling + // -------------------------------------------------------------- + /** + * Sets the first part of the title of the window, dedicated to the current action. + */ + set_title: function (title) { + this.set_title_part("action", title); + }, + /** + * Sets an arbitrary part of the title of the window. Title parts are + * identified by strings. Each time a title part is changed, all parts + * are gathered, ordered by alphabetical order and displayed in the title + * of the window separated by ``-``. + * + * @private + * @param {string} part + * @param {string} title + */ + set_title_part: function (part, title) { + var tmp = _.clone(this.get("title_part")); + tmp[part] = title; + this.set("title_part", tmp); + }, + _title_changed: function () { + var parts = _.sortBy(_.keys(this.get("title_part")), function (x) { return x; }); + var tmp = ""; + _.each(parts, function (part) { + var str = this.get("title_part")[part]; + if (str) { + tmp = tmp ? tmp + " - " + str : str; + } + }, this); + document.title = tmp; + }, + // -------------------------------------------------------------- + // do_* + // -------------------------------------------------------------- + /** + * When do_action is performed on the WebClient, forward it to the main ActionManager + * This allows to widgets that are not inside the ActionManager to perform do_action + */ + do_action: function () { + return this.action_manager.doAction.apply(this.action_manager, arguments); + }, + do_reload: function () { + var self = this; + return session.session_reload().then(function () { + session.load_modules(true).then( + self.menu.proxy('do_reload')); + }); + }, + do_push_state: function (state) { + if (!state.menu_id && this.menu) { // this.menu doesn't exist in the POS + state.menu_id = this.menu.getCurrentPrimaryMenu(); + } + if ('title' in state) { + this.set_title(state.title); + delete state.title; + } + var url = '#' + $.param(state); + this._current_state = $.deparam($.param(state), false); // stringify all values + $.bbq.pushState(url); + this.trigger('state_pushed', state); + }, + // -------------------------------------------------------------- + // Connection notifications + // -------------------------------------------------------------- + /** + * Handler to be overridden, called each time the UI is updated by the + * ActionManager. + * + * @param {Object} action the action of the currently displayed controller + * @param {Object} controller the currently displayed controller + */ + current_action_updated: function (action, controller) { + }, + //-------------------------------------------------------------- + // Misc. + //-------------------------------------------------------------- + toggle_fullscreen: function (fullscreen) { + this.$el.toggleClass('o_fullscreen', fullscreen); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the left and top scroll positions of the main scrolling area + * (i.e. the '.o_content' div in desktop). + * + * @returns {Object} with keys left and top + */ + getScrollPosition: function () { + var scrollingEl = this.action_manager.el.getElementsByClassName('o_content')[0]; + return { + left: scrollingEl ? scrollingEl.scrollLeft : 0, + top: scrollingEl ? scrollingEl.scrollTop : 0, + }; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Calls the requested service from the env. + * + * For the ajax service, the arguments are extended with the target so that + * it can call back the caller. + * + * @private + * @param {OdooEvent} event + */ + _onCallService: function (ev) { + const payload = ev.data; + 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); + }, + /** + * Whenever the connection is lost, we need to notify the user. + * + * @private + */ + _onConnectionLost: function () { + this.connectionNotificationID = this.displayNotification({ + message: _t('Connection lost. Trying to reconnect...'), + sticky: true + }); + }, + /** + * Whenever the connection is restored, we need to notify the user. + * + * @private + */ + _onConnectionRestored: function () { + if (this.connectionNotificationID) { + this.call('notification', 'close', this.connectionNotificationID); + this.displayNotification({ + type: 'info', + message: _t('Connection restored. You are back online.'), + sticky: false + }); + this.connectionNotificationID = false; + } + }, + /** + * @private + * @param {OdooEvent} e + * @param {Object} e.data.filter the filter description + * @param {function} e.data.on_success called when the RPC succeeds with its + * returned value as argument + */ + _onCreateFilter: function (e) { + data_manager + .create_filter(e.data.filter) + .then(e.data.on_success); + }, + /** + * @private + * @param {OdooEvent} e + * @param {Object} e.data.filter the filter description + * @param {function} e.data.on_success called when the RPC succeeds with its + * returned value as argument + */ + _onDeleteFilter: function (e) { + data_manager + .delete_filter(e.data.filterId) + .then(e.data.on_success); + }, + /** + * Displays a warning in a dialog or with the notification service + * + * @private + * @param {OdooEvent} e + * @param {string} e.data.message the warning's message + * @param {string} e.data.title the warning's title + * @param {string} [e.data.type] 'dialog' to display in a dialog + * @param {boolean} [e.data.sticky] whether or not the warning should be + * sticky (if displayed with the Notification) + */ + _onDisplayWarning: function (e) { + var data = e.data; + if (data.type === 'dialog') { + new WarningDialog(this, { + title: data.title, + }, data).open(); + } else { + data.type = 'warning'; + this.call('notification', 'notify', data); + } + }, + /** + * Provides to the caller the current scroll position (left and top) of the + * main scrolling area of the webclient. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback + */ + _onGetScrollPosition: function (ev) { + ev.data.callback(this.getScrollPosition()); + }, + /** + * Loads an action from the database given its ID. + * + * @private + * @param {OdooEvent} event + * @param {integer} event.data.actionID + * @param {Object} event.data.context + * @param {function} event.data.on_success + */ + _onLoadAction: function (event) { + data_manager + .load_action(event.data.actionID, event.data.context) + .then(event.data.on_success); + }, + /** + * @private + * @param {OdooEvent} e + */ + _onPushState: function (e) { + this.do_push_state(_.extend(e.data.state, {'cids': $.bbq.getState().cids})); + }, + /** + * Scrolls either to a given offset or to a target element (given a selector). + * It must be called with: trigger_up('scrollTo', options). + * + * @private + * @param {OdooEvent} ev + * @param {integer} [ev.data.top] the number of pixels to scroll from top + * @param {integer} [ev.data.left] the number of pixels to scroll from left + * @param {string} [ev.data.selector] the selector of the target element to + * scroll to + */ + _onScrollTo: function (ev) { + var scrollingEl = this.action_manager.el.getElementsByClassName('o_content')[0]; + if (!scrollingEl) { + return; + } + var offset = {top: ev.data.top, left: ev.data.left || 0}; + if (ev.data.selector) { + offset = dom.getPosition(document.querySelector(ev.data.selector)); + // Substract the position of the scrolling element + offset.top -= dom.getPosition(scrollingEl).top; + } + + scrollingEl.scrollTop = offset.top; + scrollingEl.scrollLeft = offset.left; + }, + /** + * @private + * @param {Object} payload + * @param {string} payload.part + * @param {string} [payload.title] + */ + _onSetTitlePart: function (payload) { + var part = payload.part; + var title = payload.title; + this.set_title_part(part, title); + }, + /** + * Displays a visual effect (for example, a rainbowman0 + * + * @private + * @param {OdooEvent} e + * @param {Object} [e.data] - key-value options to decide rainbowman + * behavior / appearance + */ + _onShowEffect: function (e) { + var data = e.data || {}; + var type = data.type || 'rainbow_man'; + if (type === 'rainbow_man') { + if (session.show_effect) { + new RainbowMan(data).appendTo(this.$el); + } else { + // For instance keep title blank, as we don't have title in data + this.call('notification', 'notify', { + title: "", + message: data.message, + sticky: false + }); + } + } else { + throw new Error('Unknown effect type: ' + type); + } + }, + /** + * Reacts to the end of the loading of the WebClient as a whole + * It allows for signalling to the rest of the ecosystem that the interface is usable + * + * @private + */ + _onWebClientStarted: function() { + if (!this.isStarted) { + // Listen to 'scroll' event and propagate it on main bus + this.action_manager.$el.on('scroll', core.bus.trigger.bind(core.bus, 'scroll')); + odoo.isReady = true; + core.bus.trigger('web_client_ready'); + if (session.uid === 1) { + this.$el.addClass('o_is_superuser'); + } + this.isStarted = true; + } + } +}); + +return AbstractWebClient; + +}); diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js new file mode 100644 index 00000000..6cb86030 --- /dev/null +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -0,0 +1,939 @@ +odoo.define('web.ActionManager', function (require) { +"use strict"; + +/** + * ActionManager + * + * The ActionManager is one of the centrepieces in the WebClient architecture. + * Its role is to makes sure that Odoo actions are properly started and + * coordinated. + */ + +var AbstractAction = require('web.AbstractAction'); +var concurrency = require('web.concurrency'); +var Context = require('web.Context'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dom = require('web.dom'); +var framework = require('web.framework'); +var pyUtils = require('web.py_utils'); +var Widget = require('web.Widget'); + +var _t = core._t; +var ActionManager = Widget.extend({ + className: 'o_action_manager', + custom_events: { + breadcrumb_clicked: '_onBreadcrumbClicked', + history_back: '_onHistoryBack', + push_state: '_onPushState', + redirect: '_onRedirect', + }, + + /** + * @override + * @param {Object} [userContext={}] + */ + init: function (parent, userContext) { + this._super.apply(this, arguments); + this.userContext = userContext || {}; + + // use a DropPrevious to drop previous actions when multiple actions are + // run simultaneously + this.dp = new concurrency.DropPrevious(); + + // 'actions' is an Object that registers the actions that are currently + // handled by the ActionManager (either stacked in the current window, + // or opened in dialogs) + this.actions = {}; + + // 'controllers' is an Object that registers the alive controllers + // linked registered actions, a controller being Object with keys + // (amongst others) 'jsID' (a local identifier) and 'widget' (the + // instance of the controller's widget) + this.controllers = {}; + + // 'controllerStack' is the stack of ids of the controllers currently + // displayed in the current window + this.controllerStack = []; + + // 'currentDialogController' is the current controller opened in a + // dialog (i.e. coming from an action with target='new') + this.currentDialogController = null; + }, + /** + * Called each time the action manager is attached into the DOM. + */ + on_attach_callback: function () { + this.isInDOM = true; + var currentController = this.getCurrentController(); + if (currentController) { + currentController.widget.on_attach_callback(); + } + }, + /** + * Called each time the action manager is detached from the DOM. + */ + on_detach_callback: function () { + this.isInDOM = false; + var currentController = this.getCurrentController(); + if (currentController) { + currentController.widget.on_detach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This function is called when the current controller is about to be + * removed from the DOM, because a new one will be pushed, or an old one + * will be restored. It ensures that the current controller can be left (for + * instance, that it has no unsaved changes). + * + * @returns {Promise} resolved if the current controller can be left, + * rejected otherwise. + */ + clearUncommittedChanges: function () { + var currentController = this.getCurrentController(); + if (currentController) { + return currentController.widget.canBeRemoved(); + } + return Promise.resolve(); + }, + /** + * This is the entry point to execute Odoo actions, given as an ID in + * database, an xml ID, a client action tag or an action descriptor. + * + * @param {number|string|Object} action the action to execute + * @param {Object} [options] + * @param {Object} [options.additional_context] additional context to be + * merged with the action's context. + * @param {boolean} [options.clear_breadcrumbs=false] set to true to clear + * the breadcrumbs history list + * @param {Function} [options.on_close] callback to be executed when the + * current action is active again (typically, if the new action is + * executed in target="new", on_close will be executed when the dialog is + * closed, if the current controller is still active) + * @param {Function} [options.on_reverse_breadcrumb] callback to be executed + * whenever an anterior breadcrumb item is clicked on + * @param {boolean} [options.pushState=true] set to false to prevent the + * ActionManager from pushing the state when the action is executed (this + * is useful when we come from a loadState()) + * @param {boolean} [options.replace_last_action=false] set to true to + * replace last part of the breadcrumbs with the action + * @return {Promise<Object>} resolved with the action when the action is + * loaded and appended to the DOM ; rejected if the action can't be + * executed (e.g. if doAction has been called to execute another action + * before this one was complete). + */ + doAction: function (action, options) { + var self = this; + options = _.defaults({}, options, { + additional_context: {}, + clear_breadcrumbs: false, + on_close: function () {}, + on_reverse_breadcrumb: function () {}, + pushState: true, + replace_last_action: false, + }); + + // build or load an action descriptor for the given action + var def; + if (_.isString(action) && core.action_registry.contains(action)) { + // action is a tag of a client action + action = { type: 'ir.actions.client', tag: action }; + } else if (_.isNumber(action) || _.isString(action)) { + // action is an id or xml id + def = this._loadAction(action, { + active_id: options.additional_context.active_id, + active_ids: options.additional_context.active_ids, + active_model: options.additional_context.active_model, + }).then(function (result) { + action = result; + }); + } + + return this.dp.add(Promise.resolve(def)).then(function () { + // action.target 'main' is equivalent to 'current' except that it + // also clears the breadcrumbs + options.clear_breadcrumbs = action.target === 'main' || + options.clear_breadcrumbs; + + self._preprocessAction(action, options); + + return self._handleAction(action, options).then(function () { + // now that the action has been executed, force its 'pushState' + // flag to 'true', as we don't want to prevent its controller + // from pushing its state if it changes in the future + action.pushState = true; + + return action; + }); + }).then(function(action) { + self.trigger_up('webclient_started'); + return action; + }); + }, + /** + * Compatibility with client actions that are still using do_push_state. + * + * @todo: convert all of them to trigger_up('push_state') instead. + * @param {Object} state + */ + do_push_state: function (state) { + this.trigger_up('push_state', {state: state}); + }, + /** + * Returns the action of the last controller in the controllerStack, i.e. + * the action of the currently displayed controller in the main window (not + * in a dialog), and null if there is no controller in the stack. + * + * @returns {Object|null} + */ + getCurrentAction: function () { + var controller = this.getCurrentController(); + return controller ? this.actions[controller.actionID] : null; + }, + /** + * Returns the last controller in the controllerStack, i.e. the currently + * displayed controller in the main window (not in a dialog), and + * null if there is no controller in the stack. + * + * @returns {Object|null} + */ + getCurrentController: function () { + var currentControllerID = _.last(this.controllerStack); + return currentControllerID ? this.controllers[currentControllerID] : null; + }, + /** + * Updates the UI according to the given state, for instance, executes a new + * action, or updates the state of the current action. + * + * @param {Object} state + * @param {integer|string} [state.action] the action to execute (given its + * id or tag for client actions) + * @returns {Promise} resolved when the UI has been updated + */ + loadState: function (state) { + var action; + if (!state.action) { + return Promise.resolve(); + } + if (_.isString(state.action) && core.action_registry.contains(state.action)) { + action = { + params: state, + tag: state.action, + type: 'ir.actions.client', + }; + } else { + action = state.action; + } + return this.doAction(action, { + clear_breadcrumbs: true, + pushState: false, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Appends the given controller to the DOM and restores its scroll position. + * Also updates the control panel. + * + * @private + * @param {Object} controller + */ + _appendController: function (controller) { + dom.append(this.$el, controller.widget.$el, { + in_DOM: this.isInDOM, + callbacks: [{widget: controller.widget}], + }); + + if (controller.scrollPosition) { + this.trigger_up('scrollTo', controller.scrollPosition); + } + }, + /** + * Closes the current dialog, if any. Because we listen to the 'closed' + * event triggered by the dialog when it is closed, this also destroys the + * embedded controller and removes the reference to the corresponding action. + * This also executes the 'on_close' handler in some cases, and may also + * provide infos for closing this dialog. + * + * @private + * @param {Object} options + * @param {Object} [options.infos] some infos related to the closing the + * dialog. + * @param {boolean} [options.silent=false] if true, the 'on_close' handler + * won't be called ; this is in general the case when the current dialog + * is closed because another action is opened, so we don't want the former + * action to execute its handler as it won't be displayed anyway + */ + _closeDialog: function (options) { + if (this.currentDialogController) { + this.currentDialogController.dialog.destroy(options); + } + }, + /** + * Detaches the current controller from the DOM and stores its scroll + * position, in case we'd come back to that controller later. + * + * @private + */ + _detachCurrentController: function () { + var currentController = this.getCurrentController(); + if (currentController) { + currentController.scrollPosition = this._getScrollPosition(); + dom.detach([{widget: currentController.widget}]); + } + }, + /** + * Executes actions for which a controller has to be appended to the DOM, + * either in the main content (target="current", by default), or in a dialog + * (target="new"). + * + * @private + * @param {Object} action + * @param {widget} action.controller a Widget instance to append to the DOM + * @param {string} [action.target="current"] set to "new" to render the + * controller in a dialog + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the controller is started and appended + */ + _executeAction: function (action, options) { + var self = this; + this.actions[action.jsID] = action; + + if (action.target === 'new') { + return this._executeActionInDialog(action, options); + } + + var controller = self.controllers[action.controllerID]; + return this.clearUncommittedChanges() + .then(function () { + return self.dp.add(self._startController(controller)); + }) + .then(function () { + if (self.currentDialogController) { + self._closeDialog({ silent: true }); + } + + // store the optional 'on_reverse_breadcrumb' handler + // AAB: store it on the AbstractAction instance, and call it + // automatically when the action is restored + if (options.on_reverse_breadcrumb) { + var currentAction = self.getCurrentAction(); + if (currentAction) { + currentAction.on_reverse_breadcrumb = options.on_reverse_breadcrumb; + } + } + + // update the internal state and the DOM + self._pushController(controller); + + // store the action into the sessionStorage so that it can be + // fully restored on F5 + self.call('session_storage', 'setItem', 'current_action', action._originalAction); + + return action; + }) + .guardedCatch(function () { + self._removeAction(action.jsID); + }); + }, + /** + * Executes actions with attribute target='new'. Such actions are rendered + * in a dialog. + * + * @private + * @param {Object} action + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the controller is rendered inside a + * dialog appended to the DOM + */ + _executeActionInDialog: function (action, options) { + var self = this; + var controller = this.controllers[action.controllerID]; + var widget = controller.widget; + + return this._startController(controller).then(function (controller) { + var prevDialogOnClose; + if (self.currentDialogController) { + prevDialogOnClose = self.currentDialogController.onClose; + self._closeDialog({ silent: true }); + } + + controller.onClose = prevDialogOnClose || options.on_close; + var dialog = new Dialog(self, _.defaults({}, options, { + buttons: [], + dialogClass: controller.className, + title: action.name, + size: action.context.dialog_size, + })); + /** + * @param {Object} [options={}] + * @param {Object} [options.infos] if provided and `silent` is + * unset, the `on_close` handler will pass this information, + * which gives some context for closing this dialog. + * @param {boolean} [options.silent=false] if set, do not call the + * `on_close` handler. + */ + dialog.on('closed', self, function (options) { + options = options || {}; + self._removeAction(action.jsID); + self.currentDialogController = null; + if (options.silent !== true) { + controller.onClose(options.infos); + } + }); + controller.dialog = dialog; + + return dialog.open().opened(function () { + self.currentDialogController = controller; + widget.setParent(dialog); + dom.append(dialog.$el, widget.$el, { + in_DOM: true, + callbacks: [{widget: controller.widget}], + }); + widget.renderButtons(dialog.$footer); + dialog.rebindButtonBehavior(); + + return action; + }); + }).guardedCatch(function () { + self._removeAction(action.jsID); + }); + }, + /** + * Executes actions of type 'ir.actions.client'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {string} action.tag the key of the action in the action_registry + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the client action has been executed + */ + _executeClientAction: function (action, options) { + var self = this; + var ClientAction = core.action_registry.get(action.tag); + if (!ClientAction) { + console.error("Could not find client action " + action.tag, action); + return Promise.reject(); + } + if (!(ClientAction.prototype instanceof Widget)) { + // the client action might be a function, which is executed and + // whose returned value might be another action to execute + var next = ClientAction(this, action); + if (next) { + return this.doAction(next, options); + } + return Promise.resolve(); + } + if (!(ClientAction.prototype instanceof AbstractAction)) { + console.warn('The client action ' + action.tag + ' should be an instance of AbstractAction!'); + } + + var controllerID = _.uniqueId('controller_'); + + var index = this._getControllerStackIndex(options); + options.breadcrumbs = this._getBreadcrumbs(this.controllerStack.slice(0, index)); + options.controllerID = controllerID; + var widget = new ClientAction(this, action, options); + var controller = { + actionID: action.jsID, + index: index, + jsID: controllerID, + title: widget.getTitle(), + widget: widget, + }; + this.controllers[controllerID] = controller; + action.controllerID = controllerID; + var prom = this._executeAction(action, options); + prom.then(function () { + self._pushState(controllerID, {}); + }); + return prom; + }, + /** + * Executes actions of type 'ir.actions.act_window_close', i.e. closes the + * last opened dialog. + * + * The action may also specify an effect to display right after the close + * action (e.g. rainbow man), or provide a reason for the close action. + * This is useful for decision making for the `on_close` handler. + * + * @private + * @param {Object} action + * @param {Object} [action.effect] effect to show up, e.g. rainbow man. + * @param {Object} [action.infos] infos on performing the close action. + * Useful for providing some context for the `on_close` handler. + * @returns {Promise} resolved immediately + */ + _executeCloseAction: function (action, options) { + var result; + if (!this.currentDialogController) { + result = options.on_close(action.infos); + } + + this._closeDialog({ infos: action.infos }); + + // display some effect (like rainbowman) on appropriate actions + if (action.effect) { + this.trigger_up('show_effect', action.effect); + } + + return Promise.resolve(result); + }, + /** + * Executes actions of type 'ir.actions.server'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {integer} action.id the db ID of the action to execute + * @param {Object} [action.context] + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the action has been executed + */ + _executeServerAction: function (action, options) { + var self = this; + var runDef = this._rpc({ + route: '/web/action/run', + params: { + action_id: action.id, + context: action.context || {}, + }, + }); + return this.dp.add(runDef).then(function (action) { + action = action || { type: 'ir.actions.act_window_close' }; + return self.doAction(action, options); + }); + }, + /** + * Executes actions of type 'ir.actions.act_url', i.e. redirects to the + * given url. + * + * @private + * @param {Object} action the description of the action to execute + * @param {string} action.url + * @param {string} [action.target] set to 'self' to redirect in the current page, + * redirects to a new page by default + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the redirection is done (immediately + * when redirecting to a new page) + */ + _executeURLAction: function (action, options) { + var url = action.url; + + if (action.target === 'self') { + framework.redirect(url); + return Promise.resolve(); + } else { + var w = window.open(url, '_blank'); + if (!w || w.closed || typeof w.closed === 'undefined') { + var message = _t('A popup window has been blocked. You ' + + 'may need to change your browser settings to allow ' + + 'popup windows for this page.'); + this.do_warn(false, message, true); + } + } + + options.on_close(); + + return Promise.resolve(); + }, + /** + * Returns a description of the controllers in the given controller stack. + * It is used to render the breadcrumbs. It is an array of Objects with keys + * 'title' (what to display in the breadcrumbs) and 'controllerID' (the ID + * of the corresponding controller, used to restore it when this part of the + * breadcrumbs is clicked). + * + * @private + * @param {string[]} controllerStack + * @returns {Object[]} + */ + _getBreadcrumbs: function (controllerStack) { + var self = this; + return _.map(controllerStack, function (controllerID) { + return { + controllerID: controllerID, + title: self.controllers[controllerID].title, + }; + }); + }, + /** + * Returns the index where a controller should be inserted in the controller + * stack according to the given options. By default, a controller is pushed + * on the top of the stack. + * + * @private + * @param {options} [options.clear_breadcrumbs=false] if true, insert at + * index 0 and remove all other controllers + * @param {options} [options.index=null] if given, that index is returned + * @param {options} [options.replace_last_action=false] if true, replace the + * last controller of the stack + * @returns {integer} index + */ + _getControllerStackIndex: function (options) { + var index; + if ('index' in options) { + index = options.index; + } else if (options.clear_breadcrumbs) { + index = 0; + } else if (options.replace_last_action) { + index = this.controllerStack.length - 1; + } else { + index = this.controllerStack.length; + } + return index; + }, + /** + * Returns an object containing information about the given controller, like + * its title, its action's id, the active_id and active_ids of the action... + * + * @private + * @param {string} controllerID + * @returns {Object} + */ + _getControllerState: function (controllerID) { + var controller = this.controllers[controllerID]; + var action = this.actions[controller.actionID]; + var state = { + title: controller.widget.getTitle(), + }; + if (action.id) { + state.action = action.id; + } else if (action.type === 'ir.actions.client') { + state.action = action.tag; + var params = _.pick(action.params, function (v) { + return _.isString(v) || _.isNumber(v); + }); + state = _.extend(params || {}, state); + } + if (action.context) { + var active_id = action.context.active_id; + if (active_id) { + state.active_id = active_id; + } + var active_ids = action.context.active_ids; + // we don't push active_ids if it's a single element array containing the active_id + // to make the url shorter in most cases + if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) { + state.active_ids = action.context.active_ids.join(','); + } + } + state = _.extend({}, controller.widget.getState(), state); + return state; + }, + /** + * Returns the current horizontal and vertical scroll positions. + * + * @private + * @returns {Object} + */ + _getScrollPosition: function () { + var scrollPosition; + this.trigger_up('getScrollPosition', { + callback: function (_scrollPosition) { + scrollPosition = _scrollPosition; + } + }); + return scrollPosition; + }, + /** + * Dispatches the given action to the corresponding handler to execute it, + * according to its type. This function can be overridden to extend the + * range of supported action types. + * + * @private + * @param {Object} action + * @param {string} action.type + * @param {Object} options + * @returns {Promise} resolved when the action has been executed ; rejected + * if the type of action isn't supported, or if the action can't be + * executed + */ + _handleAction: function (action, options) { + if (!action.type) { + console.error("No type for action", action); + return Promise.reject(); + } + switch (action.type) { + case 'ir.actions.act_url': + return this._executeURLAction(action, options); + case 'ir.actions.act_window_close': + return this._executeCloseAction(action, options); + case 'ir.actions.client': + return this._executeClientAction(action, options); + case 'ir.actions.server': + return this._executeServerAction(action, options); + default: + console.error("The ActionManager can't handle actions of type " + + action.type, action); + return Promise.reject(); + } + }, + /** + * Updates the internal state and the DOM with the given controller as + * current controller. + * + * @private + * @param {Object} controller + * @param {string} controller.jsID + * @param {Widget} controller.widget + * @param {integer} controller.index the controller is pushed at that + * position in the controller stack and controllers with an higher index + * are destroyed + */ + _pushController: function (controller) { + var self = this; + + // detach the current controller + this._detachCurrentController(); + + // push the new controller to the stack at the given position, and + // destroy controllers with an higher index + var toDestroy = this.controllerStack.slice(controller.index); + // reject from the list of controllers to destroy the one that we are + // currently pushing, or those linked to the same action as the one + // linked to the controller that we are pushing + toDestroy = _.reject(toDestroy, function (controllerID) { + return controllerID === controller.jsID || + self.controllers[controllerID].actionID === controller.actionID; + }); + this._removeControllers(toDestroy); + this.controllerStack = this.controllerStack.slice(0, controller.index); + this.controllerStack.push(controller.jsID); + + // append the new controller to the DOM + this._appendController(controller); + + // notify the environment of the new action + this.trigger_up('current_action_updated', { + action: this.getCurrentAction(), + controller: controller, + }); + + // close all dialogs when the current controller changes + core.bus.trigger('close_dialogs'); + + // toggle the fullscreen mode for actions in target='fullscreen' + this._toggleFullscreen(); + }, + /** + * Pushes the given state, with additional information about the given + * controller, like the action's id and the controller's title. + * + * @private + * @param {string} controllerID + * @param {Object} [state={}] + */ + _pushState: function (controllerID, state) { + var controller = this.controllers[controllerID]; + if (controller) { + var action = this.actions[controller.actionID]; + if (action.target === 'new' || action.pushState === false) { + // do not push state for actions in target="new" or for actions + // that have been explicitly marked as not pushable + return; + } + state = _.extend({}, state, this._getControllerState(controller.jsID)); + this.trigger_up('push_state', {state: state}); + } + }, + /** + * Loads an action from the database given its ID. + * + * @todo: turn this in a service (DataManager) + * @private + * @param {integer|string} action's ID or xml ID + * @param {Object} context + * @returns {Promise<Object>} resolved with the description of the action + */ + _loadAction: function (actionID, context) { + var self = this; + return new Promise(function (resolve, reject) { + self.trigger_up('load_action', { + actionID: actionID, + context: context, + on_success: resolve, + }); + }); + }, + /** + * Preprocesses the action before it is handled by the ActionManager + * (assigns a JS id, evaluates its context and domains, etc.). + * + * @param {Object} action + * @param {Object} options see @doAction options + */ + _preprocessAction: function (action, options) { + // ensure that the context and domain are evaluated + var context = new Context(this.userContext, options.additional_context, action.context); + action.context = pyUtils.eval('context', context); + if (action.domain) { + action.domain = pyUtils.eval('domain', action.domain, action.context); + } + + action._originalAction = JSON.stringify(action); + + action.jsID = _.uniqueId('action_'); + action.pushState = options.pushState; + }, + /** + * Unlinks the given action and its controller from the internal structures + * and destroys its controllers. + * + * @private + * @param {string} actionID the id of the action to remove + */ + _removeAction: function (actionID) { + var action = this.actions[actionID]; + var controller = this.controllers[action.controllerID]; + delete this.actions[action.jsID]; + delete this.controllers[action.controllerID]; + controller.widget.destroy(); + }, + /** + * Removes the given controllers and their corresponding actions. + * + * @see _removeAction + * @private + * @param {string[]} controllerIDs + */ + _removeControllers: function (controllerIDs) { + var self = this; + var actionsToRemove = _.map(controllerIDs, function (controllerID) { + return self.controllers[controllerID].actionID; + }); + _.each(_.uniq(actionsToRemove), this._removeAction.bind(this)); + }, + /** + * Restores a controller from the controllerStack and destroys all + * controllers stacked over the given controller (called when coming back + * using the breadcrumbs). + * + * @private + * @param {string} controllerID + * @returns {Promise} resolved when the controller has been restored + */ + _restoreController: function (controllerID) { + var self = this; + var controller = this.controllers[controllerID]; + // AAB: AbstractAction should define a proper hook to execute code when + // it is restored (other than do_show), and it should return a promise + var action = this.actions[controller.actionID]; + var def; + if (action.on_reverse_breadcrumb) { + def = action.on_reverse_breadcrumb(); + } + return Promise.resolve(def).then(function () { + return Promise.resolve(controller.widget.do_show()).then(function () { + var index = _.indexOf(self.controllerStack, controllerID); + self._pushController(controller, index); + }); + }); + }, + /** + * Starts the controller by appending it in a document fragment, so that it + * is ready when it will be appended to the DOM. This allows to prevent + * flickering for widgets doing async stuff in willStart() or start(). + * + * Also updates the control panel on any change of the title on controller's + * widget. + * + * @private + * @param {Object} controller + * @returns {Promise<Object>} resolved with the controller when it is ready + */ + _startController: function (controller) { + var fragment = document.createDocumentFragment(); + return controller.widget.appendTo(fragment).then(function () { + return controller; + }); + }, + /** + * Toggles the fullscreen mode if there is an action in target='fullscreen' + * in the current stack. + * + * @private + */ + _toggleFullscreen: function () { + var self = this; + var fullscreen = _.some(this.controllerStack, function (controllerID) { + var controller = self.controllers[controllerID]; + return self.actions[controller.actionID].target === 'fullscreen'; + }); + this.trigger_up('toggle_fullscreen', {fullscreen: fullscreen}); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + * @param {string} ev.data.controllerID + */ + _onBreadcrumbClicked: function (ev) { + ev.stopPropagation(); + this._restoreController(ev.data.controllerID); + }, + /** + * Goes back in the history: if a controller is opened in a dialog, closes + * the dialog, otherwise, restores the second to last controller from the + * stack. + * + * @private + */ + _onHistoryBack: function () { + if (this.currentDialogController) { + this._closeDialog(); + } else { + var length = this.controllerStack.length; + if (length > 1) { + this._restoreController(this.controllerStack[length - 2]); + } + } + }, + /** + * Intercepts and triggers a new push_state event, with additional + * information about the given controller. + * + * @private + * @param {OdooEvent} ev + * @param {string} ev.controllerID + * @param {Object} [ev.state={}] + */ + _onPushState: function (ev) { + if (ev.target !== this) { + ev.stopPropagation(); + this._pushState(ev.data.controllerID, ev.data.state); + } + }, + /** + * Intercepts and triggers a redirection on a link. + * + * @private + * @param {OdooEvent} ev + * @param {integer} ev.data.res_id + * @param {string} ev.data.res_model + */ + _onRedirect: function (ev) { + this.do_action({ + type:'ir.actions.act_window', + view_mode: 'form', + res_model: ev.data.res_model, + views: [[false, 'form']], + res_id: ev.data.res_id, + }); + }, +}); + +return ActionManager; + +}); diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js new file mode 100644 index 00000000..68a52ea1 --- /dev/null +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -0,0 +1,732 @@ +odoo.define('web.ActWindowActionManager', function (require) { +"use strict"; + +/** + * The purpose of this file is to add the support of Odoo actions of type + * 'ir.actions.act_window' to the ActionManager. + */ + +var ActionManager = require('web.ActionManager'); +var config = require('web.config'); +var Context = require('web.Context'); +var core = require('web.core'); +var pyUtils = require('web.py_utils'); +var view_registry = require('web.view_registry'); + +ActionManager.include({ + custom_events: _.extend({}, ActionManager.prototype.custom_events, { + execute_action: '_onExecuteAction', + switch_view: '_onSwitchView', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Override to handle the case of lazy-loaded controllers, which may be the + * last controller in the stack, but which should not be considered as + * current controller as they don't have an alive widget. + * + * Note: this function assumes that there can be at most one lazy loaded + * controller in the stack + * + * @override + */ + getCurrentController: function () { + var currentController = this._super.apply(this, arguments); + var action = currentController && this.actions[currentController.actionID]; + if (action && action.type === 'ir.actions.act_window' && !currentController.widget) { + var lastControllerID = this.controllerStack.pop(); + currentController = this._super.apply(this, arguments); + this.controllerStack.push(lastControllerID); + } + return currentController; + }, + /** + * Overrides to handle the case where an 'ir.actions.act_window' has to be + * loaded. + * + * @override + * @param {Object} state + * @param {integer|string} [state.action] the ID or xml ID of the action to + * execute + * @param {integer} [state.active_id] + * @param {string} [state.active_ids] + * @param {integer} [state.id] + * @param {integer} [state.view_id=false] + * @param {string} [state.view_type] + */ + loadState: function (state) { + var _super = this._super.bind(this); + var action; + var options = { + clear_breadcrumbs: true, + pushState: false, + }; + if (state.action) { + var currentController = this.getCurrentController(); + var currentAction = currentController && this.actions[currentController.actionID]; + if (currentAction && currentAction.id === state.action && + currentAction.type === 'ir.actions.act_window') { + // the action to load is already the current one, so update it + this._closeDialog(true); // there may be a currently opened dialog, close it + var viewOptions = {currentId: state.id}; + var viewType = state.view_type || currentController.viewType; + return this._switchController(currentAction, viewType, viewOptions); + } else if (!core.action_registry.contains(state.action)) { + // the action to load isn't the current one, so execute it + var context = {}; + if (state.active_id) { + context.active_id = state.active_id; + } + if (state.active_ids) { + // jQuery's BBQ plugin does some parsing on values that are valid integers + // which means that if there's only one item, it will do parseInt() on it, + // otherwise it will keep the comma seperated list as string + context.active_ids = state.active_ids.toString().split(',').map(function (id) { + return parseInt(id, 10) || id; + }); + } else if (state.active_id) { + context.active_ids = [state.active_id]; + } + context.params = state; + action = state.action; + options = _.extend(options, { + additional_context: context, + resID: state.id || undefined, // empty string with bbq + viewType: state.view_type, + }); + } + } else if (state.model && state.id) { + action = { + res_model: state.model, + res_id: state.id, + type: 'ir.actions.act_window', + views: [[state.view_id || false, 'form']], + }; + } else if (state.model && state.view_type) { + // this is a window action on a multi-record view, so restore it + // from the session storage + var storedAction = this.call('session_storage', 'getItem', 'current_action'); + var lastAction = JSON.parse(storedAction || '{}'); + if (lastAction.res_model === state.model) { + action = lastAction; + options.viewType = state.view_type; + } + } + if (action) { + return this.doAction(action, options); + } + return _super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiates the controller for a given action and view type, and adds it + * to the list of controllers in the action. + * + * @private + * @param {Object} action + * @param {AbstractController[]} action.controllers the already created + * controllers for this action + * @param {Object[]} action.views the views available for the action, each + * one containing its fieldsView + * @param {Object} action.env + * @param {string} viewType + * @param {Object} [viewOptions] dict of options passed to the initialization + * of the controller's widget + * @param {Object} [options] + * @param {string} [options.controllerID=false] when the controller has + * previously been lazy-loaded, we want to keep its jsID when loading it + * @param {integer} [options.index=0] the controller's index in the stack + * @param {boolean} [options.lazy=false] set to true to differ the + * initialization of the controller's widget + * @returns {Promise<Object>} resolved with the created controller + */ + _createViewController: function (action, viewType, viewOptions, options) { + var self = this; + var viewDescr = _.findWhere(action.views, {type: viewType}); + if (!viewDescr) { + // the requested view type isn't specified in the action (e.g. + // action with list view only, user clicks on a row in the list, it + // tries to switch to form view) + return Promise.reject(); + } + + options = options || {}; + var index = options.index || 0; + var controllerID = options.controllerID || _.uniqueId('controller_'); + var controller = { + actionID: action.jsID, + className: 'o_act_window', // used to remove the padding in dialogs + index: index, + jsID: controllerID, + viewType: viewType, + }; + Object.defineProperty(controller, 'title', { + get: function () { + // handle the case where the widget is lazy loaded + return controller.widget ? + controller.widget.getTitle() : + (action.display_name || action.name); + }, + }); + this.controllers[controllerID] = controller; + + if (!options.lazy) { + // build the view options from different sources + var flags = action.flags || {}; + viewOptions = _.extend({}, flags, flags[viewType], viewOptions, { + action: action, + breadcrumbs: this._getBreadcrumbs(this.controllerStack.slice(0, index)), + // pass the controllerID to the views as an hook for further + // communication with trigger_up + controllerID: controllerID, + }); + var rejection; + var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions); + var def = new Promise(function (resolve, reject) { + rejection = reject; + view.getController(self).then(function (widget) { + if (def.rejected) { + // the promise has been rejected meanwhile, meaning that + // the action has been removed, so simply destroy the widget + widget.destroy(); + } else { + controller.widget = widget; + resolve(controller); + } + }).guardedCatch(reject); + }); + // Need to define an reject property to call it into _destroyWindowAction + def.reject = rejection; + def.guardedCatch(function () { + def.rejected = true; + delete self.controllers[controllerID]; + }); + action.controllers[viewType] = def; + } else { + action.controllers[viewType] = Promise.resolve(controller); + } + return action.controllers[viewType]; + }, + /** + * Destroys the controllers and search view of a given action of type + * 'ir.actions.act_window'. + * + * @private + * @param {Object} action + */ + _destroyWindowAction: function (action) { + var self = this; + for (var c in action.controllers) { + var controllerDef = action.controllers[c]; + controllerDef.then(function (controller) { + delete self.controllers[controller.jsID]; + if (controller.widget) { + controller.widget.destroy(); + } + }); + // If controllerDef is not resolved yet, reject it so that the + // controller will be correctly destroyed as soon as it'll be ready, + // and its reference will be removed. Lazy-loaded controllers do + // not have a reject function on their promise + if (controllerDef.reject) { + controllerDef.reject(); + } + } + }, + /** + * Executes actions of type 'ir.actions.act_window'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {Array} action.views list of tuples [viewID, viewType] + * @param {Object} options @see doAction for details + * @param {integer} [options.resID] the current res ID + * @param {string} [options.viewType] the view to open + * @returns {Promise} resolved when the action is appended to the DOM + */ + _executeWindowAction: function (action, options) { + var self = this; + return this.dp.add(this._loadViews(action)).then(function (fieldsViews) { + var views = self._generateActionViews(action, fieldsViews); + action._views = action.views; // save the initial attribute + action.views = views; + action.controlPanelFieldsView = fieldsViews.search; + action.controllers = {}; + + // select the current view to display, and optionally the main view + // of the action which will be lazyloaded + var curView = options.viewType && _.findWhere(views, {type: options.viewType}); + var lazyView; + if (curView) { + if (!curView.multiRecord && views[0].multiRecord) { + lazyView = views[0]; + } + } else { + curView = views[0]; + } + + // use mobile-friendly view by default in mobile, if possible + if (config.device.isMobile) { + if (!curView.isMobileFriendly) { + curView = self._findMobileView(views, curView.multiRecord) || curView; + } + if (lazyView && !lazyView.isMobileFriendly) { + lazyView = self._findMobileView(views, lazyView.multiRecord) || lazyView; + } + } + + var lazyViewDef; + var lazyControllerID; + if (lazyView) { + // if the main view is lazy-loaded, its (lazy-loaded) controller is inserted + // into the controller stack (so that breadcrumbs can be correctly computed), + // so we force clear_breadcrumbs to false so that it won't be removed when the + // current controller will be inserted afterwards + options.clear_breadcrumbs = false; + // this controller being lazy-loaded, this call is actually sync + lazyViewDef = self._createViewController(action, lazyView.type, {}, {lazy: true}) + .then(function (lazyLoadedController) { + lazyControllerID = lazyLoadedController.jsID; + self.controllerStack.push(lazyLoadedController.jsID); + }); + } + return self.dp.add(Promise.resolve(lazyViewDef)) + .then(function () { + var viewOptions = { + controllerState: options.controllerState, + currentId: options.resID, + }; + var curViewDef = self._createViewController(action, curView.type, viewOptions, { + index: self._getControllerStackIndex(options), + }); + return self.dp.add(curViewDef); + }) + .then(function (controller) { + action.controllerID = controller.jsID; + return self._executeAction(action, options); + }) + .guardedCatch(function () { + if (lazyControllerID) { + var index = self.controllerStack.indexOf(lazyControllerID); + self.controllerStack = self.controllerStack.slice(0, index); + } + self._destroyWindowAction(action); + }); + }); + }, + /** + * Helper function to find the first mobile-friendly view, if any. + * + * @private + * @param {Array} views an array of views + * @param {boolean} multiRecord set to true iff we search for a multiRecord + * view + * @returns {Object|undefined} a mobile-friendly view of the requested + * multiRecord type, undefined if there is no such view + */ + _findMobileView: function (views, multiRecord) { + return _.findWhere(views, { + isMobileFriendly: true, + multiRecord: multiRecord, + }); + }, + /** + * Generate the description of the views of a given action. For each view, + * it generates a dict with information like the fieldsView, the view type, + * the Widget to use... + * + * @private + * @param {Object} action + * @param {Object} fieldsViews + * @returns {Object} + */ + _generateActionViews: function (action, fieldsViews) { + var views = []; + _.each(action.views, function (view) { + var viewType = view[1]; + var fieldsView = fieldsViews[viewType]; + var parsedXML = new DOMParser().parseFromString(fieldsView.arch, "text/xml"); + var key = parsedXML.documentElement.getAttribute('js_class'); + var View = view_registry.get(key || viewType); + if (View) { + views.push({ + accessKey: View.prototype.accessKey || View.prototype.accesskey, + displayName: View.prototype.display_name, + fieldsView: fieldsView, + icon: View.prototype.icon, + isMobileFriendly: View.prototype.mobile_friendly, + multiRecord: View.prototype.multi_record, + type: viewType, + viewID: view[0], + Widget: View, + }); + } else if (config.isDebug('assets')) { + console.log("View type '" + viewType + "' is not present in the view registry."); + } + }); + return views; + }, + /** + * Overrides to add specific information for controllers from actions of + * type 'ir.actions.act_window', like the res_model and the view_type. + * + * @override + * @private + */ + _getControllerState: function (controllerID) { + var state = this._super.apply(this, arguments); + var controller = this.controllers[controllerID]; + var action = this.actions[controller.actionID]; + if (action.type === 'ir.actions.act_window') { + state.model = action.res_model; + state.view_type = controller.viewType; + } + return state; + }, + /** + * Overrides to handle the 'ir.actions.act_window' actions. + * + * @override + * @private + */ + _handleAction: function (action, options) { + if (action.type === 'ir.actions.act_window') { + return this._executeWindowAction(action, options); + } + return this._super.apply(this, arguments); + }, + /** + * Loads the fields_views and fields for the given action. + * + * @private + * @param {Object} action + * @returns {Promise} + */ + _loadViews: function (action) { + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + var options = { + action_id: action.id, + toolbar: !inDialog && !inline, + }; + var views = action.views.slice(); + if (!inline && !(inDialog && action.views[0][1] === 'form')) { + options.load_filters = true; + var searchviewID = action.search_view_id && action.search_view_id[0]; + views.push([searchviewID || false, 'search']); + } + return this.loadViews(action.res_model, action.context, views, options); + }, + /** + * Overrides to handle the case of 'ir.actions.act_window' actions, i.e. + * destroys all controllers associated to the given action, and its search + * view. + * + * @override + * @private + */ + _removeAction: function (actionID) { + var action = this.actions[actionID]; + if (action.type === 'ir.actions.act_window') { + delete this.actions[action.jsID]; + this._destroyWindowAction(action); + } else { + this._super.apply(this, arguments); + } + }, + /** + * Overrides to handle the case where the controller to restore is from an + * 'ir.actions.act_window' action. In this case, only the controllers + * stacked over the one to restore *that are not from the same action* are + * destroyed. + * For instance, when going back to the list controller from a form + * controller of the same action using the breadcrumbs, the form controller + * isn't destroyed, as it might be reused in the future. + * + * @override + * @private + */ + _restoreController: function (controllerID) { + var self = this; + var controller = this.controllers[controllerID]; + var action = this.actions[controller.actionID]; + if (action.type === 'ir.actions.act_window') { + return this.clearUncommittedChanges().then(function () { + // AAB: this will be done directly in AbstractAction's restore + // function + var def = Promise.resolve(); + if (action.on_reverse_breadcrumb) { + def = action.on_reverse_breadcrumb(); + } + return Promise.resolve(def).then(function () { + return self._switchController(action, controller.viewType); + }); + }); + } + return this._super.apply(this, arguments); + }, + /** + * Handles the switch from a controller to another (either inside the same + * window action, or from a window action to another using the breadcrumbs). + * + * @private + * @param {Object} controller the controller to switch to + * @param {Object} [viewOptions] + * @return {Promise} resolved when the new controller is in the DOM + */ + _switchController: function (action, viewType, viewOptions) { + var self = this; + var view = _.findWhere(action.views, {type: viewType}); + if (!view) { + // can't switch to an unknown view + return Promise.reject(); + } + + var currentController = this.getCurrentController(); + var index; + if (currentController.actionID !== action.jsID) { + // the requested controller is from another action, so we went back + // to a previous action using the breadcrumbs + var controller = _.findWhere(this.controllers, { + actionID: action.jsID, + viewType: viewType, + }); + index = _.indexOf(this.controllerStack, controller.jsID); + } else { + // the requested controller is from the same action as the current + // one, so we either + // 1) go one step back from a mono record view to a multi record + // one using the breadcrumbs + // 2) or we switched from a view to another using the view + // switcher + // 3) or we opened a record from a multi record view + if (view.multiRecord) { + // cases 1) and 2) (with multi record views): replace the first + // controller linked to the same action in the stack + index = _.findIndex(this.controllerStack, function (controllerID) { + return self.controllers[controllerID].actionID === action.jsID; + }); + } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) { + // case 2) (with mono record views): replace the last + // controller by the new one if they are from the same action + // and if they both are mono record + index = this.controllerStack.length - 1; + } else { + // case 3): insert the controller on the top of the controller + // stack + index = this.controllerStack.length; + } + } + + var newController = function (controllerID) { + var options = { + controllerID: controllerID, + index: index, + }; + return self + ._createViewController(action, viewType, viewOptions, options) + .then(function (controller) { + return self._startController(controller); + }); + }; + + var controllerDef = action.controllers[viewType]; + if (controllerDef) { + controllerDef = controllerDef.then(function (controller) { + if (!controller.widget) { + // lazy loaded -> load it now (with same jsID) + return newController(controller.jsID); + } else { + return Promise.resolve(controller.widget.willRestore()).then(function () { + viewOptions = _.extend({}, viewOptions, { + breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)), + shouldUpdateSearchComponents: true, + }); + return controller.widget.reload(viewOptions).then(function () { + return controller; + }); + }); + } + }, function () { + // if the controllerDef is rejected, it probably means that the js + // code or the requests made to the server crashed. In that case, + // if we reuse the same promise, then the switch to the view is + // definitely blocked. We want to use a new controller, even though + // it is very likely that it will recrash again. At least, it will + // give more feedback to the user, and it could happen that one + // record crashes, but not another. + return newController(); + }); + } else { + controllerDef = newController(); + } + + return this.dp.add(controllerDef).then(function (controller) { + return self._pushController(controller); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handler for event 'execute_action', which is typically called when a + * button is clicked. The button may be of type 'object' (call a given + * method of a given model) or 'action' (execute a given action). + * Alternatively, the button may have the attribute 'special', and in this + * case an 'ir.actions.act_window_close' is executed. + * + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data.action_data typically, the html attributes of the + * button extended with additional information like the context + * @param {Object} [ev.data.action_data.special=false] + * @param {Object} [ev.data.action_data.type] 'object' or 'action', if set + * @param {Object} ev.data.env + * @param {function} [ev.data.on_closed] + * @param {function} [ev.data.on_fail] + * @param {function} [ev.data.on_success] + */ + _onExecuteAction: function (ev) { + ev.stopPropagation(); + var self = this; + var actionData = ev.data.action_data; + var env = ev.data.env; + var context = new Context(env.context, actionData.context || {}); + var recordID = env.currentID || null; // pyUtils handles null value, not undefined + var def; + + // determine the action to execute according to the actionData + if (actionData.special) { + def = Promise.resolve({ + type: 'ir.actions.act_window_close', + infos: { special: true }, + }); + } else if (actionData.type === 'object') { + // call a Python Object method, which may return an action to execute + var args = recordID ? [[recordID]] : [env.resIDs]; + if (actionData.args) { + try { + // warning: quotes and double quotes problem due to json and xml clash + // maybe we should force escaping in xml or do a better parse of the args array + var additionalArgs = JSON.parse(actionData.args.replace(/'/g, '"')); + args = args.concat(additionalArgs); + } catch (e) { + console.error("Could not JSON.parse arguments", actionData.args); + } + } + def = this._rpc({ + route: '/web/dataset/call_button', + params: { + args: args, + kwargs: {context: context.eval()}, + method: actionData.name, + model: env.model, + }, + }); + } else if (actionData.type === 'action') { + // execute a given action, so load it first + def = this._loadAction(actionData.name, _.extend(pyUtils.eval('context', context), { + active_model: env.model, + active_ids: env.resIDs, + active_id: recordID, + })); + } else { + def = Promise.reject(); + } + + // use the DropPrevious to prevent from executing the handler if another + // request (doAction, switchView...) has been done meanwhile ; execute + // the fail handler if the 'call_button' or 'loadAction' failed but not + // if the request failed due to the DropPrevious, + def.guardedCatch(ev.data.on_fail); + this.dp.add(def).then(function (action) { + // show effect if button have effect attribute + // rainbowman can be displayed from two places: from attribute on a button or from python + // code below handles the first case i.e 'effect' attribute on button. + var effect = false; + if (actionData.effect) { + effect = pyUtils.py_eval(actionData.effect); + } + + if (action && action.constructor === Object) { + // filter out context keys that are specific to the current action, because: + // - wrong default_* and search_default_* values won't give the expected result + // - wrong group_by values will fail and forbid rendering of the destination view + var ctx = new Context( + _.object(_.reject(_.pairs(env.context), function (pair) { + return pair[0].match('^(?:(?:default_|search_default_|show_).+|' + + '.+_view_ref|group_by|group_by_no_leaf|active_id|' + + 'active_ids|orderedBy)$') !== null; + })) + ); + ctx.add(actionData.context || {}); + ctx.add({active_model: env.model}); + if (recordID) { + ctx.add({ + active_id: recordID, + active_ids: [recordID], + }); + } + ctx.add(action.context || {}); + action.context = ctx; + // in case an effect is returned from python and there is already an effect + // attribute on the button, the priority is given to the button attribute + action.effect = effect || action.effect; + } else { + // if action doesn't return anything, but there is an effect + // attribute on the button, display rainbowman + action = { + effect: effect, + type: 'ir.actions.act_window_close', + }; + } + var options = {on_close: ev.data.on_closed}; + if (config.device.isMobile && actionData.mobile) { + options = Object.assign({}, options, actionData.mobile); + } + return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail); + }); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {string} ev.data.controllerID the id of the controller that + * triggered the event + * @param {string} ev.data.viewType the type of view to switch to + * @param {integer} [ev.data.res_id] the id of the record to open (for + * mono-record views) + * @param {mode} [ev.data.mode] the mode to open, i.e. 'edit' or 'readonly' + * (only relevant for form views) + */ + _onSwitchView: function (ev) { + ev.stopPropagation(); + const viewType = ev.data.view_type; + const currentController = this.getCurrentController(); + if (currentController.jsID === ev.data.controllerID) { + // only switch to the requested view if the controller that + // triggered the request is the current controller + const action = this.actions[currentController.actionID]; + const currentControllerState = currentController.widget.exportState(); + action.controllerState = _.extend({}, action.controllerState, currentControllerState); + const options = { + controllerState: action.controllerState, + currentId: ev.data.res_id, + }; + if (ev.data.mode) { + options.mode = ev.data.mode; + } + this._switchController(action, viewType, options); + } + }, +}); + +}); diff --git a/addons/web/static/src/js/chrome/action_manager_report.js b/addons/web/static/src/js/chrome/action_manager_report.js new file mode 100644 index 00000000..b3c05796 --- /dev/null +++ b/addons/web/static/src/js/chrome/action_manager_report.js @@ -0,0 +1,203 @@ +odoo.define('web.ReportActionManager', function (require) { +"use strict"; + +/** + * The purpose of this file is to add the support of Odoo actions of type + * 'ir.actions.report' to the ActionManager. + */ + +var ActionManager = require('web.ActionManager'); +var core = require('web.core'); +var framework = require('web.framework'); +var session = require('web.session'); + + +var _t = core._t; +var _lt = core._lt; + +// Messages that might be shown to the user dependening on the state of wkhtmltopdf +var link = '<br><br><a href="http://wkhtmltopdf.org/" target="_blank">wkhtmltopdf.org</a>'; +var WKHTMLTOPDF_MESSAGES = { + broken: _lt('Your installation of Wkhtmltopdf seems to be broken. The report will be shown ' + + 'in html.') + link, + install: _lt('Unable to find Wkhtmltopdf on this system. The report will be shown in ' + + 'html.') + link, + upgrade: _lt('You should upgrade your version of Wkhtmltopdf to at least 0.12.0 in order to ' + + 'get a correct display of headers and footers as well as support for ' + + 'table-breaking between pages.') + link, + workers: _lt('You need to start Odoo with at least two workers to print a pdf version of ' + + 'the reports.'), +}; + +ActionManager.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Downloads a PDF report for the given url. It blocks the UI during the + * report generation and download. + * + * @param {string} url + * @returns {Promise} resolved when the report has been downloaded ; + * rejected if something went wrong during the report generation + */ + _downloadReport: function (url) { + var self = this; + framework.blockUI(); + return new Promise(function (resolve, reject) { + var type = 'qweb-' + url.split('/')[2]; + var blocked = !session.get_file({ + url: '/report/download', + data: { + data: JSON.stringify([url, type]), + context: JSON.stringify(session.user_context), + }, + success: resolve, + error: (error) => { + self.call('crash_manager', 'rpc_error', error); + reject(); + }, + complete: framework.unblockUI, + }); + if (blocked) { + // AAB: this check should be done in get_file service directly, + // should not be the concern of the caller (and that way, get_file + // could return a promise) + var message = _t('A popup window with your report was blocked. You ' + + 'may need to change your browser settings to allow ' + + 'popup windows for this page.'); + self.do_warn(_t('Warning'), message, true); + } + }); + }, + + /** + * Launch download action of the report + * + * @private + * @param {Object} action the description of the action to execute + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the action has been executed + */ + _triggerDownload: function (action, options, type){ + var self = this; + var reportUrls = this._makeReportUrls(action); + return this._downloadReport(reportUrls[type]).then(function () { + if (action.close_on_report_download) { + var closeAction = { type: 'ir.actions.act_window_close' }; + return self.doAction(closeAction, _.pick(options, 'on_close')); + } else { + return options.on_close(); + } + }); + }, + /** + * Executes actions of type 'ir.actions.report'. + * + * @private + * @param {Object} action the description of the action to execute + * @param {Object} options @see doAction for details + * @returns {Promise} resolved when the action has been executed + */ + _executeReportAction: function (action, options) { + var self = this; + + if (action.report_type === 'qweb-html') { + return this._executeReportClientAction(action, options); + } else if (action.report_type === 'qweb-pdf') { + // check the state of wkhtmltopdf before proceeding + return this.call('report', 'checkWkhtmltopdf').then(function (state) { + // display a notification according to wkhtmltopdf's state + if (state in WKHTMLTOPDF_MESSAGES) { + self.do_notify(_t('Report'), WKHTMLTOPDF_MESSAGES[state], true); + } + + if (state === 'upgrade' || state === 'ok') { + // trigger the download of the PDF report + return self._triggerDownload(action, options, 'pdf'); + } else { + // open the report in the client action if generating the PDF is not possible + return self._executeReportClientAction(action, options); + } + }); + } else if (action.report_type === 'qweb-text') { + return self._triggerDownload(action, options, 'text'); + } else { + console.error("The ActionManager can't handle reports of type " + + action.report_type, action); + return Promise.reject(); + } + }, + /** + * Executes the report client action, either because the report_type is + * 'qweb-html', or because the PDF can't be generated by wkhtmltopdf (in + * the case of 'qweb-pdf' reports). + * + * @param {Object} action + * @param {Object} options + * @returns {Promise} resolved when the client action has been executed + */ + _executeReportClientAction: function (action, options) { + var urls = this._makeReportUrls(action); + var clientActionOptions = _.extend({}, options, { + context: action.context, + data: action.data, + display_name: action.display_name, + name: action.name, + report_file: action.report_file, + report_name: action.report_name, + report_url: urls.html, + }); + return this.doAction('report.client_action', clientActionOptions); + }, + /** + * Overrides to handle the 'ir.actions.report' actions. + * + * @override + * @private + */ + _handleAction: function (action, options) { + if (action.type === 'ir.actions.report') { + return this._executeReportAction(action, options); + } + return this._super.apply(this, arguments); + }, + /** + * Generates an object containing the report's urls (as value) for every + * qweb-type we support (as key). It's convenient because we may want to use + * another report's type at some point (for example, when `qweb-pdf` is not + * available). + * + * @param {Object} action + * @returns {Object} + */ + _makeReportUrls: function (action) { + var reportUrls = { + html: '/report/html/' + action.report_name, + pdf: '/report/pdf/' + action.report_name, + text: '/report/text/' + action.report_name, + }; + // We may have to build a query string with `action.data`. It's the place + // were report's using a wizard to customize the output traditionally put + // their options. + if (_.isUndefined(action.data) || _.isNull(action.data) || + (_.isObject(action.data) && _.isEmpty(action.data))) { + if (action.context.active_ids) { + var activeIDsPath = '/' + action.context.active_ids.join(','); + reportUrls = _.mapObject(reportUrls, function (value) { + return value += activeIDsPath; + }); + } + reportUrls.html += '?context=' + encodeURIComponent(JSON.stringify(session.user_context)); + } else { + var serializedOptionsPath = '?options=' + encodeURIComponent(JSON.stringify(action.data)); + serializedOptionsPath += '&context=' + encodeURIComponent(JSON.stringify(action.context)); + reportUrls = _.mapObject(reportUrls, function (value) { + return value += serializedOptionsPath; + }); + } + return reportUrls; + }, +}); +}); diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js new file mode 100644 index 00000000..82feba04 --- /dev/null +++ b/addons/web/static/src/js/chrome/action_mixin.js @@ -0,0 +1,235 @@ +odoo.define('web.ActionMixin', function (require) { + "use strict"; + + /** + * We define here the ActionMixin, the generic notion of action (from the point + * of view of the web client). In short, an action is a widget which controls + * the main part of the screen (everything below the navbar). + * + * More precisely, the action manager is the component that coordinates a stack + * of actions. Whenever the user navigates in the interface, switches views, + * open different menus, the action manager creates/updates/destroys special + * widgets which implements the ActionMixin. These actions need to answer to a + * standardised API, which is the reason for this mixin. + * + * In practice, most actions are view controllers (coming from an + * ir.action.act_window). However, some actions are 'client actions'. They + * also need to implement the ActionMixin for a better cooperation with the + * action manager. + * + * @module web.ActionMixin + * @extends WidgetAdapterMixin + */ + + const core = require('web.core'); + const { WidgetAdapterMixin } = require('web.OwlCompatibility'); + + const ActionMixin = Object.assign({}, WidgetAdapterMixin, { + template: 'Action', + + /** + * The action mixin assumes that it is rendered with the 'Action' template. + * This template has a special zone ('.o_content') where the content should + * be added. Actions that want to automatically render a template there + * should define the contentTemplate key. In short, client actions should + * probably define a contentTemplate key, and not a template key. + */ + contentTemplate: null, + + /** + * Events built by and managed by Odoo Framework + * + * It is expected that any Widget Class implementing this mixin + * will also implement the ParentedMixin which actually manages those + */ + custom_events: { + get_controller_query_params: '_onGetOwnedQueryParams', + }, + + /** + * If an action wants to use a control panel, it will be created and + * registered in this _controlPanel key (the widget). The way this control + * panel is created is up to the implementation (so, view controllers or + * client actions may have different needs). + * + * Note that most of the time, this key should be set by the framework, not + * by the code of the client action. + */ + _controlPanel: null, + + /** + * String containing the title of the client action (which may be needed to + * display in the breadcrumbs zone of the control panel). + * + * @see _setTitle + */ + _title: '', + + /** + * @override + */ + renderElement: function () { + this._super.apply(this, arguments); + if (this.contentTemplate) { + const content = core.qweb.render(this.contentTemplate, { widget: this }); + this.$('.o_content').append(content); + } + }, + + /** + * Called by the action manager when action is restored (typically, when + * the user clicks on the action in the breadcrumb) + * + * @returns {Promise|undefined} + */ + willRestore: function () { }, + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * In some situations, we need confirmation from the controller that the + * current state can be destroyed without prejudice to the user. For + * example, if the user has edited a form, maybe we should ask him if we + * can discard all his changes when we switch to another action. In that + * case, the action manager will call this method. If the returned + * promise is successfully resolved, then we can destroy the current action, + * otherwise, we need to stop. + * + * @returns {Promise} resolved if the action can be removed, rejected + * otherwise + */ + canBeRemoved: function () { + return Promise.resolve(); + }, + + /** + * This function is called when the current state of the action + * should be known. For instance, if the action is a view controller, + * this may be useful to reinstantiate a view in the same state. + * + * Typically the state can (and should) be encoded in a query object of + * the form:: + * + * { + * context: {...}, + * groupBy: [...], + * domain = [...], + * orderedBy = [...], + * } + * + * where the context key can contain many information. + * This method is mainly called during the creation of a custom filter. + * + * @returns {Object} + */ + getOwnedQueryParams: function () { + return {}; + }, + + /** + * Returns a serializable state that will be pushed in the URL by + * the action manager, allowing the action to be restarted correctly + * upon refresh. This function should be overriden to add extra information. + * Note that some keys are reserved by the framework and will thus be + * ignored ('action', 'active_id', 'active_ids' and 'title', for all + * actions, and 'model' and 'view_type' for act_window actions). + * + * @returns {Object} + */ + getState: function () { + return {}; + }, + + /** + * Returns a title that may be displayed in the breadcrumb area. For + * example, the name of the record (for a form view). This is actually + * important for the action manager: this is the way it is able to give + * the proper titles for other actions. + * + * @returns {string} + */ + getTitle: function () { + return this._title; + }, + + /** + * Renders the buttons to append, in most cases, to the control panel (in + * the bottom left corner). When the action is rendered in a dialog, those + * buttons might be moved to the dialog's footer. + * + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { }, + + /** + * Method used to update the widget buttons state. + */ + updateButtons: function () { }, + + /** + * The parameter newProps is used to update the props of + * the controlPanelWrapper before render it. The key 'cp_content' + * is not a prop of the control panel itself. One should if possible use + * the slot mechanism. + * + * @param {Object} [newProps={}] + * @returns {Promise} + */ + updateControlPanel: async function (newProps = {}) { + if (!this.withControlPanel && !this.hasControlPanel) { + return; + } + const props = Object.assign({}, newProps); // Work with a clean new object + if ('title' in props) { + this._setTitle(props.title); + this.controlPanelProps.title = this.getTitle(); + delete props.title; + } + if ('cp_content' in props) { + // cp_content has been updated: refresh it. + this.controlPanelProps.cp_content = Object.assign({}, + this.controlPanelProps.cp_content, + props.cp_content, + ); + delete props.cp_content; + } + // Update props state + Object.assign(this.controlPanelProps, props); + return this._controlPanelWrapper.update(this.controlPanelProps); + }, + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @param {string} title + */ + _setTitle: function (title) { + this._title = title; + }, + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * FIXME: this logic should be rethought + * + * Handles a context request: provides to the caller the state of the + * current controller. + * + * @private + * @param {function} callback used to send the requested state + */ + _onGetOwnedQueryParams: function (callback) { + const state = this.getOwnedQueryParams(); + callback(state || {}); + }, + }); + + return ActionMixin; +}); diff --git a/addons/web/static/src/js/chrome/apps_menu.js b/addons/web/static/src/js/chrome/apps_menu.js new file mode 100644 index 00000000..b7e057f8 --- /dev/null +++ b/addons/web/static/src/js/chrome/apps_menu.js @@ -0,0 +1,102 @@ +odoo.define('web.AppsMenu', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +var AppsMenu = Widget.extend({ + template: 'AppsMenu', + events: { + 'click .o_app': '_onAppsMenuItemClicked', + }, + /** + * @override + * @param {web.Widget} parent + * @param {Object} menuData + * @param {Object[]} menuData.children + */ + init: function (parent, menuData) { + this._super.apply(this, arguments); + this._activeApp = undefined; + this._apps = _.map(menuData.children, function (appMenuData) { + return { + actionID: parseInt(appMenuData.action.split(',')[1]), + menuID: appMenuData.id, + name: appMenuData.name, + xmlID: appMenuData.xmlid, + }; + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object[]} + */ + getApps: function () { + return this._apps; + }, + /** + * Open the first app in the list of apps. Returns whether one was found. + * + * @returns {Boolean} + */ + openFirstApp: function () { + if (!this._apps.length) { + return false; + } + var firstApp = this._apps[0]; + this._openApp(firstApp); + return true; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} app + */ + _openApp: function (app) { + this._setActiveApp(app); + this.trigger_up('app_clicked', { + action_id: app.actionID, + menu_id: app.menuID, + }); + }, + /** + * @private + * @param {Object} app + */ + _setActiveApp: function (app) { + var $oldActiveApp = this.$('.o_app.active'); + $oldActiveApp.removeClass('active'); + var $newActiveApp = this.$('.o_app[data-action-id="' + app.actionID + '"]'); + $newActiveApp.addClass('active'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on an item in the apps menu. + * + * @private + * @param {MouseEvent} ev + */ + _onAppsMenuItemClicked: function (ev) { + var $target = $(ev.currentTarget); + var actionID = $target.data('action-id'); + var menuID = $target.data('menu-id'); + var app = _.findWhere(this._apps, { actionID: actionID, menuID: menuID }); + this._openApp(app); + }, + +}); + +return AppsMenu; + +}); diff --git a/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js new file mode 100644 index 00000000..c67cb98b --- /dev/null +++ b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js @@ -0,0 +1,261 @@ +odoo.define('web.KeyboardNavigationMixin', function (require) { + "use strict"; + var BrowserDetection = require('web.BrowserDetection'); + const core = require('web.core'); + + /** + * list of the key that should not be used as accesskeys. Either because we want to reserve them for a specific behavior in Odoo or + * because they will not work in certain browser/OS + */ + var knownUnusableAccessKeys = [' ', + 'A', // reserved for Odoo Edit + 'B', // reserved for Odoo Previous Breadcrumb (Back) + 'C', // reserved for Odoo Create + 'H', // reserved for Odoo Home + 'J', // reserved for Odoo Discard + 'K', // reserved for Odoo Kanban view + 'L', // reserved for Odoo List view + 'N', // reserved for Odoo pager Next + 'P', // reserved for Odoo pager Previous + 'S', // reserved for Odoo Save + 'Q', // reserved for Odoo Search + 'E', // chrome does not support 'E' access key --> go to address bar to search google + 'F', // chrome does not support 'F' access key --> go to menu + 'D', // chrome does not support 'D' access key --> go to address bar + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' // reserved for Odoo menus + ]; + + var KeyboardNavigationMixin = { + events: { + 'keydown': '_onKeyDown', + 'keyup': '_onKeyUp', + }, + + /** + * @constructor + * @param {object} [options] + * @param {boolean} [options.autoAccessKeys=true] + * Whether accesskeys should be created automatically for buttons + * without them in the page. + */ + init: function (options) { + this.options = Object.assign({ + autoAccessKeys: true, + }, options); + this._areAccessKeyVisible = false; + this.BrowserDetection = new BrowserDetection(); + }, + /** + * @override + */ + start: function () { + const temp = this._hideAccessKeyOverlay.bind(this); + this._hideAccessKeyOverlay = () => temp(); + window.addEventListener('blur', this._hideAccessKeyOverlay); + core.bus.on('click', null, this._hideAccessKeyOverlay); + }, + /** + * @destructor + */ + destroy: function () { + window.removeEventListener('blur', this._hideAccessKeyOverlay); + core.bus.off('click', null, this._hideAccessKeyOverlay); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _addAccessKeyOverlays: function () { + var accesskeyElements = $(document).find('[accesskey]').filter(':visible'); + _.each(accesskeyElements, function (elem) { + var overlay = $(_.str.sprintf("<div class='o_web_accesskey_overlay'>%s</div>", $(elem).attr('accesskey').toUpperCase())); + + var $overlayParent; + if (elem.tagName.toUpperCase() === "INPUT") { + // special case for the search input that has an access key + // defined. We cannot set the overlay on the input itself, + // only on its parent. + $overlayParent = $(elem).parent(); + } else { + $overlayParent = $(elem); + } + + if ($overlayParent.css('position') !== 'absolute') { + $overlayParent.css('position', 'relative'); + } + overlay.appendTo($overlayParent); + }); + }, + /** + * @private + * @return {jQuery[]} + */ + _getAllUsedAccessKeys: function () { + var usedAccessKeys = knownUnusableAccessKeys.slice(); + this.$el.find('[accesskey]').each(function (_, elem) { + usedAccessKeys.push(elem.accessKey.toUpperCase()); + }); + return usedAccessKeys; + }, + /** + * hides the overlay that shows the access keys. + * + * @private + * @param $parent {jQueryElemen} the parent of the DOM element to which shorcuts overlay have been added + * @return {undefined|jQuery} + */ + _hideAccessKeyOverlay: function () { + this._areAccessKeyVisible = false; + var overlays = this.$el.find('.o_web_accesskey_overlay'); + if (overlays.length) { + return overlays.remove(); + } + }, + /** + * @private + */ + _setAccessKeyOnTopNavigation: function () { + this.$el.find('.o_menu_sections>li>a').each(function (number, item) { + item.accessKey = number + 1; + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Assign access keys to all buttons inside $el and sets an overlay to show the access key + * The access keys will be assigned using first the name of the button, letter by letter until we find one available, + * after that we will assign any available letters. + * Not all letters should be used as access keys, some of the should be reserved for standard odoo behavior or browser behavior + * + * @private + * @param keyDownEvent {jQueryKeyboardEvent} the keyboard event triggered + * return {undefined|false} + */ + _onKeyDown: function (keyDownEvent) { + if ($('body.o_ui_blocked').length && + (keyDownEvent.altKey || keyDownEvent.key === 'Alt') && + !keyDownEvent.ctrlKey) { + if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false; + if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation(); + if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true; + return false; + } + if (!this._areAccessKeyVisible && + (keyDownEvent.altKey || keyDownEvent.key === 'Alt') && + !keyDownEvent.ctrlKey) { + + this._areAccessKeyVisible = true; + + this._setAccessKeyOnTopNavigation(); + + var usedAccessKey = this._getAllUsedAccessKeys(); + + if (this.options.autoAccessKeys) { + var buttonsWithoutAccessKey = this.$el.find('button.btn:visible') + .not('[accesskey]') + .not('[disabled]') + .not('[tabindex="-1"]'); + _.each(buttonsWithoutAccessKey, function (elem) { + var buttonString = [elem.innerText, elem.title, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"].join(''); + for (var letterIndex = 0; letterIndex < buttonString.length; letterIndex++) { + var candidateAccessKey = buttonString[letterIndex].toUpperCase(); + if (candidateAccessKey >= 'A' && candidateAccessKey <= 'Z' && + !_.includes(usedAccessKey, candidateAccessKey)) { + elem.accessKey = candidateAccessKey; + usedAccessKey.push(candidateAccessKey); + break; + } + } + }); + } + + var elementsWithoutAriaKeyshortcut = this.$el.find('[accesskey]').not('[aria-keyshortcuts]'); + _.each(elementsWithoutAriaKeyshortcut, function (elem) { + elem.setAttribute('aria-keyshortcuts', 'Alt+Shift+' + elem.accessKey); + }); + this._addAccessKeyOverlays(); + } + // on mac, there are a number of keys that are only accessible though the usage of + // the ALT key (like the @ sign in most keyboards) + // for them we do not facilitate the access keys, so they will need to be activated classically + // though Control + Alt + key (case sensitive), see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey + if (this.BrowserDetection.isOsMac()) + return; + + if (keyDownEvent.altKey && !keyDownEvent.ctrlKey && keyDownEvent.key.length === 1) { // we don't want to catch the Alt key down, only the characters A to Z and number keys + var elementWithAccessKey = []; + if (keyDownEvent.keyCode >= 65 && keyDownEvent.keyCode <= 90 || keyDownEvent.keyCode >= 97 && keyDownEvent.keyCode <= 122) { + // 65 = A, 90 = Z, 97 = a, 122 = z + elementWithAccessKey = document.querySelectorAll('[accesskey="' + String.fromCharCode(keyDownEvent.keyCode).toLowerCase() + + '"], [accesskey="' + String.fromCharCode(keyDownEvent.keyCode).toUpperCase() + '"]'); + if (elementWithAccessKey.length) { + if (this.BrowserDetection.isOsMac() || + !this.BrowserDetection.isBrowserChrome()) { // on windows and linux, chrome does not prevent the default of the accesskeys + elementWithAccessKey[0].focus(); + elementWithAccessKey[0].click(); + if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false; + if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation(); + if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true; + return false; + } + } + } + else { + // identify if the user has tapped on the number keys above the text keys. + // this is not trivial because alt is a modifier and will not input the actual number in most keyboard layouts + var numberKey; + if (keyDownEvent.originalEvent.code && keyDownEvent.originalEvent.code.indexOf('Digit') === 0) { + //chrome & FF have the key Digit set correctly for the numbers + numberKey = keyDownEvent.originalEvent.code[keyDownEvent.originalEvent.code.length - 1]; + } else if (keyDownEvent.originalEvent.key && + keyDownEvent.originalEvent.key.length === 1 && + keyDownEvent.originalEvent.key >= '0' && + keyDownEvent.originalEvent.key <= '9') { + //edge does not use 'code' on the original event, but the 'key' is set correctly + numberKey = keyDownEvent.originalEvent.key; + } else if (keyDownEvent.keyCode >= 48 && keyDownEvent.keyCode <= 57) { + //fallback on keyCode if both code and key are either not set or not digits + numberKey = keyDownEvent.keyCode - 48; + } + + if (numberKey >= '0' && numberKey <= '9') { + elementWithAccessKey = document.querySelectorAll('[accesskey="' + numberKey + '"]'); + if (elementWithAccessKey.length) { + elementWithAccessKey[0].click(); + if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false; + if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation(); + if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true; + return false; + } + } + } + } + }, + /** + * hides the shortcut overlays when keyup event is triggered on the ALT key + * + * @private + * @param keyUpEvent {jQueryKeyboardEvent} the keyboard event triggered + * @return {undefined|false} + */ + _onKeyUp: function (keyUpEvent) { + if ((keyUpEvent.altKey || keyUpEvent.key === 'Alt') && !keyUpEvent.ctrlKey) { + this._hideAccessKeyOverlay(); + if (keyUpEvent.preventDefault) keyUpEvent.preventDefault(); else keyUpEvent.returnValue = false; + if (keyUpEvent.stopPropagation) keyUpEvent.stopPropagation(); + if (keyUpEvent.cancelBubble) keyUpEvent.cancelBubble = true; + return false; + } + }, + }; + + return KeyboardNavigationMixin; + +}); diff --git a/addons/web/static/src/js/chrome/loading.js b/addons/web/static/src/js/chrome/loading.js new file mode 100644 index 00000000..9ea48a40 --- /dev/null +++ b/addons/web/static/src/js/chrome/loading.js @@ -0,0 +1,80 @@ +odoo.define('web.Loading', function (require) { +"use strict"; + +/** + * Loading Indicator + * + * When the user performs an action, it is good to give him some feedback that + * something is currently happening. The purpose of the Loading Indicator is to + * display a small rectangle on the bottom right of the screen with just the + * text 'Loading' and the number of currently running rpcs. + * + * After a delay of 3s, if a rpc is still not completed, we also block the UI. + */ + +var config = require('web.config'); +var core = require('web.core'); +var framework = require('web.framework'); +var Widget = require('web.Widget'); + +var _t = core._t; + +var Loading = Widget.extend({ + template: "Loading", + + init: function(parent) { + this._super(parent); + this.count = 0; + this.blocked_ui = false; + }, + start: function() { + core.bus.on('rpc_request', this, this.request_call); + core.bus.on("rpc_response", this, this.response_call); + core.bus.on("rpc_response_failed", this, this.response_call); + }, + destroy: function() { + this.on_rpc_event(-this.count); + this._super(); + }, + request_call: function() { + this.on_rpc_event(1); + }, + response_call: function() { + this.on_rpc_event(-1); + }, + on_rpc_event : function(increment) { + var self = this; + if (!this.count && increment === 1) { + // Block UI after 3s + this.long_running_timer = setTimeout(function () { + self.blocked_ui = true; + framework.blockUI(); + }, 3000); + } + + this.count += increment; + if (this.count > 0) { + if (config.isDebug()) { + this.$el.text(_.str.sprintf( _t("Loading (%d)"), this.count)); + } else { + this.$el.text(_t("Loading")); + } + this.$el.show(); + this.getParent().$el.addClass('oe_wait'); + } else { + this.count = 0; + clearTimeout(this.long_running_timer); + // Don't unblock if blocked by somebody else + if (self.blocked_ui) { + this.blocked_ui = false; + framework.unblockUI(); + } + this.$el.fadeOut(); + this.getParent().$el.removeClass('oe_wait'); + } + } +}); + +return Loading; +}); + diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js new file mode 100644 index 00000000..28585aa1 --- /dev/null +++ b/addons/web/static/src/js/chrome/menu.js @@ -0,0 +1,243 @@ +odoo.define('web.Menu', function (require) { +"use strict"; + +var AppsMenu = require('web.AppsMenu'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +var SystrayMenu = require('web.SystrayMenu'); +var UserMenu = require('web.UserMenu'); +var Widget = require('web.Widget'); + +UserMenu.prototype.sequence = 0; // force UserMenu to be the right-most item in the systray +SystrayMenu.Items.push(UserMenu); + +var QWeb = core.qweb; + +var Menu = Widget.extend({ + template: 'Menu', + menusTemplate: 'Menu.sections', + events: { + 'mouseover .o_menu_sections > li:not(.show)': '_onMouseOverMenu', + 'click .o_menu_brand': '_onAppNameClicked', + }, + + init: function (parent, menu_data) { + var self = this; + this._super.apply(this, arguments); + + this.$menu_sections = {}; + this.menu_data = menu_data; + + // Prepare navbar's menus + var $menu_sections = $(QWeb.render(this.menusTemplate, { + menu_data: this.menu_data, + })); + $menu_sections.filter('section').each(function () { + self.$menu_sections[parseInt(this.className, 10)] = $(this).children('li'); + }); + + // Bus event + core.bus.on('change_menu_section', this, this.change_menu_section); + }, + start: function () { + var self = this; + + this.$menu_apps = this.$('.o_menu_apps'); + this.$menu_brand_placeholder = this.$('.o_menu_brand'); + this.$section_placeholder = this.$('.o_menu_sections'); + this._updateMenuBrand(); + + // Navbar's menus event handlers + var on_secondary_menu_click = function (ev) { + ev.preventDefault(); + var menu_id = $(ev.currentTarget).data('menu'); + var action_id = $(ev.currentTarget).data('action-id'); + self._on_secondary_menu_click(menu_id, action_id); + }; + var menu_ids = _.keys(this.$menu_sections); + var primary_menu_id, $section; + for (var i = 0; i < menu_ids.length; i++) { + primary_menu_id = menu_ids[i]; + $section = this.$menu_sections[primary_menu_id]; + $section.on('click', 'a[data-menu]', self, on_secondary_menu_click.bind(this)); + } + + // Apps Menu + this._appsMenu = new AppsMenu(self, this.menu_data); + var appsMenuProm = this._appsMenu.appendTo(this.$menu_apps); + + // Systray Menu + this.systray_menu = new SystrayMenu(this); + var systrayMenuProm = this.systray_menu.attachTo(this.$('.o_menu_systray')).then(function() { + self.systray_menu.on_attach_callback(); // At this point, we know we are in the DOM + dom.initAutoMoreMenu(self.$section_placeholder, { + maxWidth: function () { + return self.$el.width() - (self.$menu_apps.outerWidth(true) + self.$menu_brand_placeholder.outerWidth(true) + self.systray_menu.$el.outerWidth(true)); + }, + sizeClass: 'SM', + }); + }); + + + + return Promise.all([this._super.apply(this, arguments), appsMenuProm, systrayMenuProm]); + }, + change_menu_section: function (primary_menu_id) { + if (!this.$menu_sections[primary_menu_id]) { + this._updateMenuBrand(); + return; // unknown menu_id + } + + if (this.current_primary_menu === primary_menu_id) { + return; // already in that menu + } + + if (this.current_primary_menu) { + this.$menu_sections[this.current_primary_menu].detach(); + } + + // Get back the application name + for (var i = 0; i < this.menu_data.children.length; i++) { + if (this.menu_data.children[i].id === primary_menu_id) { + this._updateMenuBrand(this.menu_data.children[i].name); + break; + } + } + + this.$menu_sections[primary_menu_id].appendTo(this.$section_placeholder); + this.current_primary_menu = primary_menu_id; + + core.bus.trigger('resize'); + }, + _trigger_menu_clicked: function (menu_id, action_id) { + this.trigger_up('menu_clicked', { + id: menu_id, + action_id: action_id, + previous_menu_id: this.current_secondary_menu || this.current_primary_menu, + }); + }, + /** + * Updates the name of the app in the menu to the value of brandName. + * If brandName is falsy, hides the menu and its sections. + * + * @private + * @param {brandName} string + */ + _updateMenuBrand: function (brandName) { + if (brandName) { + this.$menu_brand_placeholder.text(brandName).show(); + this.$section_placeholder.show(); + } else { + this.$menu_brand_placeholder.hide() + this.$section_placeholder.hide(); + } + }, + _on_secondary_menu_click: function (menu_id, action_id) { + var self = this; + + // It is still possible that we don't have an action_id (for example, menu toggler) + if (action_id) { + self._trigger_menu_clicked(menu_id, action_id); + this.current_secondary_menu = menu_id; + } + }, + /** + * Helpers used by web_client in order to restore the state from + * an url (by restore, read re-synchronize menu and action manager) + */ + action_id_to_primary_menu_id: function (action_id) { + var primary_menu_id, found; + for (var i = 0; i < this.menu_data.children.length && !primary_menu_id; i++) { + found = this._action_id_in_subtree(this.menu_data.children[i], action_id); + if (found) { + primary_menu_id = this.menu_data.children[i].id; + } + } + return primary_menu_id; + }, + _action_id_in_subtree: function (root, action_id) { + // action_id can be a string or an integer + if (root.action && root.action.split(',')[1] === String(action_id)) { + return true; + } + var found; + for (var i = 0; i < root.children.length && !found; i++) { + found = this._action_id_in_subtree(root.children[i], action_id); + } + return found; + }, + menu_id_to_action_id: function (menu_id, root) { + if (!root) { + root = $.extend(true, {}, this.menu_data); + } + + if (root.id === menu_id) { + return root.action.split(',')[1] ; + } + for (var i = 0; i < root.children.length; i++) { + var action_id = this.menu_id_to_action_id(menu_id, root.children[i]); + if (action_id !== undefined) { + return action_id; + } + } + return undefined; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the id of the current primary (first level) menu. + * + * @returns {integer} + */ + getCurrentPrimaryMenu: function () { + return this.current_primary_menu; + }, + /** + * Open the first app, returns whether an application was found. + * + * @returns {Boolean} + */ + openFirstApp: function () { + return this._appsMenu.openFirstApp(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When clicking on app name, opens the first action of the app + * + * @private + * @param {MouseEvent} ev + */ + _onAppNameClicked: function (ev) { + var actionID = parseInt(this.menu_id_to_action_id(this.current_primary_menu)); + this._trigger_menu_clicked(this.current_primary_menu, actionID); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseOverMenu: function (ev) { + if (config.device.isMobile) { + return; + } + var $target = $(ev.currentTarget); + var $opened = $target.siblings('.show'); + if ($opened.length) { + $opened.find('[data-toggle="dropdown"]:first').dropdown('toggle'); + $opened.removeClass('show'); + $target.find('[data-toggle="dropdown"]:first').dropdown('toggle'); + $target.addClass('show'); + } + }, +}); + +return Menu; + +}); diff --git a/addons/web/static/src/js/chrome/root_widget.js b/addons/web/static/src/js/chrome/root_widget.js new file mode 100644 index 00000000..9cf0515b --- /dev/null +++ b/addons/web/static/src/js/chrome/root_widget.js @@ -0,0 +1,7 @@ +odoo.define('root.widget', function (require) { +"use strict"; + +var webClient = require('web.web_client'); + +return webClient; +}); diff --git a/addons/web/static/src/js/chrome/systray_menu.js b/addons/web/static/src/js/chrome/systray_menu.js new file mode 100644 index 00000000..0e25f256 --- /dev/null +++ b/addons/web/static/src/js/chrome/systray_menu.js @@ -0,0 +1,65 @@ +odoo.define('web.SystrayMenu', function (require) { +"use strict"; + +var dom = require('web.dom'); +var Widget = require('web.Widget'); + +/** + * The SystrayMenu is the class that manage the list of icons in the top right + * of the menu bar. + */ +var SystrayMenu = Widget.extend({ + /** + * This widget renders the systray menu. It creates and renders widgets + * pushed in instance.web.SystrayItems. + */ + init: function (parent) { + this._super(parent); + this.items = []; + this.widgets = []; + }, + /** + * Instanciate the items and add them into a temporary fragmenet + * @override + */ + willStart: function () { + var self = this; + var proms = []; + SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) { + return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50; + }); + + SystrayMenu.Items.forEach(function (WidgetClass) { + var cur_systray_item = new WidgetClass(self); + self.widgets.push(cur_systray_item); + proms.push(cur_systray_item.appendTo($('<div>'))); + }); + + return this._super.apply(this, arguments).then(function () { + return Promise.all(proms); + }); + }, + on_attach_callback() { + this.widgets + .filter(widget => widget.on_attach_callback) + .forEach(widget => widget.on_attach_callback()); + }, + /** + * Add the instanciated items, using the object located in this.wisgets + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.widgets.forEach(function (widget) { + dom.prepend(self.$el, widget.$el); + }); + }); + }, +}); + +SystrayMenu.Items = []; + +return SystrayMenu; + +}); + diff --git a/addons/web/static/src/js/chrome/user_menu.js b/addons/web/static/src/js/chrome/user_menu.js new file mode 100644 index 00000000..4281bfd4 --- /dev/null +++ b/addons/web/static/src/js/chrome/user_menu.js @@ -0,0 +1,132 @@ +odoo.define('web.UserMenu', function (require) { +"use strict"; + +/** + * This widget is appended by the webclient to the right of the navbar. + * It displays the avatar and the name of the logged user (and optionally the + * db name, in debug mode). + * If clicked, it opens a dropdown allowing the user to perform actions like + * editing its preferences, accessing the documentation, logging out... + */ + +var config = require('web.config'); +var core = require('web.core'); +var framework = require('web.framework'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); + +var _t = core._t; +var QWeb = core.qweb; + +var UserMenu = Widget.extend({ + template: 'UserMenu', + + /** + * @override + * @returns {Promise} + */ + start: function () { + var self = this; + var session = this.getSession(); + this.$el.on('click', '[data-menu]', function (ev) { + ev.preventDefault(); + var menu = $(this).data('menu'); + self['_onMenu' + menu.charAt(0).toUpperCase() + menu.slice(1)](); + }); + return this._super.apply(this, arguments).then(function () { + var $avatar = self.$('.oe_topbar_avatar'); + if (!session.uid) { + $avatar.attr('src', $avatar.data('default-src')); + return Promise.resolve(); + } + var topbar_name = session.name; + if (config.isDebug()) { + topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db); + } + self.$('.oe_topbar_name').text(topbar_name); + var avatar_src = session.url('/web/image', { + model:'res.users', + field: 'image_128', + id: session.uid, + }); + $avatar.attr('src', avatar_src); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onMenuAccount: function () { + var self = this; + this.trigger_up('clear_uncommitted_changes', { + callback: function () { + self._rpc({route: '/web/session/account'}) + .then(function (url) { + framework.redirect(url); + }) + .guardedCatch(function (result, ev){ + ev.preventDefault(); + framework.redirect('https://accounts.odoo.com/account'); + }); + }, + }); + }, + /** + * @private + */ + _onMenuDocumentation: function () { + window.open('https://www.odoo.com/documentation/14.0', '_blank'); + }, + /** + * @private + */ + _onMenuLogout: function () { + this.trigger_up('clear_uncommitted_changes', { + callback: this.do_action.bind(this, 'logout'), + }); + }, + /** + * @private + */ + _onMenuSettings: function () { + var self = this; + var session = this.getSession(); + this.trigger_up('clear_uncommitted_changes', { + callback: function () { + self._rpc({ + model: "res.users", + method: "action_get" + }) + .then(function (result) { + result.res_id = session.uid; + self.do_action(result); + }); + }, + }); + }, + /** + * @private + */ + _onMenuSupport: function () { + window.open('https://www.odoo.com/buy', '_blank'); + }, + /** + * @private + */ + _onMenuShortcuts: function() { + new Dialog(this, { + size: 'large', + dialogClass: 'o_act_window', + title: _t("Keyboard Shortcuts"), + $content: $(QWeb.render("UserMenu.shortcuts")) + }).open(); + }, +}); + +return UserMenu; + +}); diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js new file mode 100644 index 00000000..3522a7fc --- /dev/null +++ b/addons/web/static/src/js/chrome/web_client.js @@ -0,0 +1,238 @@ +odoo.define('web.WebClient', function (require) { +"use strict"; + +var AbstractWebClient = require('web.AbstractWebClient'); +var config = require('web.config'); +var core = require('web.core'); +var data_manager = require('web.data_manager'); +var dom = require('web.dom'); +var Menu = require('web.Menu'); +var session = require('web.session'); + +return AbstractWebClient.extend({ + custom_events: _.extend({}, AbstractWebClient.prototype.custom_events, { + app_clicked: 'on_app_clicked', + menu_clicked: 'on_menu_clicked', + }), + start: function () { + core.bus.on('change_menu_section', this, function (menuID) { + this.do_push_state(_.extend($.bbq.getState(), { + menu_id: menuID, + })); + }); + + return this._super.apply(this, arguments); + }, + bind_events: function () { + var self = this; + this._super.apply(this, arguments); + + /* + Small patch to allow having a link with a href towards an anchor. Since odoo use hashtag + to represent the current state of the view, we can't easily distinguish between a link + towards an anchor and a link towards anoter view/state. If we want to navigate towards an + anchor, we must not change the hash of the url otherwise we will be redirected to the app + switcher instead. + To check if we have an anchor, first check if we have an href attributes starting with #. + Try to find a element in the DOM using JQuery selector. + If we have a match, it means that it is probably a link to an anchor, so we jump to that anchor. + */ + this.$el.on('click', 'a', function (ev) { + var disable_anchor = ev.target.attributes.disable_anchor; + if (disable_anchor && disable_anchor.value === "true") { + return; + } + + var href = ev.target.attributes.href; + if (href) { + if (href.value[0] === '#' && href.value.length > 1) { + if (self.$("[id='"+href.value.substr(1)+"']").length) { + ev.preventDefault(); + self.trigger_up('scrollTo', {'selector': href.value}); + } + } + } + }); + }, + load_menus: function () { + return (odoo.loadMenusPromise || odoo.reloadMenus()) + .then(function (menuData) { + // Compute action_id if not defined on a top menu item + for (var i = 0; i < menuData.children.length; i++) { + var child = menuData.children[i]; + if (child.action === false) { + while (child.children && child.children.length) { + child = child.children[0]; + if (child.action) { + menuData.children[i].action = child.action; + break; + } + } + } + } + odoo.loadMenusPromise = null; + return menuData; + }); + }, + async show_application() { + this.set_title(); + + await this.menu_dp.add(this.instanciate_menu_widgets()); + $(window).bind('hashchange', this.on_hashchange); + + const state = $.bbq.getState(true); + if (!_.isEqual(_.keys(state), ["cids"])) { + return this.on_hashchange(); + } + + const [data] = await this.menu_dp.add(this._rpc({ + model: 'res.users', + method: 'read', + args: [session.uid, ["action_id"]], + })); + if (data.action_id) { + await this.do_action(data.action_id[0]); + this.menu.change_menu_section(this.menu.action_id_to_primary_menu_id(data.action_id[0])); + return; + } + + if (!this.menu.openFirstApp()) { + this.trigger_up('webclient_started'); + } + }, + + instanciate_menu_widgets: function () { + var self = this; + var proms = []; + return this.load_menus().then(function (menuData) { + self.menu_data = menuData; + + // Here, we instanciate every menu widgets and we immediately append them into dummy + // document fragments, so that their `start` method are executed before inserting them + // into the DOM. + if (self.menu) { + self.menu.destroy(); + } + self.menu = new Menu(self, menuData); + proms.push(self.menu.prependTo(self.$el)); + return Promise.all(proms); + }); + }, + + // -------------------------------------------------------------- + // URL state handling + // -------------------------------------------------------------- + on_hashchange: function (event) { + if (this._ignore_hashchange) { + this._ignore_hashchange = false; + return Promise.resolve(); + } + + var self = this; + return this.clear_uncommitted_changes().then(function () { + var stringstate = $.bbq.getState(false); + if (!_.isEqual(self._current_state, stringstate)) { + var state = $.bbq.getState(true); + if (state.action || (state.model && (state.view_type || state.id))) { + return self.menu_dp.add(self.action_manager.loadState(state, !!self._current_state)).then(function () { + if (state.menu_id) { + if (state.menu_id !== self.menu.current_primary_menu) { + core.bus.trigger('change_menu_section', state.menu_id); + } + } else { + var action = self.action_manager.getCurrentAction(); + if (action) { + var menu_id = self.menu.action_id_to_primary_menu_id(action.id); + core.bus.trigger('change_menu_section', menu_id); + } + } + }); + } else if (state.menu_id) { + var action_id = self.menu.menu_id_to_action_id(state.menu_id); + return self.menu_dp.add(self.do_action(action_id, {clear_breadcrumbs: true})).then(function () { + core.bus.trigger('change_menu_section', state.menu_id); + }); + } else { + self.menu.openFirstApp(); + } + } + self._current_state = stringstate; + }, function () { + if (event) { + self._ignore_hashchange = true; + window.location = event.originalEvent.oldURL; + } + }); + }, + + // -------------------------------------------------------------- + // Menu handling + // -------------------------------------------------------------- + on_app_clicked: function (ev) { + var self = this; + return this.menu_dp.add(data_manager.load_action(ev.data.action_id)) + .then(function (result) { + return self.action_mutex.exec(function () { + var completed = new Promise(function (resolve, reject) { + var options = _.extend({}, ev.data.options, { + clear_breadcrumbs: true, + action_menu_id: ev.data.menu_id, + }); + + Promise.resolve(self._openMenu(result, options)) + .then(function() { + self._on_app_clicked_done(ev) + .then(resolve) + .guardedCatch(reject); + }).guardedCatch(function() { + resolve(); + }); + setTimeout(function () { + resolve(); + }, 2000); + }); + return completed; + }); + }); + }, + _on_app_clicked_done: function (ev) { + core.bus.trigger('change_menu_section', ev.data.menu_id); + return Promise.resolve(); + }, + on_menu_clicked: function (ev) { + var self = this; + return this.menu_dp.add(data_manager.load_action(ev.data.action_id)) + .then(function (result) { + self.$el.removeClass('o_mobile_menu_opened'); + + return self.action_mutex.exec(function () { + var completed = new Promise(function (resolve, reject) { + Promise.resolve(self._openMenu(result, { + clear_breadcrumbs: true, + })).then(resolve).guardedCatch(reject); + + setTimeout(function () { + resolve(); + }, 2000); + }); + return completed; + }); + }).guardedCatch(function () { + self.$el.removeClass('o_mobile_menu_opened'); + }); + }, + /** + * Open the action linked to a menu. + * This function is mostly used to allow override in other modules. + * + * @private + * @param {Object} action + * @param {Object} options + * @returns {Promise} + */ + _openMenu: function (action, options) { + return this.do_action(action, options); + }, +}); + +}); diff --git a/addons/web/static/src/js/common_env.js b/addons/web/static/src/js/common_env.js new file mode 100644 index 00000000..792c3b96 --- /dev/null +++ b/addons/web/static/src/js/common_env.js @@ -0,0 +1,101 @@ +odoo.define("web.commonEnv", function (require) { + "use strict"; + + /** + * This file defines the common environment, which contains everything that + * is needed in the env for both the backend and the frontend (Odoo + * terminology). This module shouldn't be used as is. It should only be + * imported by the module defining the final env to use (in the frontend or + * in the backend). For instance, module 'web.env' imports it, adds stuff to + * it, and exports the final env that is used by the whole webclient + * application. + * + * There should be as much dependencies as possible in the env object. This + * will allow an easier testing of components. See [1] for more information + * on environments. + * + * [1] https://github.com/odoo/owl/blob/master/doc/reference/environment.md#content-of-an-environment + */ + + const { jsonRpc } = require("web.ajax"); + const { device, isDebug } = require("web.config"); + const { bus } = require("web.core"); + const rpc = require("web.rpc"); + const session = require("web.session"); + const { _t } = require("web.translation"); + const utils = require("web.utils"); + + const browser = { + clearInterval: window.clearInterval.bind(window), + clearTimeout: window.clearTimeout.bind(window), + Date: window.Date, + fetch: (window.fetch || (() => { })).bind(window), + Notification: window.Notification, + requestAnimationFrame: window.requestAnimationFrame.bind(window), + setInterval: window.setInterval.bind(window), + setTimeout: window.setTimeout.bind(window), + }; + Object.defineProperty(browser, 'innerHeight', { + get: () => window.innerHeight, + }); + Object.defineProperty(browser, 'innerWidth', { + get: () => window.innerWidth, + }); + + // Build the basic env + const env = { + _t, + browser, + bus, + device, + isDebug, + qweb: new owl.QWeb({ translateFn: _t }), + services: { + ajaxJsonRPC() { + return jsonRpc(...arguments); + }, + getCookie() { + return utils.get_cookie(...arguments); + }, + httpRequest(route, params = {}, readMethod = 'json') { + const info = { + method: params.method || 'POST', + }; + if (params.method !== 'GET') { + const formData = new FormData(); + for (const key in params) { + if (key === 'method') { + continue; + } + const value = params[key]; + if (Array.isArray(value) && value.length) { + for (const val of value) { + formData.append(key, val); + } + } else { + formData.append(key, value); + } + } + info.body = formData; + } + return fetch(route, info).then(response => response[readMethod]()); + }, + navigate(url, params) { + window.location = $.param.querystring(url, params); + }, + reloadPage() { + window.location.reload(); + }, + rpc(params, options) { + const query = rpc.buildQuery(params); + return session.rpc(query.route, query.params, options); + }, + setCookie() { + utils.set_cookie(...arguments); + }, + }, + session, + }; + + return env; +}); diff --git a/addons/web/static/src/js/component_extension.js b/addons/web/static/src/js/component_extension.js new file mode 100644 index 00000000..4edaee80 --- /dev/null +++ b/addons/web/static/src/js/component_extension.js @@ -0,0 +1,42 @@ +(function () { + /** + * Symbol used in ComponentWrapper to redirect Owl events to Odoo legacy + * events. + */ + odoo.widgetSymbol = Symbol('widget'); + + /** + * Add a new method to owl Components to ensure that no performed RPC is + * resolved/rejected when the component is destroyed. + */ + owl.Component.prototype.rpc = function () { + return new Promise((resolve, reject) => { + return this.env.services.rpc(...arguments) + .then(result => { + if (this.__owl__.status !== 5 /* not destroyed */) { + resolve(result); + } + }) + .catch(reason => { + if (this.__owl__.status !== 5) /* not destroyed */ { + reject(reason); + } + }); + }); + }; + + /** + * Patch owl.Component.__trigger method to call a hook that adds a listener + * for the triggered event just before triggering it. This is useful if + * there are legacy widgets in the ancestors. In that case, there would be + * a widgetSymbol key in the environment, corresponding to the hook to call + * (see ComponentWrapper). + */ + const originalTrigger = owl.Component.prototype.__trigger; + owl.Component.prototype.__trigger = function (component, evType, payload) { + if (this.env[odoo.widgetSymbol]) { + this.env[odoo.widgetSymbol](evType); + } + originalTrigger.call(this, component, evType, payload); + }; +})(); diff --git a/addons/web/static/src/js/components/action_menus.js b/addons/web/static/src/js/components/action_menus.js new file mode 100644 index 00000000..ed95e666 --- /dev/null +++ b/addons/web/static/src/js/components/action_menus.js @@ -0,0 +1,197 @@ +odoo.define('web.ActionMenus', function (require) { + "use strict"; + + const Context = require('web.Context'); + const DropdownMenu = require('web.DropdownMenu'); + const Registry = require('web.Registry'); + + const { Component } = owl; + + let registryActionId = 1; + + /** + * Action menus (or Action/Print bar, previously called 'Sidebar') + * + * The side bar is the group of dropdown menus located on the left side of the + * control panel. Its role is to display a list of items depending on the view + * type and selected records and to execute a set of actions on active records. + * It is made out of 2 dropdown menus: Print and Action. + * + * This component also provides a registry to use custom components in the ActionMenus's + * Action menu. + * @extends Component + */ + class ActionMenus extends Component { + + async willStart() { + this.actionItems = await this._setActionItems(this.props); + this.printItems = await this._setPrintItems(this.props); + } + + async willUpdateProps(nextProps) { + this.actionItems = await this._setActionItems(nextProps); + this.printItems = await this._setPrintItems(nextProps); + } + + mounted() { + this._addTooltips(); + } + + patched() { + this._addTooltips(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Add the tooltips to the items + * @private + */ + _addTooltips() { + $(this.el.querySelectorAll('[title]')).tooltip({ + delay: { show: 500, hide: 0 } + }); + } + + /** + * @private + * @param {Object} props + * @returns {Promise<Object[]>} + */ + async _setActionItems(props) { + // Callback based actions + const callbackActions = (props.items.other || []).map( + action => Object.assign({ key: `action-${action.description}` }, action) + ); + // Action based actions + const actionActions = props.items.action || []; + const relateActions = props.items.relate || []; + const formattedActions = [...actionActions, ...relateActions].map( + action => ({ action, description: action.name, key: action.id }) + ); + // ActionMenus action registry components + const registryActions = []; + const rpc = this.rpc.bind(this); + for (const { Component, getProps } of this.constructor.registry.values()) { + const itemProps = await getProps(props, this.env, rpc); + if (itemProps) { + registryActions.push({ + Component, + key: `registry-action-${registryActionId++}`, + props: itemProps, + }); + } + } + + return [...callbackActions, ...formattedActions, ...registryActions]; + } + + /** + * @private + * @param {Object} props + * @returns {Promise<Object[]>} + */ + async _setPrintItems(props) { + const printActions = props.items.print || []; + const printItems = printActions.map( + action => ({ action, description: action.name, key: action.id }) + ); + return printItems; + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * Perform the action for the item clicked after getting the data + * necessary with a trigger. + * @private + * @param {OwlEvent} ev + */ + async _executeAction(action) { + let activeIds = this.props.activeIds; + if (this.props.isDomainSelected) { + activeIds = await this.rpc({ + model: this.env.action.res_model, + method: 'search', + args: [this.props.domain], + kwargs: { + limit: this.env.session.active_ids_limit, + }, + }); + } + const activeIdsContext = { + active_id: activeIds[0], + active_ids: activeIds, + active_model: this.env.action.res_model, + }; + if (this.props.domain) { + // keep active_domain in context for backward compatibility + // reasons, and to allow actions to bypass the active_ids_limit + activeIdsContext.active_domain = this.props.domain; + } + + const context = new Context(this.props.context, activeIdsContext).eval(); + const result = await this.rpc({ + route: '/web/action/load', + params: { action_id: action.id, context }, + }); + result.context = new Context(result.context || {}, activeIdsContext) + .set_eval_context(context); + result.flags = result.flags || {}; + result.flags.new_window = true; + this.trigger('do-action', { + action: result, + options: { + on_close: () => this.trigger('reload'), + }, + }); + } + + /** + * Handler used to determine which way must be used to execute a selected + * action: it will be either: + * - a callback (function given by the view controller); + * - an action ID (string); + * - an URL (string). + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + const { item } = ev.detail; + if (item.callback) { + item.callback([item]); + } else if (item.action) { + this._executeAction(item.action); + } else if (item.url) { + // Event has been prevented at its source: we need to redirect manually. + this.env.services.navigate(item.url); + } + } + } + + ActionMenus.registry = new Registry(); + + ActionMenus.components = { DropdownMenu }; + ActionMenus.props = { + activeIds: { type: Array, element: [Number, String] }, // virtual IDs are strings. + context: Object, + domain: { type: Array, optional: 1 }, + isDomainSelected: { type: Boolean, optional: 1 }, + items: { + type: Object, + shape: { + action: { type: Array, optional: 1 }, + print: { type: Array, optional: 1 }, + other: { type: Array, optional: 1 }, + }, + }, + }; + ActionMenus.template = 'web.ActionMenus'; + + return ActionMenus; +}); diff --git a/addons/web/static/src/js/components/custom_checkbox.js b/addons/web/static/src/js/components/custom_checkbox.js new file mode 100644 index 00000000..bc98dd7b --- /dev/null +++ b/addons/web/static/src/js/components/custom_checkbox.js @@ -0,0 +1,58 @@ +odoo.define('web.CustomCheckbox', function (require) { + "use strict"; + + const utils = require('web.utils'); + + const { Component } = owl; + + /** + * Custom checkbox + * + * Component that can be used in templates to render the custom checkbox of Odoo. + * + * <CustomCheckbox + * value="boolean" + * disabled="boolean" + * text="'Change the label text'" + * t-on-change="_onValueChange" + * /> + * + * @extends Component + */ + class CustomCheckbox extends Component { + /** + * @param {Object} [props] + * @param {string | number | null} [props.id] + * @param {boolean} [props.value=false] + * @param {boolean} [props.disabled=false] + * @param {string} [props.text] + */ + constructor() { + super(...arguments); + this._id = `checkbox-comp-${utils.generateID()}`; + } + } + + CustomCheckbox.props = { + id: { + type: [String, Number], + optional: 1, + }, + disabled: { + type: Boolean, + optional: 1, + }, + value: { + type: Boolean, + optional: 1, + }, + text: { + type: String, + optional: 1, + }, + }; + + CustomCheckbox.template = 'web.CustomCheckbox'; + + return CustomCheckbox; +}); diff --git a/addons/web/static/src/js/components/custom_file_input.js b/addons/web/static/src/js/components/custom_file_input.js new file mode 100644 index 00000000..14f521a4 --- /dev/null +++ b/addons/web/static/src/js/components/custom_file_input.js @@ -0,0 +1,88 @@ +odoo.define('web.CustomFileInput', function (require) { + "use strict"; + + const { Component, hooks } = owl; + const { useRef } = hooks; + + /** + * Custom file input + * + * Component representing a customized input of type file. It takes a sub-template + * in its default t-slot and uses it as the trigger to open the file upload + * prompt. + * @extends Component + */ + class CustomFileInput extends Component { + /** + * @param {Object} [props] + * @param {string} [props.accepted_file_extensions='*'] Comma-separated + * list of authorized file extensions (default to all). + * @param {string} [props.action='/web/binary/upload'] Route called when + * a file is uploaded in the input. + * @param {string} [props.id] + * @param {string} [props.model] + * @param {string} [props.multi_upload=false] Whether the input should allow + * to upload multiple files at once. + */ + constructor() { + super(...arguments); + + this.fileInputRef = useRef('file-input'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Upload an attachment to the given action with the given parameters: + * - ufile: list of files contained in the file input + * - csrf_token: CSRF token provided by the odoo global object + * - model: a specific model which will be given when creating the attachment + * - id: the id of the model target instance + * @private + */ + async _onFileInputChange() { + const { action, model, id } = this.props; + const params = { + csrf_token: odoo.csrf_token, + ufile: [...this.fileInputRef.el.files], + }; + if (model) { + params.model = model; + } + if (id) { + params.id = id; + } + const fileData = await this.env.services.httpRequest(action, params, 'text'); + const parsedFileData = JSON.parse(fileData); + if (parsedFileData.error) { + throw new Error(parsedFileData.error); + } + this.trigger('uploaded', { files: parsedFileData }); + } + + /** + * Redirect clicks from the trigger element to the input. + * @private + */ + _onTriggerClicked() { + this.fileInputRef.el.click(); + } + } + CustomFileInput.defaultProps = { + accepted_file_extensions: '*', + action: '/web/binary/upload', + multi_upload: false, + }; + CustomFileInput.props = { + accepted_file_extensions: { type: String, optional: 1 }, + action: { type: String, optional: 1 }, + id: { type: Number, optional: 1 }, + model: { type: String, optional: 1 }, + multi_upload: { type: Boolean, optional: 1 }, + }; + CustomFileInput.template = 'web.CustomFileInput'; + + return CustomFileInput; +}); diff --git a/addons/web/static/src/js/components/datepicker.js b/addons/web/static/src/js/components/datepicker.js new file mode 100644 index 00000000..94ce7ece --- /dev/null +++ b/addons/web/static/src/js/components/datepicker.js @@ -0,0 +1,263 @@ +odoo.define('web.DatePickerOwl', function (require) { + "use strict"; + + const field_utils = require('web.field_utils'); + const time = require('web.time'); + const { useAutofocus } = require('web.custom_hooks'); + + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + let datePickerId = 0; + + /** + * Date picker + * + * This component exposes the API of the tempusdominus datepicker library. + * As such, its template is a simple input that will open the TD datepicker + * when clicked on. The component will also synchronize any user-input value + * with the library widget and vice-vera. + * + * For further details regarding the implementation of the picker itself, please + * refer to the official tempusdominus documentation (note: all props given + * to this component will be passed as arguments to instantiate the picker widget). + * @extends Component + */ + class DatePicker extends Component { + constructor() { + super(...arguments); + + this.inputRef = useRef('input'); + this.state = useState({ warning: false }); + + this.datePickerId = `o_datepicker_${datePickerId++}`; + this.typeOfDate = 'date'; + + useAutofocus(); + useExternalListener(window, 'scroll', this._onWindowScroll); + } + + mounted() { + $(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this)); + $(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this)); + $(this.el).on('error.datetimepicker', () => false); + + const pickerOptions = Object.assign({ format: this.defaultFormat }, this.props); + this._datetimepicker(pickerOptions); + this.inputRef.el.value = this._formatDate(this.props.date); + } + + willUnmount() { + this._datetimepicker('destroy'); + } + + willUpdateProps(nextProps) { + for (const prop in nextProps) { + this._datetimepicker(prop, nextProps[prop]); + } + if (nextProps.date) { + this.inputRef.el.value = this._formatDate(nextProps.date); + } + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @returns {string} + */ + get defaultFormat() { + return time.getLangDateFormat(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Handle bootstrap datetimepicker calls. + * @private + * @param {...any} args anything that will be passed to the datetimepicker function. + */ + _datetimepicker(...args) { + $(this.el).datetimepicker(...args); + } + + /** + * @private + * @param {moment} date + * @returns {string} + */ + _formatDate(date) { + try { + return field_utils.format[this.typeOfDate](date, null, { timezone: false }); + } catch (err) { + return false; + } + } + + /** + * @private + * @param {string|false} value + * @returns {moment} + */ + _parseInput(inputValue) { + try { + return field_utils.parse[this.typeOfDate](inputValue, null, { timezone: false }); + } catch (err) { + return false; + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * Reacts to the datetimepicker being hidden + * Used to unbind the scroll event from the datetimepicker + * @private + */ + _onDateTimePickerHide() { + const date = this._parseInput(this.inputRef.el.value); + this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD'); + this.trigger('datetime-changed', { date }); + } + + /** + * Reacts to the datetimepicker being shown + * Could set/verify our widget value + * And subsequently update the datetimepicker + * @private + */ + _onDateTimePickerShow() { + this.inputRef.el.select(); + } + + /** + * @private + */ + _onInputClick() { + this._datetimepicker('toggle'); + } + + /** + * @private + */ + _onInputChange() { + const date = this._parseInput(this.inputRef.el.value); + if (date) { + this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD'); + this.trigger('datetime-changed', { date }); + } else { + this.inputRef.el.value = this._formatDate(this.props.date); + } + } + + /** + * @private + */ + _onWindowScroll(ev) { + if (ev.target !== this.inputRef.el) { + this._datetimepicker('hide'); + } + } + } + + DatePicker.defaultProps = { + calendarWeeks: true, + icons: { + clear: 'fa fa-delete', + close: 'fa fa-check primary', + date: 'fa fa-calendar', + down: 'fa fa-chevron-down', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + time: 'fa fa-clock-o', + today: 'fa fa-calendar-check-o', + up: 'fa fa-chevron-up', + }, + get locale() {return moment.locale();}, + maxDate: moment({ y: 9999, M: 11, d: 31 }), + minDate: moment({ y: 1000 }), + useCurrent: false, + widgetParent: 'body', + }; + DatePicker.props = { + // Actual date value + date: moment, + // Other props + buttons: { + type: Object, + shape: { + showClear: Boolean, + showClose: Boolean, + showToday: Boolean, + }, + optional: 1, + }, + calendarWeeks: Boolean, + format: { type: String, optional: 1 }, + icons: { + type: Object, + shape: { + clear: String, + close: String, + date: String, + down: String, + next: String, + previous: String, + time: String, + today: String, + up: String, + }, + }, + keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 }, + locale: String, + maxDate: moment, + minDate: moment, + readonly: { type: Boolean, optional: 1 }, + useCurrent: Boolean, + widgetParent: String, + }; + DatePicker.template = 'web.DatePicker'; + + /** + * Date/time picker + * + * Similar to the DatePicker component, adding the handling of more specific + * time values: hour-minute-second. + * + * Once again, refer to the tempusdominus documentation for implementation + * details. + * @extends DatePicker + */ + class DateTimePicker extends DatePicker { + constructor() { + super(...arguments); + + this.typeOfDate = 'datetime'; + } + + /** + * @override + */ + get defaultFormat() { + return time.getLangDatetimeFormat(); + } + } + + DateTimePicker.defaultProps = Object.assign(Object.create(DatePicker.defaultProps), { + buttons: { + showClear: false, + showClose: true, + showToday: false, + }, + }); + + return { + DatePicker, + DateTimePicker, + }; +}); diff --git a/addons/web/static/src/js/components/dropdown_menu.js b/addons/web/static/src/js/components/dropdown_menu.js new file mode 100644 index 00000000..890d928b --- /dev/null +++ b/addons/web/static/src/js/components/dropdown_menu.js @@ -0,0 +1,174 @@ +odoo.define('web.DropdownMenu', function (require) { + "use strict"; + + const DropdownMenuItem = require('web.DropdownMenuItem'); + + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + /** + * Dropdown menu + * + * Generic component used to generate a list of interactive items. It uses some + * bootstrap classes but most interactions are handled in here or in the dropdown + * menu item class definition, including some keyboard navigation and escaping + * system (click outside to close the dropdown). + * + * The layout of a dropdown menu is as following: + * > a Button (always rendered) with a `title` and an optional `icon`; + * > a Dropdown (rendered when open) containing a collection of given items. + * These items must be objects and can have two shapes: + * 1. item.Component & item.props > will instantiate the given Component with + * the given props. Any additional key will be useless. + * 2. any other shape > will instantiate a DropdownMenuItem with the item + * object being its props. There is no props validation as this object + * will be passed as-is when `selected` and can contain additional meta-keys + * that will not affect the displayed item. For more information regarding + * the behaviour of these items, @see DropdownMenuItem. + * @extends Component + */ + class DropdownMenu extends Component { + constructor() { + super(...arguments); + + this.dropdownMenu = useRef('dropdown'); + this.state = useState({ open: false }); + + useExternalListener(window, 'click', this._onWindowClick, true); + useExternalListener(window, 'keydown', this._onWindowKeydown); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * In desktop, by default, we do not display a caret icon next to the + * dropdown. + * @returns {boolean} + */ + get displayCaret() { + return false; + } + + /** + * In mobile, by default, we display a chevron icon next to the dropdown + * button. Note that when 'displayCaret' is true, we display a caret + * instead of a chevron, no matter the value of 'displayChevron'. + * @returns {boolean} + */ + get displayChevron() { + return this.env.device.isMobile; + } + + /** + * Can be overriden to force an icon on an inheriting class. + * @returns {string} Font Awesome icon class + */ + get icon() { + return this.props.icon; + } + + /** + * Meant to be overriden to provide the list of items to display. + * @returns {Object[]} + */ + get items() { + return this.props.items; + } + + /** + * @returns {string} + */ + get title() { + return this.props.title; + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onButtonKeydown(ev) { + switch (ev.key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowDown': + const firstItem = this.el.querySelector('.dropdown-item'); + if (firstItem) { + ev.preventDefault(); + firstItem.focus(); + } + } + } + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(/* ev */) { + if (this.props.closeOnSelected) { + this.state.open = false; + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onWindowClick(ev) { + if ( + this.state.open && + !this.el.contains(ev.target) && + !this.el.contains(document.activeElement) + ) { + if (document.body.classList.contains("modal-open")) { + // retrieve the active modal and check if the dropdown is a child of this modal + const modal = document.querySelector('body > .modal:not(.o_inactive_modal)'); + if (modal && !modal.contains(this.el)) { + return; + } + const owlModal = document.querySelector('body > .o_dialog > .modal:not(.o_inactive_modal)'); + if (owlModal && !owlModal.contains(this.el)) { + return; + } + } + // check for an active open bootstrap calendar like the filter dropdown inside the search panel) + if (document.querySelector('body > .bootstrap-datetimepicker-widget')) { + return; + } + this.state.open = false; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onWindowKeydown(ev) { + if (this.state.open && ev.key === 'Escape') { + this.state.open = false; + } + } + } + + DropdownMenu.components = { DropdownMenuItem }; + DropdownMenu.defaultProps = { items: [] }; + DropdownMenu.props = { + icon: { type: String, optional: 1 }, + items: { + type: Array, + element: Object, + optional: 1, + }, + title: { type: String, optional: 1 }, + closeOnSelected: { type: Boolean, optional: 1 }, + }; + DropdownMenu.template = 'web.DropdownMenu'; + + return DropdownMenu; +}); diff --git a/addons/web/static/src/js/components/dropdown_menu_item.js b/addons/web/static/src/js/components/dropdown_menu_item.js new file mode 100644 index 00000000..bcc54ab4 --- /dev/null +++ b/addons/web/static/src/js/components/dropdown_menu_item.js @@ -0,0 +1,102 @@ +odoo.define('web.DropdownMenuItem', function (require) { + "use strict"; + + const { useListener } = require('web.custom_hooks'); + + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + /** + * Dropdown menu item + * + * Generic component instantiated by a dropdown menu (@see DropdownMenu) in + * the absence of `Component` and `props` keys in a given item object. + * + * In its simplest form, a dropdown menu item will be given a description (optional, + * but highly recommended) and will trigger a 'select-item' when clicked on. + * Additionaly it can receive the following props: + * - isActive: will add a `checked` symbol on the left side of the item + * - removable: will add a `remove` trash icon on the right side of the item. + * when clicked, will trigger a 'remove-item' event. + * - options: will change the behaviour of the item ; instead of triggering + * an event, the item will act as a nested dropdown menu and display + * its given options. These will have the same definition as another + * dropdown item but cannot have options of their own. + * + * It is recommended to extend this class when defining a Component which will + * be put inside of a dropdown menu (@see CustomFilterItem as example). + * @extends Component + */ + class DropdownMenuItem extends Component { + constructor() { + super(...arguments); + + this.canBeOpened = Boolean(this.props.options && this.props.options.length); + + this.fallbackFocusRef = useRef('fallback-focus'); + this.state = useState({ open: false }); + + useExternalListener(window, 'click', this._onWindowClick); + useListener('keydown', this._onKeydown); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) { + return; + } + switch (ev.key) { + case 'ArrowLeft': + if (this.canBeOpened && this.state.open) { + ev.preventDefault(); + if (this.fallbackFocusRef.el) { + this.fallbackFocusRef.el.focus(); + } + this.state.open = false; + } + break; + case 'ArrowRight': + if (this.canBeOpened && !this.state.open) { + ev.preventDefault(); + this.state.open = true; + } + break; + case 'Escape': + ev.target.blur(); + if (this.canBeOpened && this.state.open) { + ev.preventDefault(); + ev.stopPropagation(); + if (this.fallbackFocusRef.el) { + this.fallbackFocusRef.el.focus(); + } + this.state.open = false; + } + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onWindowClick(ev) { + if ( + this.state.open && + !this.el.contains(ev.target) && + !this.el.contains(document.activeElement) + ) { + this.state.open = false; + } + } + } + + DropdownMenuItem.template = 'web.DropdownMenuItem'; + + return DropdownMenuItem; +}); diff --git a/addons/web/static/src/js/components/pager.js b/addons/web/static/src/js/components/pager.js new file mode 100644 index 00000000..2d8d402a --- /dev/null +++ b/addons/web/static/src/js/components/pager.js @@ -0,0 +1,225 @@ +odoo.define('web.Pager', function (require) { + "use strict"; + + const { useAutofocus } = require('web.custom_hooks'); + + const { Component, hooks } = owl; + const { useState } = hooks; + + /** + * Pager + * + * The pager goes from 1 to size (included). + * The current value is currentMinimum if limit === 1 or the interval: + * [currentMinimum, currentMinimum + limit[ if limit > 1]. + * The value can be manually changed by clicking on the pager value and giving + * an input matching the pattern: min[,max] (in which the comma can be a dash + * or a semicolon). + * The pager also provides two buttons to quickly change the current page (next + * or previous). + * @extends Component + */ + class Pager extends Component { + /** + * @param {Object} [props] + * @param {int} [props.size] the total number of elements + * @param {int} [props.currentMinimum] the first element of the current_page + * @param {int} [props.limit] the number of elements per page + * @param {boolean} [props.editable] editable feature of the pager + * @param {function} [props.validate] callback returning a Promise to + * validate changes + * @param {boolean} [props.withAccessKey] can be disabled, for example, + * for x2m widgets + */ + constructor() { + super(...arguments); + + this.state = useState({ + disabled: false, + editing: false, + }); + + useAutofocus(); + } + + async willUpdateProps() { + this.state.editing = false; + this.state.disabled = false; + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @returns {number} + */ + get maximum() { + return Math.min(this.props.currentMinimum + this.props.limit - 1, this.props.size); + } + + /** + * @returns {boolean} true iff there is only one page + */ + get singlePage() { + return (1 === this.props.currentMinimum) && (this.maximum === this.props.size); + } + + /** + * @returns {number} + */ + get value() { + return this.props.currentMinimum + (this.props.limit > 1 ? `-${this.maximum}` : ''); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Update the pager's state according to a pager action + * @private + * @param {number} [direction] the action (previous or next) on the pager + */ + async _changeSelection(direction) { + try { + await this.props.validate(); + } catch (err) { + return; + } + const { limit, size } = this.props; + + // Compute the new currentMinimum + let currentMinimum = (this.props.currentMinimum + limit * direction); + if (currentMinimum > size) { + currentMinimum = 1; + } else if ((currentMinimum < 1) && (limit === 1)) { + currentMinimum = size; + } else if ((currentMinimum < 1) && (limit > 1)) { + currentMinimum = size - ((size % limit) || limit) + 1; + } + + // The re-rendering of the pager must be done before the trigger of + // event 'pager-changed' as the rendering may enable the pager + // (and a common use is to disable the pager when this event is + // triggered, and to re-enable it when the data have been reloaded). + this._updateAndDisable(currentMinimum, limit); + } + + /** + * Save the state from the content of the input + * @private + * @param {string} value the new raw pager value + * @returns {Promise} + */ + async _saveValue(value) { + try { + await this.props.validate(); + } catch (err) { + return; + } + const [min, max] = value.trim().split(/\s*[\-\s,;]\s*/); + + let currentMinimum = Math.max(Math.min(parseInt(min, 10), this.props.size), 1); + let maximum = max ? Math.max(Math.min(parseInt(max, 10), this.props.size), 1) : min; + + if ( + !isNaN(currentMinimum) && + !isNaN(maximum) && + currentMinimum <= maximum + ) { + const limit = Math.max(maximum - currentMinimum) + 1; + this._updateAndDisable(currentMinimum, limit); + } + } + + /** + * Commits the current input value. There are two scenarios: + * - the value is the same: the pager toggles back to readonly + * - the value changed: the pager is disabled to prevent furtherchanges + * Either way the "pager-changed" event is triggered to reload the + * view. + * @private + * @param {number} currentMinimum + * @param {number} limit + */ + _updateAndDisable(currentMinimum, limit) { + if ( + currentMinimum !== this.props.currentMinimum || + limit !== this.props.limit + ) { + this.state.disabled = true; + } else { + // In this case we want to trigger an update, but since it will + // not re-render the pager (current props === next props) we + // have to disable the edition manually here. + this.state.editing = false; + } + this.trigger('pager-changed', { currentMinimum, limit }); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + */ + _onEdit() { + if ( + this.props.editable && // editable + !this.state.editing && // not already editing + !this.state.disabled // not being changed already + ) { + this.state.editing = true; + } + } + + /** + * @private + * @param {InputEvent} ev + */ + _onValueChange(ev) { + this._saveValue(ev.currentTarget.value); + if (!this.state.disabled) { + ev.preventDefault(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onValueKeydown(ev) { + switch (ev.key) { + case 'Enter': + ev.preventDefault(); + ev.stopPropagation(); + this._saveValue(ev.currentTarget.value); + break; + case 'Escape': + ev.preventDefault(); + ev.stopPropagation(); + this.state.editing = false; + break; + } + } + } + + Pager.defaultProps = { + editable: true, + validate: async () => { }, + withAccessKey: true, + }; + Pager.props = { + currentMinimum: { type: Number, optional: 1 }, + editable: Boolean, + limit: { validate: l => !isNaN(l), optional: 1 }, + size: { type: Number, optional: 1 }, + validate: Function, + withAccessKey: Boolean, + }; + Pager.template = 'web.Pager'; + + return Pager; +}); diff --git a/addons/web/static/src/js/control_panel/comparison_menu.js b/addons/web/static/src/js/control_panel/comparison_menu.js new file mode 100644 index 00000000..dee1c982 --- /dev/null +++ b/addons/web/static/src/js/control_panel/comparison_menu.js @@ -0,0 +1,63 @@ +odoo.define("web.ComparisonMenu", function (require) { + "use strict"; + + const DropdownMenu = require("web.DropdownMenu"); + const { FACET_ICONS } = require("web.searchUtils"); + const { useModel } = require("web/static/src/js/model.js"); + + /** + * "Comparison" menu + * + * Displays a set of comparison options related to the currently selected + * date filters. + * @extends DropdownMenu + */ + class ComparisonMenu extends DropdownMenu { + constructor() { + super(...arguments); + this.model = useModel('searchModel'); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get icon() { + return FACET_ICONS.comparison; + } + + /** + * @override + */ + get items() { + return this.model.get('filters', f => f.type === 'comparison'); + } + + /** + * @override + */ + get title() { + return this.env._t("Comparison"); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + const { item } = ev.detail; + this.model.dispatch("toggleComparison", item.id); + } + + } + + return ComparisonMenu; +}); diff --git a/addons/web/static/src/js/control_panel/control_panel.js b/addons/web/static/src/js/control_panel/control_panel.js new file mode 100644 index 00000000..3841541e --- /dev/null +++ b/addons/web/static/src/js/control_panel/control_panel.js @@ -0,0 +1,223 @@ +odoo.define('web.ControlPanel', function (require) { + "use strict"; + + const ActionMenus = require('web.ActionMenus'); + const ComparisonMenu = require('web.ComparisonMenu'); + const ActionModel = require('web/static/src/js/views/action_model.js'); + const FavoriteMenu = require('web.FavoriteMenu'); + const FilterMenu = require('web.FilterMenu'); + const GroupByMenu = require('web.GroupByMenu'); + const patchMixin = require('web.patchMixin'); + const Pager = require('web.Pager'); + const SearchBar = require('web.SearchBar'); + const { useModel } = require('web/static/src/js/model.js'); + + const { Component, hooks } = owl; + const { useRef, useSubEnv } = hooks; + + /** + * TODO: remove this whole mechanism as soon as `cp_content` is completely removed. + * Extract the 'cp_content' key of the given props and return them as well as + * the extracted content. + * @param {Object} props + * @returns {Object} + */ + function getAdditionalContent(props) { + const additionalContent = {}; + if ('cp_content' in props) { + const content = props.cp_content || {}; + if ('$buttons' in content) { + additionalContent.buttons = content.$buttons; + } + if ('$searchview' in content) { + additionalContent.searchView = content.$searchview; + } + if ('$pager' in content) { + additionalContent.pager = content.$pager; + } + if ('$searchview_buttons' in content) { + additionalContent.searchViewButtons = content.$searchview_buttons; + } + } + return additionalContent; + } + + /** + * Control panel + * + * The control panel of the action|view. In its standard form, it is composed of + * several sections/subcomponents. Here is a simplified graph representing the + * action|view and its control panel: + * + * ┌ View Controller | Action ----------------------------------------------------------┐ + * | ┌ Control Panel ──────────────┬──────────────────────────────────────────────────┐ | + * | │ ┌ Breadcrumbs ────────────┐ │ ┌ Search View ─────────────────────────────────┐ │ | + * | │ │ [1] / [2] │ │ │ [3] [ ================ 4 ================= ] │ │ | + * | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ | + * | ├─────────────────────────────┼──────────────────────────────────────────────────┤ | + * | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ | + * | │ │ [5] │ │ [6] │ │ │ [7] [8] [9] [10] │ │ [11] ││ [12] │ │ | + * | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ | + * | └─────────────────────────────┴──────────────────────────────────────────────────┘ | + * | ┌ View Renderer | Action content ────────────────────────────────────────────────┐ | + * | │ │ | + * | │ ... │ | + * | │ │ | + * | │ │ | + * | │ │ | + * | └────────────────────────────────────────────────────────────────────────────────┘ | + * └------------------------------------------------------------------------------------┘ + * + * 1. Breadcrumbs: list of links composed by the `props.breadcrumbs` collection. + * 2. Title: the title of the action|view. Can be empty and will yield 'Unnamed'. + * 3. Search facets: a collection of facet components generated by the `ControlPanelModel` + * and handled by the `SearchBar` component. @see SearchFacet + * 4. SearchBar: @see SearchBar + * 5. Buttons: section in which the action|controller is meant to inject its control + * buttons. The template provides a slot for this purpose. + * 6. Action menus: @see ActionMenus + * 7. Filter menu: @see FilterMenu + * 8. Group by menu: @see GroupByMenu + * 9. Comparison menu: @see ComparisonMenu + * 10. Favorite menu: @see FavoriteMenu + * 11. Pager: @see Pager + * 12. View switcher buttons: list of buttons composed by the `props.views` collection. + * + * Subcomponents (especially in the [Search Menus] section) will call + * the ControlPanelModel to get processed information about the current view|action. + * @see ControlPanelModel for more details. + * + * Note: an additional temporary (and ugly) mechanic allows to inject a jQuery element + * given in `props.cp_content` in a related section: + * $buttons -> [Buttons] + * $searchview -> [Search View] + * $searchview_buttons -> [Search Menus] + * $pager -> [Pager] + * This system must be replaced by proper slot usage and the static template + * inheritance mechanism when converting the views/actions. + * @extends Component + */ + class ControlPanel extends Component { + constructor() { + super(...arguments); + + this.additionalContent = getAdditionalContent(this.props); + + useSubEnv({ + action: this.props.action, + searchModel: this.props.searchModel, + view: this.props.view, + }); + + // Connect to the model + // TODO: move this in enterprise whenever possible + if (this.env.searchModel) { + this.model = useModel('searchModel'); + } + + // Reference hooks + this.contentRefs = { + buttons: useRef('buttons'), + pager: useRef('pager'), + searchView: useRef('searchView'), + searchViewButtons: useRef('searchViewButtons'), + }; + + this.fields = this._formatFields(this.props.fields); + + this.sprintf = _.str.sprintf; + } + + mounted() { + this._attachAdditionalContent(); + } + + patched() { + this._attachAdditionalContent(); + } + + async willUpdateProps(nextProps) { + // Note: action and searchModel are not likely to change during + // the lifespan of a ControlPanel instance, so we only need to update + // the view information. + if ('view' in nextProps) { + this.env.view = nextProps.view; + } + if ('fields' in nextProps) { + this.fields = this._formatFields(nextProps.fields); + } + this.additionalContent = getAdditionalContent(nextProps); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Attach additional content extracted from the props 'cp_content' key, if any. + * @private + */ + _attachAdditionalContent() { + for (const key in this.additionalContent) { + if (this.additionalContent[key] && this.additionalContent[key].length) { + const target = this.contentRefs[key].el; + if (target) { + target.innerHTML = ""; + target.append(...this.additionalContent[key]); + } + } + } + } + + /** + * Give `name` and `description` keys to the fields given to the control + * panel. + * @private + * @param {Object} fields + * @returns {Object} + */ + _formatFields(fields) { + const formattedFields = {}; + for (const fieldName in fields) { + formattedFields[fieldName] = Object.assign({ + description: fields[fieldName].string, + name: fieldName, + }, fields[fieldName]); + } + return formattedFields; + } + } + ControlPanel.modelExtension = "ControlPanel"; + + ControlPanel.components = { + SearchBar, + ActionMenus, Pager, + ComparisonMenu, FilterMenu, GroupByMenu, FavoriteMenu, + }; + ControlPanel.defaultProps = { + breadcrumbs: [], + fields: {}, + searchMenuTypes: [], + views: [], + withBreadcrumbs: true, + withSearchBar: true, + }; + ControlPanel.props = { + action: Object, + breadcrumbs: Array, + searchModel: ActionModel, + cp_content: { type: Object, optional: 1 }, + fields: Object, + pager: { validate: p => typeof p === 'object' || p === null, optional: 1 }, + searchMenuTypes: Array, + actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 }, + title: { type: String, optional: 1 }, + view: { type: Object, optional: 1 }, + views: Array, + withBreadcrumbs: Boolean, + withSearchBar: Boolean, + }; + ControlPanel.template = 'web.ControlPanel'; + + return patchMixin(ControlPanel); +}); diff --git a/addons/web/static/src/js/control_panel/control_panel_model_extension.js b/addons/web/static/src/js/control_panel/control_panel_model_extension.js new file mode 100644 index 00000000..57242caf --- /dev/null +++ b/addons/web/static/src/js/control_panel/control_panel_model_extension.js @@ -0,0 +1,1658 @@ +odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", function (require) { + "use strict"; + + const ActionModel = require("web/static/src/js/views/action_model.js"); + const Domain = require('web.Domain'); + const pyUtils = require('web.py_utils'); + + const { DEFAULT_INTERVAL, DEFAULT_PERIOD, + getComparisonOptions, getIntervalOptions, getPeriodOptions, + constructDateDomain, rankInterval, yearSelected } = require('web.searchUtils'); + + const FAVORITE_PRIVATE_GROUP = 1; + const FAVORITE_SHARED_GROUP = 2; + const DISABLE_FAVORITE = "search_disable_custom_filters"; + + let filterId = 1; + let groupId = 1; + let groupNumber = 0; + + /** + * Control panel model + * + * The control panel model state is an object structured in the following way: + * + * { + * filters: Object{}, + * query: Object[], + * } + * + *------------------------------------------------------------------------- + * Filters + *------------------------------------------------------------------------- + * + * The keys are stringified numbers called 'filter ids'. + * The values are objects called 'filters'. + * + * Each filter has the following properties: + * @prop {number} id unique identifier, also the filter's corresponding key + * @prop {number} groupId the id of some group, actually the group itself, + * the (active) 'groups' are reconstructed in _getGroups. + * @prop {string} description the description of the filter + * @prop {string} type 'filter'|'groupBy'|'comparison'|'field'|'favorite' + * + * Other properties can be present according to the corresponding filter type: + * + * • type 'comparison': + * @prop {string} comparisonOptionId option identifier (@see COMPARISON_OPTIONS). + * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' + * with isDateFilter=true) + * + * • type 'filter': + * @prop {number} groupNumber used to separate items in the 'Filters' menu + * @prop {string} [context] context + * @prop {boolean} [invisible] determine if the filter is accessible in the interface + * @prop {boolean} [isDefault] + * @if isDefault = true: + * > @prop {number} [defaultRank=-5] used to determine the order of + * > activation of default filters + * @prop {boolean} [isDateFilter] true if the filter comes from an arch node + * with a valid 'date' attribute. + * @if isDateFilter = true + * > @prop {boolean} [hasOptions=true] + * > @prop {string} defaultOptionId option identifier determined by + * > default_period attribute (@see PERIOD_OPTIONS). + * > Default set to DEFAULT_PERIOD. + * > @prop {string} fieldName determined by the value of 'date' attribute + * > @prop {string} fieldType 'date' or 'datetime', type of the corresponding field + * @else + * > @prop {string} domain + * + * • type 'groupBy': + * @prop {string} fieldName + * @prop {string} fieldType + * @prop {number} groupNumber used to separate items in the 'Group by' menu + * @prop {boolean} [isDefault] + * @if isDefault = true: + * > @prop {number} defaultRank used to determine the order of activation + * > of default filters + * @prop {boolean} [invisible] determine if the filter is accessible in the interface + * @prop {boolean} [hasOptions] true if field type is 'date' or 'datetime' + * @if hasOptions=true + * > @prop {string} defaultOptionId option identifier (see INTERVAL_OPTIONS) + * default set to DEFAULT_INTERVAL. + * + * • type 'field': + * @prop {string} fieldName + * @prop {string} fieldType + * @prop {string} [context] + * @prop {string} [domain] + * @prop {string} [filterDomain] + * @prop {boolean} [invisible] determine if the filter is accessible in the interface + * @prop {boolean} [isDefault] + * @prop {string} [operator] + * @if isDefault = true: + * > @prop {number} [defaultRank=-10] used to determine the order of + * > activation of filters + * > @prop {Object} defaultAutocompleteValue of the form { value, label, operator } + * + * • type: 'favorite': + * @prop {Object} [comparison] of the form {comparisonId, fieldName, fieldDescription, + * range, rangeDescription, comparisonRange, comparisonRangeDescription, } + * @prop {Object} context + * @prop {string} domain + * @prop {string[]} groupBys + * @prop {number} groupNumber 1 | 2, 2 if the favorite is shared + * @prop {string[]} orderedBy + * @prop {boolean} [removable=true] indicates that the favorite can be deleted + * @prop {number} serverSideId + * @prop {number} userId + * @prop {boolean} [isDefault] + * + *------------------------------------------------------------------------- + * Query + *------------------------------------------------------------------------- + * + * The query elements are objects called 'query elements'. + * + * Each query element has the following properties: + * @prop {number} filterId the id of some filter + * @prop {number} groupId the id of some group (actually the group itself) + * + * Other properties must be defined according to the corresponding filter type. + * + * • type 'comparison': + * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' + * with hasOptions=true) + * @prop {string} type 'comparison', help when searching if a comparison is active + * + * • type 'filter' with hasOptions=true: + * @prop {string} optionId option identifier (@see PERIOD_OPTIONS) + * + * • type 'groupBy' with hasOptions=true: + * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS) + * + * • type 'field': + * @prop {string} label description put in the facet (can be temporarilly missing) + * @prop {(string|number)} value used as the value of the generated domain + * @prop {string} operator used as the operator of the generated domain + * + * The query elements indicates what are the active filters and 'how' they are active. + * The key groupId has been added for simplicity. It could have been removed from query elements + * since the information is available on the corresponding filters. + * @extends ActionModel.Extension + */ + class ControlPanelModelExtension extends ActionModel.Extension { + /** + * @param {Object} config + * @param {(string|number)} config.actionId + * @param {Object} config.env + * @param {string} config.modelName + * @param {Object} [config.context={}] + * @param {Object[]} [config.archNodes=[]] + * @param {Object[]} [config.dynamicFilters=[]] + * @param {string[]} [config.searchMenuTypes=[]] + * @param {Object} [config.favoriteFilters={}] + * @param {Object} [config.fields={}] + * @param {boolean} [config.withSearchBar=true] + */ + constructor() { + super(...arguments); + + this.actionContext = Object.assign({}, this.config.context); + this.searchMenuTypes = this.config.searchMenuTypes || []; + this.favoriteFilters = this.config.favoriteFilters || []; + this.fields = this.config.fields || {}; + this.searchDefaults = {}; + for (const key in this.actionContext) { + const match = /^search_default_(.*)$/.exec(key); + if (match) { + const val = this.actionContext[key]; + if (val) { + this.searchDefaults[match[1]] = val; + } + delete this.actionContext[key]; + } + } + this.labelPromises = []; + + this.referenceMoment = moment(); + this.optionGenerators = getPeriodOptions(this.referenceMoment); + this.intervalOptions = getIntervalOptions(); + this.comparisonOptions = getComparisonOptions(); + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + * @returns {any} + */ + get(property, ...args) { + switch (property) { + case "context": return this.getContext(); + case "domain": return this.getDomain(); + case "facets": return this._getFacets(); + case "filters": return this._getFilters(...args); + case "groupBy": return this.getGroupBy(); + case "orderedBy": return this.getOrderedBy(); + case "timeRanges": return this.getTimeRanges(); + } + } + + /** + * @override + */ + async load() { + await Promise.all(this.labelPromises); + } + + /** + * @override + */ + prepareState() { + Object.assign(this.state, { + filters: {}, + query: [], + }); + if (this.config.withSearchBar !== false) { + this._addFilters(); + this._activateDefaultFilters(); + } + } + + //--------------------------------------------------------------------- + // Actions / Getters + //--------------------------------------------------------------------- + + /** + * @returns {Object | undefined} + */ + get activeComparison() { + return this.state.query.find(queryElem => queryElem.type === 'comparison'); + } + + /** + * Activate a filter of type 'field' with given filterId with + * 'autocompleteValues' value, label, and operator. + * @param {Object} + */ + addAutoCompletionValues({ filterId, label, value, operator }) { + const queryElem = this.state.query.find(queryElem => + queryElem.filterId === filterId && + queryElem.value === value && + queryElem.operator === operator + ); + if (!queryElem) { + const { groupId } = this.state.filters[filterId]; + this.state.query.push({ filterId, groupId, label, value, operator }); + } else { + queryElem.label = label; + } + } + + /** + * Remove all the query elements from query. + */ + clearQuery() { + this.state.query = []; + } + + /** + * Create a new filter of type 'favorite' and activate it. + * A new group containing only that filter is created. + * The query is emptied before activating the new favorite. + * @param {Object} preFilter + * @returns {Promise} + */ + async createNewFavorite(preFilter) { + const preFavorite = await this._saveQuery(preFilter); + this.clearQuery(); + const filter = Object.assign(preFavorite, { + groupId, + id: filterId, + }); + this.state.filters[filterId] = filter; + this.state.query.push({ groupId, filterId }); + groupId++; + filterId++; + } + + /** + * Create new filters of type 'filter' and activate them. + * A new group containing only those filters is created. + * @param {Object[]} filters + * @returns {number[]} + */ + createNewFilters(prefilters) { + if (!prefilters.length) { + return []; + } + const newFilterIdS = []; + prefilters.forEach(preFilter => { + const filter = Object.assign(preFilter, { + groupId, + groupNumber, + id: filterId, + type: 'filter', + }); + this.state.filters[filterId] = filter; + this.state.query.push({ groupId, filterId }); + newFilterIdS.push(filterId); + filterId++; + }); + groupId++; + groupNumber++; + return newFilterIdS; + } + + /** + * Create a new filter of type 'groupBy' and activate it. + * It is added to the unique group of groupbys. + * @param {Object} field + */ + createNewGroupBy(field) { + const groupBy = Object.values(this.state.filters).find(f => f.type === 'groupBy'); + const filter = { + description: field.string || field.name, + fieldName: field.name, + fieldType: field.type, + groupId: groupBy ? groupBy.groupId : groupId++, + groupNumber, + id: filterId, + type: 'groupBy', + }; + this.state.filters[filterId] = filter; + if (['date', 'datetime'].includes(field.type)) { + filter.hasOptions = true; + filter.defaultOptionId = DEFAULT_INTERVAL; + this.toggleFilterWithOptions(filterId); + } else { + this.toggleFilter(filterId); + } + groupNumber++; + filterId++; + } + + /** + * Deactivate a group with provided groupId, i.e. delete the query elements + * with given groupId. + * @param {number} groupId + */ + deactivateGroup(groupId) { + this.state.query = this.state.query.filter( + queryElem => queryElem.groupId !== groupId + ); + this._checkComparisonStatus(); + } + + /** + * Delete a filter of type 'favorite' with given filterId server side and + * in control panel model. Of course the filter is also removed + * from the search query. + * @param {number} filterId + */ + async deleteFavorite(filterId) { + const { serverSideId } = this.state.filters[filterId]; + await this.env.dataManager.delete_filter(serverSideId); + const index = this.state.query.findIndex( + queryElem => queryElem.filterId === filterId + ); + delete this.state.filters[filterId]; + if (index >= 0) { + this.state.query.splice(index, 1); + } + } + + /** + * @returns {Object} + */ + getContext() { + const groups = this._getGroups(); + return this._getContext(groups); + } + + /** + * @returns {Array[]} + */ + getDomain() { + const groups = this._getGroups(); + const userContext = this.env.session.user_context; + try { + return Domain.prototype.stringToArray(this._getDomain(groups), userContext); + } catch (err) { + throw new Error( + `${this.env._t("Control panel model extension failed to evaluate domain")}:/n${JSON.stringify(err)}` + ); + } + } + + /** + * @returns {string[]} + */ + getGroupBy() { + const groups = this._getGroups(); + return this._getGroupBy(groups); + } + + /** + * @returns {string[]} + */ + getOrderedBy() { + const groups = this._getGroups(); + return this._getOrderedBy(groups); + } + + /** + * @returns {Object} + */ + getTimeRanges() { + const requireEvaluation = true; + return this._getTimeRanges(requireEvaluation); + } + + /** + * Used to call dispatch and trigger a 'search'. + */ + search() { + /* ... */ + } + + /** + * Activate/Deactivate a filter of type 'comparison' with provided id. + * At most one filter of type 'comparison' can be activated at every time. + * @param {string} filterId + */ + toggleComparison(filterId) { + const { groupId, dateFilterId } = this.state.filters[filterId]; + const queryElem = this.state.query.find(queryElem => + queryElem.type === 'comparison' && + queryElem.filterId === filterId + ); + // make sure only one comparison can be active + this.state.query = this.state.query.filter(queryElem => queryElem.type !== 'comparison'); + if (!queryElem) { + this.state.query.push({ groupId, filterId, dateFilterId, type: 'comparison', }); + } + } + + /** + * Activate or deactivate the simple filter with given filterId, i.e. + * add or remove a corresponding query element. + * @param {string} filterId + */ + toggleFilter(filterId) { + const index = this.state.query.findIndex( + queryElem => queryElem.filterId === filterId + ); + if (index >= 0) { + this.state.query.splice(index, 1); + } else { + const { groupId, type } = this.state.filters[filterId]; + if (type === 'favorite') { + this.state.query = []; + } + this.state.query.push({ groupId, filterId }); + } + } + + /** + * Used to toggle a query element { filterId, optionId, (groupId) }. + * This can impact the query in various form, e.g. add/remove other query elements + * in case the filter is of type 'filter'. + * @param {string} filterId + * @param {string} [optionId] + */ + toggleFilterWithOptions(filterId, optionId) { + const filter = this.state.filters[filterId]; + optionId = optionId || filter.defaultOptionId; + const option = this.optionGenerators.find(o => o.id === optionId); + + const index = this.state.query.findIndex( + queryElem => queryElem.filterId === filterId && queryElem.optionId === optionId + ); + + if (index >= 0) { + this.state.query.splice(index, 1); + if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { + // This is the case where optionId was the last option + // of type 'year' to be there before being removed above. + // Since other options of type 'month' or 'quarter' do + // not make sense without a year we deactivate all options. + this.state.query = this.state.query.filter( + queryElem => queryElem.filterId !== filterId + ); + } + } else { + this.state.query.push({ groupId: filter.groupId, filterId, optionId }); + if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { + // Here we add 'this_year' as options if no option of type + // year is already selected. + this.state.query.push({ + groupId: filter.groupId, + filterId, + optionId: option.defaultYearId, + }); + } + } + if (filter.type === 'filter') { + this._checkComparisonStatus(); + } + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Activate the default favorite (if any) or all default filters. + * @private + */ + _activateDefaultFilters() { + if (this.defaultFavoriteId) { + // Activate default favorite + this.toggleFilter(this.defaultFavoriteId); + } else { + // Activate default filters + Object.values(this.state.filters) + .filter((f) => f.isDefault && f.type !== 'favorite') + .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100)) + .forEach(f => { + if (f.hasOptions) { + this.toggleFilterWithOptions(f.id); + } else if (f.type === 'field') { + let { operator, label, value } = f.defaultAutocompleteValue; + this.addAutoCompletionValues({ + filterId: f.id, + value, + operator, + label, + }); + } else { + this.toggleFilter(f.id); + } + }); + } + } + + /** + * This function populates the 'filters' object at initialization. + * The filters come from: + * - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'), + * - config.dynamicFilters (type 'filter'), + * - config.favoriteFilters (type 'favorite'), + * - code itself (type 'timeRange') + * @private + */ + _addFilters() { + this._createGroupOfFavorites(); + this._createGroupOfFiltersFromArch(); + this._createGroupOfDynamicFilters(); + } + + /** + * If a comparison is active, check if it should become inactive. + * The comparison should become inactive if the corresponding date filter has become + * inactive. + * @private + */ + _checkComparisonStatus() { + const activeComparison = this.activeComparison; + if (!activeComparison) { + return; + } + const { dateFilterId } = activeComparison; + const dateFilterIsActive = this.state.query.some( + queryElem => queryElem.filterId === dateFilterId + ); + if (!dateFilterIsActive) { + this.state.query = this.state.query.filter( + queryElem => queryElem.type !== 'comparison' + ); + } + } + + /** + * Returns the active comparison timeRanges object. + * @private + * @param {Object} comparisonFilter + * @returns {Object | null} + */ + _computeTimeRanges(comparisonFilter) { + const { filterId } = this.activeComparison; + if (filterId !== comparisonFilter.id) { + return null; + } + const { dateFilterId, comparisonOptionId } = comparisonFilter; + const { + fieldName, + fieldType, + description: dateFilterDescription, + } = this.state.filters[dateFilterId]; + + const selectedOptionIds = this._getSelectedOptionIds(dateFilterId); + + // compute range and range description + const { domain: range, description: rangeDescription } = constructDateDomain( + this.referenceMoment, fieldName, fieldType, selectedOptionIds, + ); + + // compute comparisonRange and comparisonRange description + const { + domain: comparisonRange, + description: comparisonRangeDescription, + } = constructDateDomain( + this.referenceMoment, fieldName, fieldType, selectedOptionIds, comparisonOptionId + ); + + return { + comparisonId: comparisonOptionId, + fieldName, + fieldDescription: dateFilterDescription, + range, + rangeDescription, + comparisonRange, + comparisonRangeDescription, + }; + } + + /** + * Starting from the array of date filters, create the filters of type + * 'comparison'. + * @private + * @param {Object[]} dateFilters + */ + _createGroupOfComparisons(dateFilters) { + const preFilters = []; + for (const dateFilter of dateFilters) { + for (const comparisonOption of this.comparisonOptions) { + const { id: dateFilterId, description } = dateFilter; + const preFilter = { + type: 'comparison', + comparisonOptionId: comparisonOption.id, + description: `${description}: ${comparisonOption.description}`, + dateFilterId, + }; + preFilters.push(preFilter); + } + } + this._createGroupOfFilters(preFilters); + } + + /** + * Add filters of type 'filter' determined by the key array dynamicFilters. + * @private + */ + _createGroupOfDynamicFilters() { + const dynamicFilters = this.config.dynamicFilters || []; + const pregroup = dynamicFilters.map(filter => { + return { + description: filter.description, + domain: JSON.stringify(filter.domain), + isDefault: true, + type: 'filter', + }; + }); + this._createGroupOfFilters(pregroup); + } + + /** + * Add filters of type 'favorite' determined by the array this.favoriteFilters. + * @private + */ + _createGroupOfFavorites() { + const activateFavorite = DISABLE_FAVORITE in this.actionContext ? + !this.actionContext[DISABLE_FAVORITE] : + true; + this.favoriteFilters.forEach(irFilter => { + const favorite = this._irFilterToFavorite(irFilter); + this._createGroupOfFilters([favorite]); + if (activateFavorite && favorite.isDefault) { + this.defaultFavoriteId = favorite.id; + } + }); + } + + /** + * Using a list (a 'pregroup') of 'prefilters', create new filters in `state.filters` + * for each prefilter. The new filters belong to a same new group. + * @private + * @param {Object[]} pregroup, list of 'prefilters' + * @param {string} type + */ + _createGroupOfFilters(pregroup) { + pregroup.forEach(preFilter => { + const filter = Object.assign(preFilter, { groupId, id: filterId }); + this.state.filters[filterId] = filter; + if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') { + this._prepareDefaultLabel(filter); + } + filterId++; + }); + groupId++; + } + + /** + * Parse the arch of a 'search' view and create corresponding filters and groups. + * + * A searchview arch may contain a 'searchpanel' node, but this isn't + * the concern of the ControlPanel (the SearchPanel will handle it). + * Ideally, this code should whitelist the tags to take into account + * instead of blacklisting the others, but with the current (messy) + * structure of a searchview arch, it's way simpler to do it that way. + * @private + */ + _createGroupOfFiltersFromArch() { + const preFilters = this.config.archNodes.reduce( + (preFilters, child) => { + if (child.tag === 'group') { + return [...preFilters, ...child.children.map(c => this._evalArchChild(c))]; + } else { + return [...preFilters, this._evalArchChild(child)]; + } + }, + [] + ); + preFilters.push({ tag: 'separator' }); + + // create groups and filters + let currentTag; + let currentGroup = []; + let pregroupOfGroupBys = []; + + preFilters.forEach(preFilter => { + if ( + preFilter.tag !== currentTag || + ['separator', 'field'].includes(preFilter.tag) + ) { + if (currentGroup.length) { + if (currentTag === 'groupBy') { + pregroupOfGroupBys = [...pregroupOfGroupBys, ...currentGroup]; + } else { + this._createGroupOfFilters(currentGroup); + } + } + currentTag = preFilter.tag; + currentGroup = []; + groupNumber++; + } + if (preFilter.tag !== 'separator') { + const filter = { + type: preFilter.tag, + // we need to codify here what we want to keep from attrs + // and how, for now I put everything. + // In some sence, some filter are active (totally determined, given) + // and others are passive (require input(s) to become determined) + // What is the right place to process the attrs? + }; + if (preFilter.attrs && JSON.parse(preFilter.attrs.modifiers || '{}').invisible) { + filter.invisible = true; + let preFilterFieldName = null; + if (preFilter.tag === 'filter' && preFilter.attrs.date) { + preFilterFieldName = preFilter.attrs.date; + } else if (preFilter.tag === 'groupBy') { + preFilterFieldName = preFilter.attrs.fieldName; + } + if (preFilterFieldName && !this.fields[preFilterFieldName]) { + // In some case when a field is limited to specific groups + // on the model, we need to ensure to discard related filter + // as it may still be present in the view (in 'invisible' state) + return; + } + } + if (filter.type === 'filter' || filter.type === 'groupBy') { + filter.groupNumber = groupNumber; + } + this._extractAttributes(filter, preFilter.attrs); + currentGroup.push(filter); + } + }); + + if (pregroupOfGroupBys.length) { + this._createGroupOfFilters(pregroupOfGroupBys); + } + const dateFilters = Object.values(this.state.filters).filter( + (filter) => filter.isDateFilter + ); + if (dateFilters.length) { + this._createGroupOfComparisons(dateFilters); + } + } + + /** + * Returns null or a copy of the provided filter with additional information + * used only outside of the control panel model, like in search bar or in the + * various menus. The value null is returned if the filter should not appear + * for some reason. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {Object | null} + */ + _enrichFilterCopy(filter, filterQueryElements) { + const isActive = Boolean(filterQueryElements.length); + const f = Object.assign({ isActive }, filter); + + function _enrichOptions(options) { + return options.map(o => { + const { description, id, groupNumber } = o; + const isActive = filterQueryElements.some(a => a.optionId === id); + return { description, id, groupNumber, isActive }; + }); + } + + switch (f.type) { + case 'comparison': { + const { dateFilterId } = filter; + const dateFilterIsActive = this.state.query.some( + queryElem => queryElem.filterId === dateFilterId + ); + if (!dateFilterIsActive) { + return null; + } + break; + } + case 'filter': + if (f.hasOptions) { + f.options = _enrichOptions(this.optionGenerators); + } + break; + case 'groupBy': + if (f.hasOptions) { + f.options = _enrichOptions(this.intervalOptions); + } + break; + case 'field': + f.autoCompleteValues = filterQueryElements.map( + ({ label, value, operator }) => ({ label, value, operator }) + ); + break; + } + return f; + } + + /** + * Process a given arch node and enrich it. + * @private + * @param {Object} child + * @returns {Object} + */ + _evalArchChild(child) { + if (child.attrs.context) { + try { + const context = pyUtils.eval('context', child.attrs.context); + child.attrs.context = context; + if (context.group_by) { + // let us extract basic data since we just evaluated context + // and use a correct tag! + child.attrs.fieldName = context.group_by.split(':')[0]; + child.attrs.defaultInterval = context.group_by.split(':')[1]; + child.tag = 'groupBy'; + } + } catch (e) { } + } + if (child.attrs.name in this.searchDefaults) { + child.attrs.isDefault = true; + let value = this.searchDefaults[child.attrs.name]; + if (child.tag === 'field') { + child.attrs.defaultValue = this.fields[child.attrs.name].type === 'many2one' && Array.isArray(value) ? value[0] : value; + } else if (child.tag === 'groupBy') { + child.attrs.defaultRank = typeof value === 'number' ? value : 100; + } + } + return child; + } + + /** + * Process the attributes set on an arch node and adds various keys to + * the given filter. + * @private + * @param {Object} filter + * @param {Object} attrs + */ + _extractAttributes(filter, attrs) { + if (attrs.isDefault) { + filter.isDefault = attrs.isDefault; + } + filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω'; + switch (filter.type) { + case 'filter': + if (attrs.context) { + filter.context = attrs.context; + } + if (attrs.date) { + filter.isDateFilter = true; + filter.hasOptions = true; + filter.fieldName = attrs.date; + filter.fieldType = this.fields[attrs.date].type; + filter.defaultOptionId = attrs.default_period || DEFAULT_PERIOD; + } else { + filter.domain = attrs.domain || '[]'; + } + if (filter.isDefault) { + filter.defaultRank = -5; + } + break; + case 'groupBy': + filter.fieldName = attrs.fieldName; + filter.fieldType = this.fields[attrs.fieldName].type; + if (['date', 'datetime'].includes(filter.fieldType)) { + filter.hasOptions = true; + filter.defaultOptionId = attrs.defaultInterval || DEFAULT_INTERVAL; + } + if (filter.isDefault) { + filter.defaultRank = attrs.defaultRank; + } + break; + case 'field': { + const field = this.fields[attrs.name]; + filter.fieldName = attrs.name; + filter.fieldType = field.type; + if (attrs.domain) { + filter.domain = attrs.domain; + } + if (attrs.filter_domain) { + filter.filterDomain = attrs.filter_domain; + } else if (attrs.operator) { + filter.operator = attrs.operator; + } + if (attrs.context) { + filter.context = attrs.context; + } + if (filter.isDefault) { + let operator = filter.operator; + if (!operator) { + const type = attrs.widget || filter.fieldType; + // Note: many2one as a default filter will have a + // numeric value instead of a string => we want "=" + // instead of "ilike". + if (["char", "html", "many2many", "one2many", "text"].includes(type)) { + operator = "ilike"; + } else { + operator = "="; + } + } + filter.defaultRank = -10; + filter.defaultAutocompleteValue = { + operator, + value: attrs.defaultValue, + }; + } + break; + } + } + if (filter.fieldName && !attrs.string) { + const { string } = this.fields[filter.fieldName]; + filter.description = string; + } + } + + /** + * Returns an object irFilter serving to create an ir_filte in db + * starting from a filter of type 'favorite'. + * @private + * @param {Object} favorite + * @returns {Object} + */ + _favoriteToIrFilter(favorite) { + const irFilter = { + action_id: this.config.actionId, + model_id: this.config.modelName, + }; + + // ir.filter fields + if ('description' in favorite) { + irFilter.name = favorite.description; + } + if ('domain' in favorite) { + irFilter.domain = favorite.domain; + } + if ('isDefault' in favorite) { + irFilter.is_default = favorite.isDefault; + } + if ('orderedBy' in favorite) { + const sort = favorite.orderedBy.map( + ob => ob.name + (ob.asc === false ? " desc" : "") + ); + irFilter.sort = JSON.stringify(sort); + } + if ('serverSideId' in favorite) { + irFilter.id = favorite.serverSideId; + } + if ('userId' in favorite) { + irFilter.user_id = favorite.userId; + } + + // Context + const context = Object.assign({}, favorite.context); + if ('groupBys' in favorite) { + context.group_by = favorite.groupBys; + } + if ('comparison' in favorite) { + context.comparison = favorite.comparison; + } + if (Object.keys(context).length) { + irFilter.context = context; + } + + return irFilter; + } + + /** + * Return the domain resulting from the combination of the auto-completion + * values of a filter of type 'field'. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {string} + */ + _getAutoCompletionFilterDomain(filter, filterQueryElements) { + const domains = filterQueryElements.map(({ label, value, operator }) => { + let domain; + if (filter.filterDomain) { + domain = Domain.prototype.stringToArray( + filter.filterDomain, + { + self: label, + raw_value: value, + } + ); + } else { + // Create new domain + domain = [[filter.fieldName, operator, value]]; + } + return Domain.prototype.arrayToString(domain); + }); + return pyUtils.assembleDomains(domains, 'OR'); + } + + /** + * Construct a single context from the contexts of + * filters of type 'filter', 'favorite', and 'field'. + * @private + * @returns {Object} + */ + _getContext(groups) { + const types = ['filter', 'favorite', 'field']; + const contexts = groups.reduce( + (contexts, group) => { + if (types.includes(group.type)) { + contexts.push(...this._getGroupContexts(group)); + } + return contexts; + }, + [] + ); + const evaluationContext = this.env.session.user_context; + try { + return pyUtils.eval('contexts', contexts, evaluationContext); + } catch (err) { + throw new Error( + this.env._t("Failed to evaluate search context") + ":\n" + + JSON.stringify(err) + ); + } + } + + /** + * Compute the string representation or the description of the current domain associated + * with a date filter starting from its corresponding query elements. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @param {'domain'|'description'} [key='domain'] + * @returns {string} + */ + _getDateFilterDomain(filter, filterQueryElements, key = 'domain') { + const { fieldName, fieldType } = filter; + const selectedOptionIds = filterQueryElements.map(queryElem => queryElem.optionId); + const dateFilterRange = constructDateDomain( + this.referenceMoment, fieldName, fieldType, selectedOptionIds, + ); + return dateFilterRange[key]; + } + + /** + * Return the string or array representation of a domain created by combining + * appropriately (with an 'AND') the domains coming from the active groups + * of type 'filter', 'favorite', and 'field'. + * @private + * @param {Object[]} groups + * @returns {string} + */ + _getDomain(groups) { + const types = ['filter', 'favorite', 'field']; + const domains = []; + for (const group of groups) { + if (types.includes(group.type)) { + domains.push(this._getGroupDomain(group)); + } + } + return pyUtils.assembleDomains(domains, 'AND'); + } + + /** + * Get the filter description to use in the search bar as a facet. + * @private + * @param {Object} activity + * @param {Object} activity.filter + * @param {Object[]} activity.filterQueryElements + * @returns {string} + */ + _getFacetDescriptions(activities, type) { + const facetDescriptions = []; + if (type === 'field') { + for (const queryElem of activities[0].filterQueryElements) { + facetDescriptions.push(queryElem.label); + } + } else if (type === 'groupBy') { + for (const { filter, filterQueryElements } of activities) { + if (filter.hasOptions) { + for (const queryElem of filterQueryElements) { + const option = this.intervalOptions.find( + o => o.id === queryElem.optionId + ); + facetDescriptions.push(filter.description + ': ' + option.description); + } + } else { + facetDescriptions.push(filter.description); + } + } + } else { + let facetDescription; + for (const { filter, filterQueryElements } of activities) { + // filter, favorite and comparison + facetDescription = filter.description; + if (filter.isDateFilter) { + const description = this._getDateFilterDomain( + filter, filterQueryElements, 'description' + ); + facetDescription += `: ${description}`; + } + facetDescriptions.push(facetDescription); + } + } + return facetDescriptions; + } + + /** + * @returns {Object[]} + */ + _getFacets() { + const facets = this._getGroups().map(({ activities, type, id }) => { + const values = this._getFacetDescriptions(activities, type); + const title = activities[0].filter.description; + return { groupId: id, title, type, values }; + }); + return facets; + } + + /** + * Return an array containing enriched copies of the filters of the provided type. + * @param {Function} predicate + * @returns {Object[]} + */ + _getFilters(predicate) { + const filters = []; + Object.values(this.state.filters).forEach(filter => { + if ((!predicate || predicate(filter)) && !filter.invisible) { + const filterQueryElements = this.state.query.filter( + queryElem => queryElem.filterId === filter.id + ); + const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements); + if (enrichedFilter) { + filters.push(enrichedFilter); + } + } + }); + if (filters.some(f => f.type === 'favorite')) { + filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber); + } + return filters; + } + + /** + * Return the context of the provided (active) filter. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {Object} + */ + _getFilterContext(filter, filterQueryElements) { + let context = filter.context || {}; + // for <field> nodes, a dynamic context (like context="{'field1': self}") + // should set {'field1': [value1, value2]} in the context + if (filter.type === 'field' && filter.context) { + context = pyUtils.eval('context', + filter.context, + { self: filterQueryElements.map(({ value }) => value) }, + ); + } + // the following code aims to remodel this: + // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L498 + // this is required for the helpdesk tour to pass + // this seems weird to only do that for m2o fields, but a test fails if + // we do it for other fields (my guess being that the test should simply + // be adapted) + if (filter.type === 'field' && filter.isDefault && filter.fieldType === 'many2one') { + context[`default_${filter.fieldName}`] = filter.defaultAutocompleteValue.value; + } + return context; + } + + /** + * Return the domain of the provided filter. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {string} domain, string representation of a domain + */ + _getFilterDomain(filter, filterQueryElements) { + if (filter.type === 'filter' && filter.hasOptions) { + const { dateFilterId } = this.activeComparison || {}; + if (this.searchMenuTypes.includes('comparison') && dateFilterId === filter.id) { + return "[]"; + } + return this._getDateFilterDomain(filter, filterQueryElements); + } else if (filter.type === 'field') { + return this._getAutoCompletionFilterDomain(filter, filterQueryElements); + } + return filter.domain; + } + + /** + * Return the groupBys of the provided filter. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {string[]} groupBys + */ + _getFilterGroupBys(filter, filterQueryElements) { + if (filter.type === 'groupBy') { + const fieldName = filter.fieldName; + if (filter.hasOptions) { + return filterQueryElements.map( + ({ optionId }) => `${fieldName}:${optionId}` + ); + } else { + return [fieldName]; + } + } else { + return filter.groupBys; + } + } + + /** + * Return the concatenation of groupBys comming from the active filters of + * type 'favorite' and 'groupBy'. + * The result respects the appropriate logic: the groupBys + * coming from an active favorite (if any) come first, then come the + * groupBys comming from the active filters of type 'groupBy' in the order + * defined in this.state.query. If no groupBys are found, one tries to + * find some grouBys in the action context. + * @private + * @param {Object[]} groups + * @returns {string[]} + */ + _getGroupBy(groups) { + const groupBys = groups.reduce( + (groupBys, group) => { + if (['groupBy', 'favorite'].includes(group.type)) { + groupBys.push(...this._getGroupGroupBys(group)); + } + return groupBys; + }, + [] + ); + const groupBy = groupBys.length ? groupBys : (this.actionContext.group_by || []); + return typeof groupBy === 'string' ? [groupBy] : groupBy; + } + + /** + * Return the list of the contexts of the filters active in the given + * group. + * @private + * @param {Object} group + * @returns {Object[]} + */ + _getGroupContexts(group) { + const contexts = group.activities.reduce( + (ctx, qe) => [...ctx, this._getFilterContext(qe.filter, qe.filterQueryElements)], + [] + ); + return contexts; + } + + /** + * Return the string representation of a domain created by combining + * appropriately (with an 'OR') the domains coming from the filters + * active in the given group. + * @private + * @param {Object} group + * @returns {string} string representation of a domain + */ + _getGroupDomain(group) { + const domains = group.activities.map(({ filter, filterQueryElements }) => { + return this._getFilterDomain(filter, filterQueryElements); + }); + return pyUtils.assembleDomains(domains, 'OR'); + } + + /** + * Return the groupBys coming form the filters active in the given group. + * @private + * @param {Object} group + * @returns {string[]} + */ + _getGroupGroupBys(group) { + const groupBys = group.activities.reduce( + (gb, qe) => [...gb, ...this._getFilterGroupBys(qe.filter, qe.filterQueryElements)], + [] + ); + return groupBys; + } + + /** + * Reconstruct the (active) groups from the query elements. + * @private + * @returns {Object[]} + */ + _getGroups() { + const groups = this.state.query.reduce( + (groups, queryElem) => { + const { groupId, filterId } = queryElem; + let group = groups.find(group => group.id === groupId); + const filter = this.state.filters[filterId]; + if (!group) { + const { type } = filter; + group = { + id: groupId, + type, + activities: [] + }; + groups.push(group); + } + group.activities.push(queryElem); + return groups; + }, + [] + ); + groups.forEach(g => this._mergeActivities(g)); + return groups; + } + + /** + * Used to get the key orderedBy of the active favorite. + * @private + * @param {Object[]} groups + * @returns {string[]} orderedBy + */ + _getOrderedBy(groups) { + return groups.reduce( + (orderedBy, group) => { + if (group.type === 'favorite') { + const favoriteOrderedBy = group.activities[0].filter.orderedBy; + if (favoriteOrderedBy) { + // Group order is reversed but inner order is kept + orderedBy = [...favoriteOrderedBy, ...orderedBy]; + } + } + return orderedBy; + }, + [] + ); + } + + /** + * Starting from the id of a date filter, returns the array of option ids currently selected + * for the corresponding filter. + * @private + * @param {string} dateFilterId + * @returns {string[]} + */ + _getSelectedOptionIds(dateFilterId) { + const selectedOptionIds = []; + for (const queryElem of this.state.query) { + if (queryElem.filterId === dateFilterId) { + selectedOptionIds.push(queryElem.optionId); + } + } + return selectedOptionIds; + } + + /** + * Returns the last timeRanges object found in the query. + * TimeRanges objects can be associated with filters of type 'favorite' + * or 'comparison'. + * @private + * @param {boolean} [evaluation=false] + * @returns {Object | null} + */ + _getTimeRanges(evaluation) { + let timeRanges; + for (const queryElem of this.state.query.slice().reverse()) { + const filter = this.state.filters[queryElem.filterId]; + if (filter.type === 'comparison') { + timeRanges = this._computeTimeRanges(filter); + break; + } else if (filter.type === 'favorite' && filter.comparison) { + timeRanges = filter.comparison; + break; + } + } + if (timeRanges) { + if (evaluation) { + timeRanges.range = Domain.prototype.stringToArray(timeRanges.range); + timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange); + } + return timeRanges; + } + return null; + } + + /** + * Returns a filter of type 'favorite' starting from an ir_filter comming from db. + * @private + * @param {Object} irFilter + * @returns {Object} + */ + _irFilterToFavorite(irFilter) { + let userId = irFilter.user_id || false; + if (Array.isArray(userId)) { + userId = userId[0]; + } + const groupNumber = userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP; + const context = pyUtils.eval('context', irFilter.context, this.env.session.user_context); + let groupBys = []; + if (context.group_by) { + groupBys = context.group_by; + delete context.group_by; + } + let comparison; + if (context.comparison) { + comparison = context.comparison; + delete context.comparison; + } + let sort; + try { + sort = JSON.parse(irFilter.sort); + } catch (err) { + if (err instanceof SyntaxError) { + sort = []; + } else { + throw err; + } + } + const orderedBy = sort.map(order => { + let fieldName; + let asc; + const sqlNotation = order.split(' '); + if (sqlNotation.length > 1) { + // regex: \fieldName (asc|desc)?\ + fieldName = sqlNotation[0]; + asc = sqlNotation[1] === 'asc'; + } else { + // legacy notation -- regex: \-?fieldName\ + fieldName = order[0] === '-' ? order.slice(1) : order; + asc = order[0] === '-' ? false : true; + } + return { + asc: asc, + name: fieldName, + }; + }); + const favorite = { + context, + description: irFilter.name, + domain: irFilter.domain, + groupBys, + groupNumber, + orderedBy, + removable: true, + serverSideId: irFilter.id, + type: 'favorite', + userId, + }; + if (irFilter.is_default) { + favorite.isDefault = irFilter.is_default; + } + if (comparison) { + favorite.comparison = comparison; + } + return favorite; + } + + /** + * Group the query elements in group.activities by qe -> qe.filterId + * and changes the form of group.activities to make it more suitable for further + * computations. + * @private + * @param {Object} group + */ + _mergeActivities(group) { + const { activities, type } = group; + let res = []; + switch (type) { + case 'filter': + case 'groupBy': { + for (const activity of activities) { + const { filterId } = activity; + let a = res.find(({ filter }) => filter.id === filterId); + if (!a) { + a = { + filter: this.state.filters[filterId], + filterQueryElements: [] + }; + res.push(a); + } + a.filterQueryElements.push(activity); + } + break; + } + case 'favorite': + case 'field': + case 'comparison': { + // all activities in the group have same filterId + const { filterId } = group.activities[0]; + const filter = this.state.filters[filterId]; + res.push({ + filter, + filterQueryElements: group.activities + }); + break; + } + } + if (type === 'groupBy') { + res.forEach(activity => { + activity.filterQueryElements.sort( + (qe1, qe2) => rankInterval(qe1.optionId) - rankInterval(qe2.optionId) + ); + }); + } + group.activities = res; + } + + /** + * Set the key label in defaultAutocompleteValue used by default filters of + * type 'field'. + * @private + * @param {Object} filter + */ + _prepareDefaultLabel(filter) { + const { id, fieldType, fieldName, defaultAutocompleteValue } = filter; + const { selection, context, relation } = this.fields[fieldName]; + if (fieldType === 'selection') { + defaultAutocompleteValue.label = selection.find( + sel => sel[0] === defaultAutocompleteValue.value + )[1]; + } else if (fieldType === 'many2one') { + const updateLabel = label => { + const queryElem = this.state.query.find(({ filterId }) => filterId === id); + if (queryElem) { + queryElem.label = label; + defaultAutocompleteValue.label = label; + } + }; + const promise = this.env.services.rpc({ + args: [defaultAutocompleteValue.value], + context: context, + method: 'name_get', + model: relation, + }) + .then(results => updateLabel(results[0][1])) + .guardedCatch(() => updateLabel(defaultAutocompleteValue.value)); + this.labelPromises.push(promise); + } else { + defaultAutocompleteValue.label = defaultAutocompleteValue.value; + } + } + + /** + * Compute the search Query and save it as an ir_filter in db. + * No evaluation of domains is done in order to keep them dynamic. + * If the operation is successful, a new filter of type 'favorite' is + * created and activated. + * @private + * @param {Object} preFilter + * @returns {Promise<Object>} + */ + async _saveQuery(preFilter) { + const groups = this._getGroups(); + + const userContext = this.env.session.user_context; + let controllerQueryParams; + this.config.trigger("get-controller-query-params", params => { + controllerQueryParams = params; + }); + controllerQueryParams = controllerQueryParams || {}; + controllerQueryParams.context = controllerQueryParams.context || {}; + + const queryContext = this._getContext(groups); + const context = pyUtils.eval( + 'contexts', + [userContext, controllerQueryParams.context, queryContext] + ); + for (const key in userContext) { + delete context[key]; + } + + const requireEvaluation = false; + const domain = this._getDomain(groups); + const groupBys = this._getGroupBy(groups); + const timeRanges = this._getTimeRanges(requireEvaluation); + const orderedBy = controllerQueryParams.orderedBy ? + controllerQueryParams.orderedBy : + (this._getOrderedBy(groups) || []); + + const userId = preFilter.isShared ? false : this.env.session.uid; + delete preFilter.isShared; + + Object.assign(preFilter, { + context, + domain, + groupBys, + groupNumber: userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP, + orderedBy, + removable: true, + userId, + }); + if (timeRanges) { + preFilter.comparison = timeRanges; + } + const irFilter = this._favoriteToIrFilter(preFilter); + const serverSideId = await this.env.dataManager.create_filter(irFilter); + + preFilter.serverSideId = serverSideId; + + return preFilter; + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @override + * @returns {{ attrs: Object, children: Object[] }} + */ + static extractArchInfo(archs) { + const { attrs, children } = archs.search; + const controlPanelInfo = { + attrs, + children: [], + }; + for (const child of children) { + if (child.tag !== "searchpanel") { + controlPanelInfo.children.push(child); + } + } + return controlPanelInfo; + } + } + + ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10); + + return ControlPanelModelExtension; +}); diff --git a/addons/web/static/src/js/control_panel/control_panel_x2many.js b/addons/web/static/src/js/control_panel/control_panel_x2many.js new file mode 100644 index 00000000..22b4ae56 --- /dev/null +++ b/addons/web/static/src/js/control_panel/control_panel_x2many.js @@ -0,0 +1,40 @@ +odoo.define('web.ControlPanelX2Many', function (require) { + + const ControlPanel = require('web.ControlPanel'); + + /** + * Control panel (adaptation for x2many fields) + * + * Smaller version of the control panel with an abridged template (buttons and + * pager only). We still extend the main version for the injection of `cp_content` + * keys. + * The pager of this control panel is only displayed if the amount of records + * cannot be displayed in a single page. + * @extends ControlPanel + */ + class ControlPanelX2Many extends ControlPanel { + + /** + * @private + * @returns {boolean} + */ + _shouldShowPager() { + if (!this.props.pager || !this.props.pager.limit) { + return false; + } + const { currentMinimum, limit, size } = this.props.pager; + const maximum = Math.min(currentMinimum + limit - 1, size); + const singlePage = (1 === currentMinimum) && (maximum === size); + return !singlePage; + } + } + + ControlPanelX2Many.defaultProps = {}; + ControlPanelX2Many.props = { + cp_content: { type: Object, optional: 1 }, + pager: Object, + }; + ControlPanelX2Many.template = 'web.ControlPanelX2Many'; + + return ControlPanelX2Many; +}); diff --git a/addons/web/static/src/js/control_panel/custom_favorite_item.js b/addons/web/static/src/js/control_panel/custom_favorite_item.js new file mode 100644 index 00000000..b7adb031 --- /dev/null +++ b/addons/web/static/src/js/control_panel/custom_favorite_item.js @@ -0,0 +1,152 @@ +odoo.define('web.CustomFavoriteItem', function (require) { + "use strict"; + + const DropdownMenuItem = require('web.DropdownMenuItem'); + const FavoriteMenu = require('web.FavoriteMenu'); + const { useAutofocus } = require('web.custom_hooks'); + const { useModel } = require('web/static/src/js/model.js'); + + const { useRef } = owl.hooks; + + let favoriteId = 0; + + /** + * Favorite generator menu + * + * This component is used to add a new favorite linked which will take every + * information out of the current context and save it to a new `ir.filter`. + * + * There are 3 additional inputs to modify the filter: + * - a text input (mandatory): the name of the favorite (must be unique) + * - 'use by default' checkbox: if checked, the favorite will be the default + * filter of the current model (and will bypass + * any existing default filter). Cannot be checked + * along with 'share with all users' checkbox. + * - 'share with all users' checkbox: if checked, the favorite will be available + * with all users instead of the current + * one.Cannot be checked along with 'use + * by default' checkbox. + * Finally, there is a 'Save' button used to apply the current configuration + * and save the context to a new filter. + * @extends DropdownMenuItem + */ + class CustomFavoriteItem extends DropdownMenuItem { + constructor() { + super(...arguments); + + const favId = favoriteId++; + this.useByDefaultId = `o_favorite_use_by_default_${favId}`; + this.shareAllUsersId = `o_favorite_share_all_users_${favId}`; + + this.descriptionRef = useRef('description'); + this.model = useModel('searchModel'); + this.interactive = true; + Object.assign(this.state, { + description: this.env.action.name || "", + isDefault: false, + isShared: false, + }); + + useAutofocus(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + */ + _saveFavorite() { + if (!this.state.description.length) { + this.env.services.notification.notify({ + message: this.env._t("A name for your favorite filter is required."), + type: 'danger', + }); + return this.descriptionRef.el.focus(); + } + const favorites = this.model.get('filters', f => f.type === 'favorite'); + if (favorites.some(f => f.description === this.state.description)) { + this.env.services.notification.notify({ + message: this.env._t("Filter with same name already exists."), + type: 'danger', + }); + return this.descriptionRef.el.focus(); + } + this.model.dispatch('createNewFavorite', { + type: 'favorite', + description: this.state.description, + isDefault: this.state.isDefault, + isShared: this.state.isShared, + }); + // Reset state + Object.assign(this.state, { + description: this.env.action.name || "", + isDefault: false, + isShared: false, + open: false, + }); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev change Event + */ + _onCheckboxChange(ev) { + const { checked, id } = ev.target; + if (this.useByDefaultId === id) { + this.state.isDefault = checked; + if (checked) { + this.state.isShared = false; + } + } else { + this.state.isShared = checked; + if (checked) { + this.state.isDefault = false; + } + } + } + + /** + * @private + * @param {jQueryEvent} ev + */ + _onInputKeydown(ev) { + switch (ev.key) { + case 'Enter': + ev.preventDefault(); + this._saveFavorite(); + break; + case 'Escape': + // Gives the focus back to the component. + ev.preventDefault(); + ev.target.blur(); + break; + } + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @param {Object} env + * @returns {boolean} + */ + static shouldBeDisplayed(env) { + return true; + } + } + + CustomFavoriteItem.props = {}; + CustomFavoriteItem.template = 'web.CustomFavoriteItem'; + CustomFavoriteItem.groupNumber = 3; // have 'Save Current Search' in its own group + + FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0); + + return CustomFavoriteItem; +}); diff --git a/addons/web/static/src/js/control_panel/custom_filter_item.js b/addons/web/static/src/js/control_panel/custom_filter_item.js new file mode 100644 index 00000000..0b9b1ccb --- /dev/null +++ b/addons/web/static/src/js/control_panel/custom_filter_item.js @@ -0,0 +1,275 @@ +odoo.define('web.CustomFilterItem', function (require) { + "use strict"; + + const { DatePicker, DateTimePicker } = require('web.DatePickerOwl'); + const Domain = require('web.Domain'); + const DropdownMenuItem = require('web.DropdownMenuItem'); + const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils'); + const field_utils = require('web.field_utils'); + const patchMixin = require('web.patchMixin'); + const { useModel } = require('web/static/src/js/model.js'); + + /** + * Filter generator menu + * + * Component which purpose is to generate new filters from a set of inputs. + * It is made of one or several `condition` objects that will be used to generate + * filters which will be added in the filter menu. Each condition is composed + * of 2, 3 or 4 inputs: + * + * 1. FIELD (select): the field used to form the base of the domain condition; + * + * 2. OPERATOR (select): the symbol determining the operator(s) of the domain + * condition, linking the field to one or several value(s). + * Some operators can have pre-defined values that will replace user inputs. + * @see searchUtils for the list of operators. + * + * 3. [VALUE] (input|select): the value of the domain condition. it will be parsed + * according to the selected field's type. Note that + * it is optional as some operators have defined values. + * The generated condition domain will be as following: + * [ + * [field, operator, (operator_value|input_value)] + * ] + * + * 4. [VALUE] (input): for now, only date-typed fields with the 'between' operator + * allow for a second value. The given input values will then + * be taken as the borders of the date range (between x and y) + * and will be translated as the following domain form: + * [ + * [date_field, '>=', x], + * [date_field, '<=', y], + * ] + * @extends DropdownMenuItem + */ + class CustomFilterItem extends DropdownMenuItem { + constructor() { + super(...arguments); + + this.model = useModel('searchModel'); + + this.canBeOpened = true; + this.state.conditions = []; + // Format, filter and sort the fields props + this.fields = Object.values(this.props.fields) + .filter(field => this._validateField(field)) + .concat({ string: 'ID', type: 'id', name: 'id' }) + .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0); + + // Give access to constants variables to the template. + this.DECIMAL_POINT = this.env._t.database.parameters.decimal_point; + this.OPERATORS = FIELD_OPERATORS; + this.FIELD_TYPES = FIELD_TYPES; + + // Add default empty condition + this._addDefaultCondition(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Populate the conditions list with a default condition having as properties: + * - the first available field + * - the first available operator + * - a null or empty array value + * @private + */ + _addDefaultCondition() { + const condition = { + field: 0, + operator: 0, + }; + this._setDefaultValue(condition); + this.state.conditions.push(condition); + } + + /** + * @private + * @param {Object} field + * @returns {boolean} + */ + _validateField(field) { + return !field.deprecated && + field.searchable && + FIELD_TYPES[field.type] && + field.name !== 'id'; + } + + /** + * @private + * @param {Object} condition + */ + _setDefaultValue(condition) { + const fieldType = this.fields[condition.field].type; + const genericType = FIELD_TYPES[fieldType]; + const operator = FIELD_OPERATORS[genericType][condition.operator]; + // Logical value + switch (genericType) { + case 'id': + case 'number': + condition.value = 0; + break; + case 'date': + condition.value = [moment()]; + if (operator.symbol === 'between') { + condition.value.push(moment()); + } + break; + case 'datetime': + condition.value = [moment('00:00:00', 'hh:mm:ss')]; + if (operator.symbol === 'between') { + condition.value.push(moment('23:59:59', 'hh:mm:ss')); + } + break; + case 'selection': + const [firstValue] = this.fields[condition.field].selection[0]; + condition.value = firstValue; + break; + default: + condition.value = ""; + } + // Displayed value + if (["float", "monetary"].includes(fieldType)) { + condition.displayedValue = `0${this.DECIMAL_POINT}0`; + } else { + condition.displayedValue = String(condition.value); + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * Convert all conditions to prefilters. + * @private + */ + _onApply() { + const preFilters = this.state.conditions.map(condition => { + const field = this.fields[condition.field]; + const type = this.FIELD_TYPES[field.type]; + const operator = this.OPERATORS[type][condition.operator]; + const descriptionArray = [field.string, operator.description]; + const domainArray = []; + let domainValue; + // Field type specifics + if ('value' in operator) { + domainValue = [operator.value]; + // No description to push here + } else if (['date', 'datetime'].includes(type)) { + domainValue = condition.value.map( + val => field_utils.parse[type](val, { type }, { timezone: true }) + ); + const dateValue = condition.value.map( + val => field_utils.format[type](val, { type }, { timezone: false }) + ); + descriptionArray.push(`"${dateValue.join(" " + this.env._t("and") + " ")}"`); + } else { + domainValue = [condition.value]; + descriptionArray.push(`"${condition.value}"`); + } + // Operator specifics + if (operator.symbol === 'between') { + domainArray.push( + [field.name, '>=', domainValue[0]], + [field.name, '<=', domainValue[1]] + ); + } else { + domainArray.push([field.name, operator.symbol, domainValue[0]]); + } + const preFilter = { + description: descriptionArray.join(" "), + domain: Domain.prototype.arrayToString(domainArray), + type: 'filter', + }; + return preFilter; + }); + + this.model.dispatch('createNewFilters', preFilters); + + // Reset state + this.state.open = false; + this.state.conditions = []; + this._addDefaultCondition(); + } + + /** + * @private + * @param {Object} condition + * @param {number} valueIndex + * @param {OwlEvent} ev + */ + _onDateChanged(condition, valueIndex, ev) { + condition.value[valueIndex] = ev.detail.date; + } + + /** + * @private + * @param {Object} condition + * @param {Event} ev + */ + _onFieldSelect(condition, ev) { + Object.assign(condition, { + field: ev.target.selectedIndex, + operator: 0, + }); + this._setDefaultValue(condition); + } + + /** + * @private + * @param {Object} condition + * @param {Event} ev + */ + _onOperatorSelect(condition, ev) { + condition.operator = ev.target.selectedIndex; + this._setDefaultValue(condition); + } + + /** + * @private + * @param {Object} condition + */ + _onRemoveCondition(conditionIndex) { + this.state.conditions.splice(conditionIndex, 1); + } + + /** + * @private + * @param {Object} condition + * @param {Event} ev + */ + _onValueInput(condition, ev) { + if (!ev.target.value) { + return this._setDefaultValue(condition); + } + let { type } = this.fields[condition.field]; + if (type === "id") { + type = "integer"; + } + if (FIELD_TYPES[type] === "number") { + try { + // Write logical value into the 'value' property + condition.value = field_utils.parse[type](ev.target.value); + // Write displayed value in the input and 'displayedValue' property + condition.displayedValue = ev.target.value; + } catch (err) { + // Parsing error: reverts to previous value + ev.target.value = condition.displayedValue; + } + } else { + condition.value = condition.displayedValue = ev.target.value; + } + } + } + + CustomFilterItem.components = { DatePicker, DateTimePicker }; + CustomFilterItem.props = { + fields: Object, + }; + CustomFilterItem.template = 'web.CustomFilterItem'; + + return patchMixin(CustomFilterItem); +}); diff --git a/addons/web/static/src/js/control_panel/custom_group_by_item.js b/addons/web/static/src/js/control_panel/custom_group_by_item.js new file mode 100644 index 00000000..20a1c3c7 --- /dev/null +++ b/addons/web/static/src/js/control_panel/custom_group_by_item.js @@ -0,0 +1,46 @@ +odoo.define('web.CustomGroupByItem', function (require) { + "use strict"; + + const DropdownMenuItem = require('web.DropdownMenuItem'); + const { useModel } = require('web/static/src/js/model.js'); + + /** + * Group by generator menu + * + * Component used to generate new filters of type 'groupBy'. It is composed + * of a button (used to toggle the rendering of the rest of the component) and + * an input (select) used to choose a new field name which will be used as a + * new groupBy value. + * @extends DropdownMenuItem + */ + class CustomGroupByItem extends DropdownMenuItem { + constructor() { + super(...arguments); + + this.canBeOpened = true; + this.state.fieldName = this.props.fields[0].name; + + this.model = useModel('searchModel'); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + */ + _onApply() { + const field = this.props.fields.find(f => f.name === this.state.fieldName); + this.model.dispatch('createNewGroupBy', field); + this.state.open = false; + } + } + + CustomGroupByItem.template = 'web.CustomGroupByItem'; + CustomGroupByItem.props = { + fields: Array, + }; + + return CustomGroupByItem; +}); diff --git a/addons/web/static/src/js/control_panel/favorite_menu.js b/addons/web/static/src/js/control_panel/favorite_menu.js new file mode 100644 index 00000000..ff09109f --- /dev/null +++ b/addons/web/static/src/js/control_panel/favorite_menu.js @@ -0,0 +1,107 @@ +odoo.define('web.FavoriteMenu', function (require) { + "use strict"; + + const Dialog = require('web.OwlDialog'); + const DropdownMenu = require('web.DropdownMenu'); + const { FACET_ICONS } = require("web.searchUtils"); + const Registry = require('web.Registry'); + const { useModel } = require('web/static/src/js/model.js'); + + /** + * 'Favorites' menu + * + * Simple rendering of the filters of type `favorites` given by the control panel + * model. It uses most of the behaviours implemented by the dropdown menu Component, + * with the addition of a submenu registry used to display additional components. + * Only the favorite generator (@see CustomFavoriteItem) is registered in + * the `web` module. + * @see DropdownMenu for additional details. + * @extends DropdownMenu + */ + class FavoriteMenu extends DropdownMenu { + constructor() { + super(...arguments); + + this.model = useModel('searchModel'); + this.state.deletedFavorite = false; + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get icon() { + return FACET_ICONS.favorite; + } + + /** + * @override + */ + get items() { + const favorites = this.model.get('filters', f => f.type === 'favorite'); + const registryMenus = this.constructor.registry.values().reduce( + (menus, Component) => { + if (Component.shouldBeDisplayed(this.env)) { + menus.push({ + key: Component.name, + groupNumber: Component.groupNumber, + Component, + }); + } + return menus; + }, + [] + ); + return [...favorites, ...registryMenus]; + } + + /** + * @override + */ + get title() { + return this.env._t("Favorites"); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemRemoved(ev) { + const favorite = this.items.find(fav => fav.id === ev.detail.item.id); + this.state.deletedFavorite = favorite; + } + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + this.model.dispatch('toggleFilter', ev.detail.item.id); + } + + /** + * @private + */ + async _onRemoveFavorite() { + this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id); + this.state.deletedFavorite = false; + } + } + + FavoriteMenu.registry = new Registry(); + + FavoriteMenu.components = Object.assign({}, DropdownMenu.components, { + Dialog, + }); + FavoriteMenu.template = 'web.FavoriteMenu'; + + return FavoriteMenu; +}); diff --git a/addons/web/static/src/js/control_panel/filter_menu.js b/addons/web/static/src/js/control_panel/filter_menu.js new file mode 100644 index 00000000..da37eb23 --- /dev/null +++ b/addons/web/static/src/js/control_panel/filter_menu.js @@ -0,0 +1,79 @@ +odoo.define('web.FilterMenu', function (require) { + "use strict"; + + const CustomFilterItem = require('web.CustomFilterItem'); + const DropdownMenu = require('web.DropdownMenu'); + const { FACET_ICONS } = require("web.searchUtils"); + const { useModel } = require('web/static/src/js/model.js'); + + /** + * 'Filters' menu + * + * Simple rendering of the filters of type `filter` given by the control panel + * model. It uses most of the behaviours implemented by the dropdown menu Component, + * with the addition of a filter generator (@see CustomFilterItem). + * @see DropdownMenu for additional details. + * @extends DropdownMenu + */ + class FilterMenu extends DropdownMenu { + + constructor() { + super(...arguments); + + this.model = useModel('searchModel'); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get icon() { + return FACET_ICONS.filter; + } + + /** + * @override + */ + get items() { + return this.model.get('filters', f => f.type === 'filter'); + } + + /** + * @override + */ + get title() { + return this.env._t("Filters"); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + const { item, option } = ev.detail; + if (option) { + this.model.dispatch('toggleFilterWithOptions', item.id, option.id); + } else { + this.model.dispatch('toggleFilter', item.id); + } + } + } + + FilterMenu.components = Object.assign({}, DropdownMenu.components, { + CustomFilterItem, + }); + FilterMenu.props = Object.assign({}, DropdownMenu.props, { + fields: Object, + }); + FilterMenu.template = 'web.FilterMenu'; + + return FilterMenu; +}); diff --git a/addons/web/static/src/js/control_panel/groupby_menu.js b/addons/web/static/src/js/control_panel/groupby_menu.js new file mode 100644 index 00000000..546952ad --- /dev/null +++ b/addons/web/static/src/js/control_panel/groupby_menu.js @@ -0,0 +1,98 @@ +odoo.define('web.GroupByMenu', function (require) { + "use strict"; + + const CustomGroupByItem = require('web.CustomGroupByItem'); + const DropdownMenu = require('web.DropdownMenu'); + const { FACET_ICONS, GROUPABLE_TYPES } = require('web.searchUtils'); + const { useModel } = require('web/static/src/js/model.js'); + + /** + * 'Group by' menu + * + * Simple rendering of the filters of type `groupBy` given by the control panel + * model. It uses most of the behaviours implemented by the dropdown menu Component, + * with the addition of a groupBy filter generator (@see CustomGroupByItem). + * @see DropdownMenu for additional details. + * @extends DropdownMenu + */ + class GroupByMenu extends DropdownMenu { + + constructor() { + super(...arguments); + + this.fields = Object.values(this.props.fields) + .filter(field => this._validateField(field)) + .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0); + + this.model = useModel('searchModel'); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get icon() { + return FACET_ICONS.groupBy; + } + + /** + * @override + */ + get items() { + return this.model.get('filters', f => f.type === 'groupBy'); + } + + /** + * @override + */ + get title() { + return this.env._t("Group By"); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @param {Object} field + * @returns {boolean} + */ + _validateField(field) { + return field.sortable && + field.name !== "id" && + GROUPABLE_TYPES.includes(field.type); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + const { item, option } = ev.detail; + if (option) { + this.model.dispatch('toggleFilterWithOptions', item.id, option.id); + } else { + this.model.dispatch('toggleFilter', item.id); + } + } + } + + GroupByMenu.components = Object.assign({}, DropdownMenu.components, { + CustomGroupByItem, + }); + GroupByMenu.props = Object.assign({}, DropdownMenu.props, { + fields: Object, + }); + GroupByMenu.template = 'web.GroupByMenu'; + + return GroupByMenu; +}); diff --git a/addons/web/static/src/js/control_panel/search_bar.js b/addons/web/static/src/js/control_panel/search_bar.js new file mode 100644 index 00000000..dd0d4460 --- /dev/null +++ b/addons/web/static/src/js/control_panel/search_bar.js @@ -0,0 +1,493 @@ +odoo.define('web.SearchBar', function (require) { + "use strict"; + + const Domain = require('web.Domain'); + const field_utils = require('web.field_utils'); + const { useAutofocus } = require('web.custom_hooks'); + const { useModel } = require('web/static/src/js/model.js'); + + const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text']; + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + let sourceId = 0; + + /** + * Search bar + * + * This component has two main roles: + * 1) Display the current search facets + * 2) Create new search filters using an input and an autocompletion values + * generator. + * + * For the first bit, the core logic can be found in the XML template of this + * component, searchfacet components or in the ControlPanelModel itself. + * + * The autocompletion mechanic works with transient subobjects called 'sources'. + * Sources contain the information that will be used to generate new search facets. + * A source is generated either: + * a. From an undetermined user input: the user will give a string and select + * a field from the autocompletion dropdown > this will search the selected + * field records with the given pattern (with an 'ilike' operator); + * b. From a given selection: when given an input by the user, the searchbar + * will pre-fetch 'many2one' field records matching the input value and filter + * 'select' fields with the same value. If the user clicks on one of these + * fetched/filtered values, it will generate a matching search facet targeting + * records having this exact value. + * @extends Component + */ + class SearchBar extends Component { + constructor() { + super(...arguments); + + this.focusOnUpdate = useAutofocus(); + this.inputRef = useRef('search-input'); + this.model = useModel('searchModel'); + this.state = useState({ + sources: [], + focusedItem: 0, + inputValue: "", + }); + + this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map( + filter => this._createSource(filter) + ); + this.noResultItem = [null, this.env._t("(no result)")]; + + useExternalListener(window, 'click', this._onWindowClick); + useExternalListener(window, 'keydown', this._onWindowKeydown); + } + + mounted() { + // 'search' will always patch the search bar, 'focus' will never. + this.env.searchModel.on('search', this, this.focusOnUpdate); + this.env.searchModel.on('focus-control-panel', this, () => { + this.inputRef.el.focus(); + }); + } + + willUnmount() { + this.env.searchModel.off('search', this); + this.env.searchModel.off('focus-control-panel', this); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + */ + _closeAutoComplete() { + this.state.sources = []; + this.state.focusedItem = 0; + this.state.inputValue = ""; + this.inputRef.el.value = ""; + this.focusOnUpdate(); + } + + /** + * @private + * @param {Object} filter + * @returns {Object} + */ + _createSource(filter) { + const field = this.props.fields[filter.fieldName]; + const type = field.type === "reference" ? "char" : field.type; + const source = { + active: true, + description: filter.description, + filterId: filter.id, + filterOperator: filter.operator, + id: sourceId ++, + operator: CHAR_FIELDS.includes(type) ? 'ilike' : '=', + parent: false, + type, + }; + switch (type) { + case 'selection': { + source.active = false; + source.selection = field.selection || []; + break; + } + case 'boolean': { + source.active = false; + source.selection = [ + [true, this.env._t("Yes")], + [false, this.env._t("No")], + ]; + break; + } + case 'many2one': { + source.expand = true; + source.expanded = false; + source.context = field.context; + source.relation = field.relation; + if (filter.domain) { + source.domain = filter.domain; + } + } + } + return source; + } + + /** + * @private + * @param {Object} source + * @param {[any, string]} values + * @param {boolean} [active=true] + */ + _createSubSource(source, [value, label], active = true) { + const subSource = { + active, + filterId: source.filterId, + filterOperator: source.filterOperator, + id: sourceId ++, + label, + operator: '=', + parent: source, + value, + }; + return subSource; + } + + /** + * @private + * @param {Object} source + * @param {boolean} shouldExpand + */ + async _expandSource(source, shouldExpand) { + source.expanded = shouldExpand; + if (shouldExpand) { + let args = source.domain; + if (typeof args === 'string') { + try { + args = Domain.prototype.stringToArray(args); + } catch (err) { + args = []; + } + } + const results = await this.rpc({ + kwargs: { + args, + context: source.context, + limit: 8, + name: this.state.inputValue.trim(), + }, + method: 'name_search', + model: source.relation, + }); + const options = results.map(result => this._createSubSource(source, result)); + const parentIndex = this.state.sources.indexOf(source); + if (!options.length) { + options.push(this._createSubSource(source, this.noResultItem, false)); + } + this.state.sources.splice(parentIndex + 1, 0, ...options); + } else { + this.state.sources = this.state.sources.filter(src => src.parent !== source); + } + } + + /** + * @private + * @param {string} query + */ + _filterSources(query) { + return this.autoCompleteSources.reduce( + (sources, source) => { + // Field selection or boolean. + if (source.selection) { + const options = []; + source.selection.forEach(result => { + if (fuzzy.test(query, result[1].toLowerCase())) { + options.push(this._createSubSource(source, result)); + } + }); + if (options.length) { + sources.push(source, ...options); + } + // Any other type. + } else if (this._validateSource(query, source)) { + sources.push(source); + } + // Fold any expanded item. + if (source.expanded) { + source.expanded = false; + } + return sources; + }, + [] + ); + } + + /** + * Focus the search facet at the designated index if any. + * @private + */ + _focusFacet(index) { + const facets = this.el.getElementsByClassName('o_searchview_facet'); + if (facets.length) { + facets[index].focus(); + } + } + + /** + * Try to parse the given rawValue according to the type of the given + * source field type. The returned formatted value is the one that will + * supposedly be sent to the server. + * @private + * @param {string} rawValue + * @param {Object} source + * @returns {string} + */ + _parseWithSource(rawValue, { type }) { + const parser = field_utils.parse[type]; + let parsedValue; + switch (type) { + case 'date': + case 'datetime': { + const parsedDate = parser(rawValue, { type }, { timezone: true }); + const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'; + const momentValue = moment(parsedDate, dateFormat); + if (!momentValue.isValid()) { + throw new Error('Invalid date'); + } + parsedValue = parsedDate.toJSON(); + break; + } + case 'many2one': { + parsedValue = rawValue; + break; + } + default: { + parsedValue = parser(rawValue); + } + } + return parsedValue; + } + + /** + * @private + * @param {Object} source + */ + _selectSource(source) { + // Inactive sources are: + // - Selection sources + // - "no result" items + if (source.active) { + const labelValue = source.label || this.state.inputValue; + this.model.dispatch('addAutoCompletionValues', { + filterId: source.filterId, + value: "value" in source ? source.value : this._parseWithSource(labelValue, source), + label: labelValue, + operator: source.filterOperator || source.operator, + }); + } + this._closeAutoComplete(); + } + + /** + * @private + * @param {string} query + * @param {Object} source + * @returns {boolean} + */ + _validateSource(query, source) { + try { + this._parseWithSource(query, source); + } catch (err) { + return false; + } + return true; + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {Object} facet + * @param {number} facetIndex + * @param {KeyboardEvent} ev + */ + _onFacetKeydown(facet, facetIndex, ev) { + switch (ev.key) { + case 'ArrowLeft': + if (facetIndex === 0) { + this.inputRef.el.focus(); + } else { + this._focusFacet(facetIndex - 1); + } + break; + case 'ArrowRight': + const facets = this.el.getElementsByClassName('o_searchview_facet'); + if (facetIndex === facets.length - 1) { + this.inputRef.el.focus(); + } else { + this._focusFacet(facetIndex + 1); + } + break; + case 'Backspace': + this._onFacetRemove(facet); + break; + } + } + + /** + * @private + * @param {Object} facet + */ + _onFacetRemove(facet) { + this.model.dispatch('deactivateGroup', facet.groupId); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onSearchKeydown(ev) { + if (ev.isComposing) { + // This case happens with an IME for example: we let it handle all key events. + return; + } + const currentItem = this.state.sources[this.state.focusedItem] || {}; + switch (ev.key) { + case 'ArrowDown': + ev.preventDefault(); + if (Object.keys(this.state.sources).length) { + let nextIndex = this.state.focusedItem + 1; + if (nextIndex >= this.state.sources.length) { + nextIndex = 0; + } + this.state.focusedItem = nextIndex; + } else { + this.env.bus.trigger('focus-view'); + } + break; + case 'ArrowLeft': + if (currentItem.expanded) { + // Priority 1: fold expanded item. + ev.preventDefault(); + this._expandSource(currentItem, false); + } else if (currentItem.parent) { + // Priority 2: focus parent item. + ev.preventDefault(); + this.state.focusedItem = this.state.sources.indexOf(currentItem.parent); + // Priority 3: Do nothing (navigation inside text). + } else if (ev.target.selectionStart === 0) { + // Priority 4: navigate to rightmost facet. + this._focusFacet(this.model.get("facets").length - 1); + } + break; + case 'ArrowRight': + if (ev.target.selectionStart === this.state.inputValue.length) { + // Priority 1: Do nothing (navigation inside text). + if (currentItem.expand) { + // Priority 2: go to first child or expand item. + ev.preventDefault(); + if (currentItem.expanded) { + this.state.focusedItem ++; + } else { + this._expandSource(currentItem, true); + } + } else if (ev.target.selectionStart === this.state.inputValue.length) { + // Priority 3: navigate to leftmost facet. + this._focusFacet(0); + } + } + break; + case 'ArrowUp': + ev.preventDefault(); + let previousIndex = this.state.focusedItem - 1; + if (previousIndex < 0) { + previousIndex = this.state.sources.length - 1; + } + this.state.focusedItem = previousIndex; + break; + case 'Backspace': + if (!this.state.inputValue.length) { + const facets = this.model.get("facets"); + if (facets.length) { + this._onFacetRemove(facets[facets.length - 1]); + } + } + break; + case 'Enter': + if (!this.state.inputValue.length) { + this.model.dispatch('search'); + break; + } + /* falls through */ + case 'Tab': + if (this.state.inputValue.length) { + ev.preventDefault(); // keep the focus inside the search bar + this._selectSource(currentItem); + } + break; + case 'Escape': + if (this.state.sources.length) { + this._closeAutoComplete(); + } + break; + } + } + + /** + * @private + * @param {InputEvent} ev + */ + _onSearchInput(ev) { + this.state.inputValue = ev.target.value; + const wasVisible = this.state.sources.length; + const query = this.state.inputValue.trim().toLowerCase(); + if (query.length) { + this.state.sources = this._filterSources(query); + } else if (wasVisible) { + this._closeAutoComplete(); + } + } + + /** + * Only handled if the user has moved its cursor at least once after the + * results are loaded and displayed. + * @private + * @param {number} resultIndex + */ + _onSourceMousemove(resultIndex) { + this.state.focusedItem = resultIndex; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onWindowClick(ev) { + if (this.state.sources.length && !this.el.contains(ev.target)) { + this._closeAutoComplete(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onWindowKeydown(ev) { + if (ev.key === 'Escape' && this.state.sources.length) { + ev.preventDefault(); + ev.stopPropagation(); + this._closeAutoComplete(); + } + } + } + + SearchBar.defaultProps = { + fields: {}, + }; + SearchBar.props = { + fields: Object, + }; + SearchBar.template = 'web.SearchBar'; + + return SearchBar; +}); diff --git a/addons/web/static/src/js/control_panel/search_utils.js b/addons/web/static/src/js/control_panel/search_utils.js new file mode 100644 index 00000000..8fce5b23 --- /dev/null +++ b/addons/web/static/src/js/control_panel/search_utils.js @@ -0,0 +1,542 @@ +odoo.define('web.searchUtils', function (require) { + "use strict"; + + const { _lt, _t } = require('web.core'); + const Domain = require('web.Domain'); + const pyUtils = require('web.py_utils'); + + //------------------------------------------------------------------------- + // Constants + //------------------------------------------------------------------------- + + // Filter menu parameters + const FIELD_OPERATORS = { + boolean: [ + { symbol: "=", description: _lt("is true"), value: true }, + { symbol: "!=", description: _lt("is false"), value: true }, + ], + char: [ + { symbol: "ilike", description: _lt("contains") }, + { symbol: "not ilike", description: _lt("doesn't contain") }, + { symbol: "=", description: _lt("is equal to") }, + { symbol: "!=", description: _lt("is not equal to") }, + { symbol: "!=", description: _lt("is set"), value: false }, + { symbol: "=", description: _lt("is not set"), value: false }, + ], + date: [ + { symbol: "=", description: _lt("is equal to") }, + { symbol: "!=", description: _lt("is not equal to") }, + { symbol: ">", description: _lt("is after") }, + { symbol: "<", description: _lt("is before") }, + { symbol: ">=", description: _lt("is after or equal to") }, + { symbol: "<=", description: _lt("is before or equal to") }, + { symbol: "between", description: _lt("is between") }, + { symbol: "!=", description: _lt("is set"), value: false }, + { symbol: "=", description: _lt("is not set"), value: false }, + ], + datetime: [ + { symbol: "between", description: _lt("is between") }, + { symbol: "=", description: _lt("is equal to") }, + { symbol: "!=", description: _lt("is not equal to") }, + { symbol: ">", description: _lt("is after") }, + { symbol: "<", description: _lt("is before") }, + { symbol: ">=", description: _lt("is after or equal to") }, + { symbol: "<=", description: _lt("is before or equal to") }, + { symbol: "!=", description: _lt("is set"), value: false }, + { symbol: "=", description: _lt("is not set"), value: false }, + ], + id: [ + { symbol: "=", description: _lt("is") }, + { symbol: "<=", description: _lt("less than or equal to")}, + { symbol: ">", description: _lt("greater than")}, + ], + number: [ + { symbol: "=", description: _lt("is equal to") }, + { symbol: "!=", description: _lt("is not equal to") }, + { symbol: ">", description: _lt("greater than") }, + { symbol: "<", description: _lt("less than") }, + { symbol: ">=", description: _lt("greater than or equal to") }, + { symbol: "<=", description: _lt("less than or equal to") }, + { symbol: "!=", description: _lt("is set"), value: false }, + { symbol: "=", description: _lt("is not set"), value: false }, + ], + selection: [ + { symbol: "=", description: _lt("is") }, + { symbol: "!=", description: _lt("is not") }, + { symbol: "!=", description: _lt("is set"), value: false }, + { symbol: "=", description: _lt("is not set"), value: false }, + ], + }; + const FIELD_TYPES = { + boolean: 'boolean', + char: 'char', + date: 'date', + datetime: 'datetime', + float: 'number', + id: 'id', + integer: 'number', + html: 'char', + many2many: 'char', + many2one: 'char', + monetary: 'number', + one2many: 'char', + text: 'char', + selection: 'selection', + }; + const DEFAULT_PERIOD = 'this_month'; + const QUARTERS = { + 1: { description: _lt("Q1"), coveredMonths: [0, 1, 2] }, + 2: { description: _lt("Q2"), coveredMonths: [3, 4, 5] }, + 3: { description: _lt("Q3"), coveredMonths: [6, 7, 8] }, + 4: { description: _lt("Q4"), coveredMonths: [9, 10, 11] }, + }; + const MONTH_OPTIONS = { + this_month: { + id: 'this_month', groupNumber: 1, format: 'MMMM', + addParam: {}, granularity: 'month', + }, + last_month: { + id: 'last_month', groupNumber: 1, format: 'MMMM', + addParam: { months: -1 }, granularity: 'month', + }, + antepenultimate_month: { + id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM', + addParam: { months: -2 }, granularity: 'month', + }, + }; + const QUARTER_OPTIONS = { + fourth_quarter: { + id: 'fourth_quarter', groupNumber: 1, description: QUARTERS[4].description, + setParam: { quarter: 4 }, granularity: 'quarter', + }, + third_quarter: { + id: 'third_quarter', groupNumber: 1, description: QUARTERS[3].description, + setParam: { quarter: 3 }, granularity: 'quarter', + }, + second_quarter: { + id: 'second_quarter', groupNumber: 1, description: QUARTERS[2].description, + setParam: { quarter: 2 }, granularity: 'quarter', + }, + first_quarter: { + id: 'first_quarter', groupNumber: 1, description: QUARTERS[1].description, + setParam: { quarter: 1 }, granularity: 'quarter', + }, + }; + const YEAR_OPTIONS = { + this_year: { + id: 'this_year', groupNumber: 2, format: 'YYYY', + addParam: {}, granularity: 'year', + }, + last_year: { + id: 'last_year', groupNumber: 2, format: 'YYYY', + addParam: { years: -1 }, granularity: 'year', + }, + antepenultimate_year: { + id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY', + addParam: { years: -2 }, granularity: 'year', + }, + }; + const PERIOD_OPTIONS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS); + + // GroupBy menu parameters + const GROUPABLE_TYPES = [ + 'boolean', + 'char', + 'date', + 'datetime', + 'integer', + 'many2one', + 'selection', + ]; + const DEFAULT_INTERVAL = 'month'; + const INTERVAL_OPTIONS = { + year: { description: _lt("Year"), id: 'year', groupNumber: 1 }, + quarter: { description: _lt("Quarter"), id: 'quarter', groupNumber: 1 }, + month: { description: _lt("Month"), id: 'month', groupNumber: 1 }, + week: { description: _lt("Week"), id: 'week', groupNumber: 1 }, + day: { description: _lt("Day"), id: 'day', groupNumber: 1 } + }; + + // Comparison menu parameters + const COMPARISON_OPTIONS = { + previous_period: { + description: _lt("Previous Period"), id: 'previous_period', + }, + previous_year: { + description: _lt("Previous Year"), id: 'previous_year', addParam: { years: -1 }, + }, + }; + const PER_YEAR = { + year: 1, + quarter: 4, + month: 12, + }; + // Search bar + const FACET_ICONS = { + filter: 'fa fa-filter', + groupBy: 'fa fa-bars', + favorite: 'fa fa-star', + comparison: 'fa fa-adjust', + }; + + //------------------------------------------------------------------------- + // Functions + //------------------------------------------------------------------------- + + /** + * Constructs the string representation of a domain and its description. The + * domain is of the form: + * ['|',..., '|', d_1,..., d_n] + * where d_i is a time range of the form + * ['&', [fieldName, >=, leftBound_i], [fieldName, <=, rightBound_i]] + * where leftBound_i and rightBound_i are date or datetime computed accordingly + * to the given options and reference moment. + * (@see constructDateRange). + * @param {moment} referenceMoment + * @param {string} fieldName + * @param {string} fieldType + * @param {string[]} selectedOptionIds + * @param {string} [comparisonOptionId] + * @returns {{ domain: string, description: string }} + */ + function constructDateDomain( + referenceMoment, + fieldName, + fieldType, + selectedOptionIds, + comparisonOptionId + ) { + let addParam; + let selectedOptions; + if (comparisonOptionId) { + [addParam, selectedOptions] = getComparisonParams( + referenceMoment, + selectedOptionIds, + comparisonOptionId); + } else { + selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds); + } + + const yearOptions = selectedOptions.year; + const otherOptions = [ + ...(selectedOptions.quarter || []), + ...(selectedOptions.month || []) + ]; + + sortPeriodOptions(yearOptions); + sortPeriodOptions(otherOptions); + + const ranges = []; + for (const yearOption of yearOptions) { + const constructRangeParams = { + referenceMoment, + fieldName, + fieldType, + addParam, + }; + if (otherOptions.length) { + for (const option of otherOptions) { + const setParam = Object.assign({}, + yearOption.setParam, + option ? option.setParam : {} + ); + const { granularity } = option; + const range = constructDateRange(Object.assign( + { granularity, setParam }, + constructRangeParams + )); + ranges.push(range); + } + } else { + const { granularity, setParam } = yearOption; + const range = constructDateRange(Object.assign( + { granularity, setParam }, + constructRangeParams + )); + ranges.push(range); + } + } + + const domain = pyUtils.assembleDomains(ranges.map(range => range.domain), 'OR'); + const description = ranges.map(range => range.description).join("/"); + + return { domain, description }; + } + + /** + * Constructs the string representation of a domain and its description. The + * domain is a time range of the form: + * ['&', [fieldName, >=, leftBound],[fieldName, <=, rightBound]] + * where leftBound and rightBound are some date or datetime determined by setParam, + * addParam, granularity and the reference moment. + * @param {Object} params + * @param {moment} params.referenceMoment + * @param {string} params.fieldName + * @param {string} params.fieldType + * @param {string} params.granularity + * @param {Object} params.setParam + * @param {Object} [params.addParam] + * @returns {{ domain: string, description: string }} + */ + function constructDateRange({ + referenceMoment, + fieldName, + fieldType, + granularity, + setParam, + addParam, + }) { + const date = referenceMoment.clone().set(setParam).add(addParam || {}); + + // compute domain + let leftBound = date.clone().locale('en').startOf(granularity); + let rightBound = date.clone().locale('en').endOf(granularity); + if (fieldType === 'date') { + leftBound = leftBound.format('YYYY-MM-DD'); + rightBound = rightBound.format('YYYY-MM-DD'); + } else { + leftBound = leftBound.utc().format('YYYY-MM-DD HH:mm:ss'); + rightBound = rightBound.utc().format('YYYY-MM-DD HH:mm:ss'); + } + const domain = Domain.prototype.arrayToString([ + '&', + [fieldName, '>=', leftBound], + [fieldName, '<=', rightBound] + ]); + + // compute description + const descriptions = [date.format("YYYY")]; + const method = _t.database.parameters.direction === "rtl" ? "push" : "unshift"; + if (granularity === "month") { + descriptions[method](date.format("MMMM")); + } else if (granularity === "quarter") { + descriptions[method](QUARTERS[date.quarter()].description); + } + const description = descriptions.join(" "); + + return { domain, description, }; + } + + /** + * Returns a version of the options in COMPARISON_OPTIONS with translated descriptions. + * @see getOptionsWithDescriptions + */ + function getComparisonOptions() { + return getOptionsWithDescriptions(COMPARISON_OPTIONS); + } + + /** + * Returns the params addParam and selectedOptions necessary for the computation + * of a comparison domain. + * @param {moment} referenceMoment + * @param {string{}} selectedOptionIds + * @param {string} comparisonOptionId + * @returns {Object[]} + */ + function getComparisonParams(referenceMoment, selectedOptionIds, comparisonOptionId) { + const comparisonOption = COMPARISON_OPTIONS[comparisonOptionId]; + const selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds); + let addParam = comparisonOption.addParam; + if (addParam) { + return [addParam, selectedOptions]; + } + addParam = {}; + + let globalGranularity = 'year'; + if (selectedOptions.month) { + globalGranularity = 'month'; + } else if (selectedOptions.quarter) { + globalGranularity = 'quarter'; + } + const granularityFactor = PER_YEAR[globalGranularity]; + const years = selectedOptions.year.map(o => o.setParam.year); + const yearMin = Math.min(...years); + const yearMax = Math.max(...years); + + let optionMin = 0; + let optionMax = 0; + if (selectedOptions.quarter) { + const quarters = selectedOptions.quarter.map(o => o.setParam.quarter); + if (globalGranularity === 'month') { + delete selectedOptions.quarter; + for (const quarter of quarters) { + for (const month of QUARTERS[quarter].coveredMonths) { + const monthOption = selectedOptions.month.find( + o => o.setParam.month === month + ); + if (!monthOption) { + selectedOptions.month.push({ + setParam: { month, }, granularity: 'month', + }); + } + } + } + } else { + optionMin = Math.min(...quarters); + optionMax = Math.max(...quarters); + } + } + if (selectedOptions.month) { + const months = selectedOptions.month.map(o => o.setParam.month); + optionMin = Math.min(...months); + optionMax = Math.max(...months); + } + + addParam[globalGranularity] = -1 + + granularityFactor * (yearMin - yearMax) + + optionMin - optionMax; + + return [addParam, selectedOptions]; + } + + /** + * Returns a version of the options in INTERVAL_OPTIONS with translated descriptions. + * @see getOptionsWithDescriptions + */ + function getIntervalOptions() { + return getOptionsWithDescriptions(INTERVAL_OPTIONS); + } + + /** + * Returns a version of the options in PERIOD_OPTIONS with translated descriptions + * and a key defautlYearId used in the control panel model when toggling a period option. + * @param {moment} referenceMoment + * @returns {Object[]} + */ + function getPeriodOptions(referenceMoment) { + const options = []; + for (const option of Object.values(PERIOD_OPTIONS)) { + const { id, groupNumber, description, } = option; + const res = { id, groupNumber, }; + const date = referenceMoment.clone().set(option.setParam).add(option.addParam); + if (description) { + res.description = description.toString(); + } else { + res.description = date.format(option.format.toString()); + } + res.setParam = getSetParam(option, referenceMoment); + res.defaultYear = date.year(); + options.push(res); + } + for (const option of options) { + const yearOption = options.find( + o => o.setParam && o.setParam.year === option.defaultYear + ); + option.defaultYearId = yearOption.id; + delete option.defaultYear; + delete option.setParam; + } + return options; + } + + /** + * Returns a version of the options in OPTIONS with translated descriptions (if any). + * @param {Object{}} OPTIONS + * @returns {Object[]} + */ + function getOptionsWithDescriptions(OPTIONS) { + const options = []; + for (const option of Object.values(OPTIONS)) { + const { id, groupNumber, description, } = option; + const res = { id, }; + if (description) { + res.description = description.toString(); + } + if (groupNumber) { + res.groupNumber = groupNumber; + } + options.push(res); + } + return options; + } + + /** + * Returns a version of the period options whose ids are in selectedOptionIds + * partitioned by granularity. + * @param {moment} referenceMoment + * @param {string[]} selectedOptionIds + * @param {Object} + */ + function getSelectedOptions(referenceMoment, selectedOptionIds) { + const selectedOptions = { year: [] }; + for (const optionId of selectedOptionIds) { + const option = PERIOD_OPTIONS[optionId]; + const setParam = getSetParam(option, referenceMoment); + const granularity = option.granularity; + if (!selectedOptions[granularity]) { + selectedOptions[granularity] = []; + } + selectedOptions[granularity].push({ granularity, setParam }); + } + return selectedOptions; + } + + /** + * Returns the setParam object associated with the given periodOption and + * referenceMoment. + * @param {Object} periodOption + * @param {moment} referenceMoment + * @returns {Object} + */ + function getSetParam(periodOption, referenceMoment) { + if (periodOption.setParam) { + return periodOption.setParam; + } + const date = referenceMoment.clone().add(periodOption.addParam); + const setParam = {}; + setParam[periodOption.granularity] = date[periodOption.granularity](); + return setParam; + } + + /** + * @param {string} intervalOptionId + * @returns {number} index + */ + function rankInterval(intervalOptionId) { + return Object.keys(INTERVAL_OPTIONS).indexOf(intervalOptionId); + } + + /** + * Sorts in place an array of 'period' options. + * @param {Object[]} options supposed to be of the form: + * { granularity, setParam, } + */ + function sortPeriodOptions(options) { + options.sort((o1, o2) => { + const granularity1 = o1.granularity; + const granularity2 = o2.granularity; + if (granularity1 === granularity2) { + return o1.setParam[granularity1] - o2.setParam[granularity1]; + } + return granularity1 < granularity2 ? -1 : 1; + }); + } + + /** + * Checks if a year id is among the given array of period option ids. + * @param {string[]} selectedOptionIds + * @returns {boolean} + */ + function yearSelected(selectedOptionIds) { + return selectedOptionIds.some(optionId => !!YEAR_OPTIONS[optionId]); + } + + return { + COMPARISON_OPTIONS, + DEFAULT_INTERVAL, + DEFAULT_PERIOD, + FACET_ICONS, + FIELD_OPERATORS, + FIELD_TYPES, + GROUPABLE_TYPES, + INTERVAL_OPTIONS, + PERIOD_OPTIONS, + + constructDateDomain, + getComparisonOptions, + getIntervalOptions, + getPeriodOptions, + rankInterval, + yearSelected, + }; +}); 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; + +}); diff --git a/addons/web/static/src/js/env.js b/addons/web/static/src/js/env.js new file mode 100644 index 00000000..7fd7e1de --- /dev/null +++ b/addons/web/static/src/js/env.js @@ -0,0 +1,16 @@ +odoo.define("web.env", function (require) { + "use strict"; + + /** + * This file defines the env to use in the webclient. + */ + + const commonEnv = require('web.commonEnv'); + const dataManager = require('web.data_manager'); + const { blockUI, unblockUI } = require("web.framework"); + + const env = Object.assign(commonEnv, { dataManager }); + env.services = Object.assign(env.services, { blockUI, unblockUI }); + + return env; +}); diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js new file mode 100644 index 00000000..16e928ea --- /dev/null +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -0,0 +1,621 @@ +odoo.define('web.AbstractField', function (require) { +"use strict"; + +/** + * This is the basic field widget used by all the views to render a field in a view. + * These field widgets are mostly common to all views, in particular form and list + * views. + * + * The responsabilities of a field widget are mainly: + * - render a visual representation of the current value of a field + * - that representation is either in 'readonly' or in 'edit' mode + * - notify the rest of the system when the field has been changed by + * the user (in edit mode) + * + * Notes + * - the widget is not supposed to be able to switch between modes. If another + * mode is required, the view will take care of instantiating another widget. + * - notify the system when its value has changed and its mode is changed to 'readonly' + * - notify the system when some action has to be taken, such as opening a record + * - the Field widget should not, ever, under any circumstance, be aware of + * its parent. The way it communicates changes with the rest of the system is by + * triggering events (with trigger_up). These events bubble up and are interpreted + * by the most appropriate parent. + * + * Also, in some cases, it may not be practical to have the same widget for all + * views. In that situation, you can have a 'view specific widget'. Just register + * the widget in the registry prefixed by the view type and a dot. So, for example, + * a form specific many2one widget should be registered as 'form.many2one'. + * + * @module web.AbstractField + */ + +var field_utils = require('web.field_utils'); +var Widget = require('web.Widget'); + +var AbstractField = Widget.extend({ + events: { + 'keydown': '_onKeydown', + }, + custom_events: { + navigation_move: '_onNavigationMove', + }, + + /** + * An object representing fields to be fetched by the model eventhough not present in the view + * This object contains "field name" as key and an object as value. + * That value object must contain the key "type" + * see FieldBinaryImage for an example. + */ + fieldDependencies: {}, + + /** + * If this flag is set to true, the field widget will be reset on every + * change which is made in the view (if the view supports it). This is + * currently a form view feature. + */ + resetOnAnyFieldChange: false, + /** + * If this flag is given a string, the related BasicModel will be used to + * initialize specialData the field might need. This data will be available + * through this.record.specialData[this.name]. + * + * @see BasicModel._fetchSpecialData + */ + specialData: false, + /** + * to override to indicate which field types are supported by the widget + * + * @type Array<String> + */ + supportedFieldTypes: [], + + /** + * To override to give a user friendly name to the widget. + * + * @type <string> + */ + description: "", + /** + * Currently only used in list view. + * If this flag is set to true, the list column name will be empty. + */ + noLabel: false, + /** + * Currently only used in list view. + * If set, this value will be displayed as column name. + */ + label: '', + /** + * Abstract field class + * + * @constructor + * @param {Widget} parent + * @param {string} name The field name defined in the model + * @param {Object} record A record object (result of the get method of + * a basic model) + * @param {Object} [options] + * @param {string} [options.mode=readonly] should be 'readonly' or 'edit' + */ + init: function (parent, name, record, options) { + this._super(parent); + options = options || {}; + + // 'name' is the field name displayed by this widget + this.name = name; + + // the datapoint fetched from the model + this.record = record; + + // the 'field' property is a description of all the various field properties, + // such as the type, the comodel (relation), ... + this.field = record.fields[name]; + + // the 'viewType' is the type of the view in which the field widget is + // instantiated. For standalone widgets, a 'default' viewType is set. + this.viewType = options.viewType || 'default'; + + // the 'attrs' property contains the attributes of the xml 'field' tag, + // the inner views... + var fieldsInfo = record.fieldsInfo[this.viewType]; + this.attrs = options.attrs || (fieldsInfo && fieldsInfo[name]) || {}; + + // the 'additionalContext' property contains the attributes to pass through the context. + this.additionalContext = options.additionalContext || {}; + + // this property tracks the current (parsed if needed) value of the field. + // Note that we don't use an event system anymore, using this.get('value') + // is no longer valid. + this.value = record.data[name]; + + // recordData tracks the values for the other fields for the same record. + // note that it is expected to be mostly a readonly property, you cannot + // use this to try to change other fields value, this is not how it is + // supposed to work. Also, do not use this.recordData[this.name] to get + // the current value, this could be out of sync after a _setValue. + this.recordData = record.data; + + // the 'string' property is a human readable (and translated) description + // of the field. Mostly useful to be displayed in various places in the + // UI, such as tooltips or create dialogs. + this.string = this.attrs.string || this.field.string || this.name; + + // Widget can often be configured in the 'options' attribute in the + // xml 'field' tag. These options are saved (and evaled) in nodeOptions + this.nodeOptions = this.attrs.options || {}; + + // dataPointID is the id corresponding to the current record in the model. + // Its intended use is to be able to tag any messages going upstream, + // so the view knows which records was changed for example. + this.dataPointID = record.id; + + // this is the res_id for the record in database. Obviously, it is + // readonly. Also, when the user is creating a new record, there is + // no res_id. When the record will be created, the field widget will + // be destroyed (when the form view switches to readonly mode) and a new + // widget with a res_id in mode readonly will be created. + this.res_id = record.res_id; + + // useful mostly to trigger rpcs on the correct model + this.model = record.model; + + // a widget can be in two modes: 'edit' or 'readonly'. This mode should + // never be changed, if a view changes its mode, it will destroy and + // recreate a new field widget. + this.mode = options.mode || "readonly"; + + // this flag tracks if the widget is in a valid state, meaning that the + // current value represented in the DOM is a value that can be parsed + // and saved. For example, a float field can only use a number and not + // a string. + this._isValid = true; + + // this is the last value that was set by the user, unparsed. This is + // used to avoid setting the value twice in a row with the exact value. + this.lastSetValue = undefined; + + // formatType is used to determine which format (and parse) functions + // to call to format the field's value to insert into the DOM (typically + // put into a span or an input), and to parse the value from the input + // to send it to the server. These functions are chosen according to + // the 'widget' attrs if is is given, and if it is a valid key, with a + // fallback on the field type, ensuring that the value is formatted and + // displayed according to the chosen widget, if any. + this.formatType = this.attrs.widget in field_utils.format ? + this.attrs.widget : + this.field.type; + // formatOptions (resp. parseOptions) is a dict of options passed to + // calls to the format (resp. parse) function. + this.formatOptions = {}; + this.parseOptions = {}; + + // if we add decorations, we need to reevaluate the field whenever any + // value from the record is changed + if (this.attrs.decorations) { + this.resetOnAnyFieldChange = true; + } + }, + /** + * When a field widget is appended to the DOM, its start method is called, + * and will automatically call render. Most widgets should not override this. + * + * @returns {Promise} + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.$el.attr('name', self.name); + self.$el.addClass('o_field_widget'); + return self._render(); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Activates the field widget. By default, activation means focusing and + * selecting (if possible) the associated focusable element. The selecting + * part can be disabled. In that case, note that the focused input/textarea + * will have the cursor at the very end. + * + * @param {Object} [options] + * @param {boolean} [options.noselect=false] if false and the input + * is of type text or textarea, the content will also be selected + * @param {Event} [options.event] the event which fired this activation + * @returns {boolean} true if the widget was activated, false if the + * focusable element was not found or invisible + */ + activate: function (options) { + if (this.isFocusable()) { + var $focusable = this.getFocusableElement(); + $focusable.focus(); + if ($focusable.is('input[type="text"], textarea')) { + $focusable[0].selectionStart = $focusable[0].selectionEnd = $focusable[0].value.length; + if (options && !options.noselect) { + $focusable.select(); + } + } + return true; + } + return false; + }, + /** + * This function should be implemented by widgets that are not able to + * notify their environment when their value changes (maybe because their + * are not aware of the changes) or that may have a value in a temporary + * state (maybe because some action should be performed to validate it + * before notifying it). This is typically called before trying to save the + * widget's value, so it should call _setValue() to notify the environment + * if the value changed but was not notified. + * + * @abstract + * @returns {Promise|undefined} + */ + commitChanges: function () {}, + /** + * Returns the main field's DOM element (jQuery form) which can be focused + * by the browser. + * + * @returns {jQuery} main focusable element inside the widget + */ + getFocusableElement: function () { + return $(); + }, + /** + * Returns whether or not the field is empty and can thus be hidden. This + * method is typically called when the widget is in readonly, to hide it + * (and its label) if it is empty. + * + * @returns {boolean} + */ + isEmpty: function () { + return !this.isSet(); + }, + /** + * Returns true iff the widget has a visible element that can take the focus + * + * @returns {boolean} + */ + isFocusable: function () { + var $focusable = this.getFocusableElement(); + return $focusable.length && $focusable.is(':visible'); + }, + /** + * this method is used to determine if the field value is set to a meaningful + * value. This is useful to determine if a field should be displayed as empty + * + * @returns {boolean} + */ + isSet: function () { + return !!this.value; + }, + /** + * A field widget is valid if it was checked as valid the last time its + * value was changed by the user. This is checked before saving a record, by + * the view. + * + * Note: this is the responsibility of the view to check that required + * fields have a set value. + * + * @returns {boolean} true/false if the widget is valid + */ + isValid: function () { + return this._isValid; + }, + /** + * this method is supposed to be called from the outside of field widgets. + * The typical use case is when an onchange has changed the widget value. + * It will reset the widget to the values that could have changed, then will + * rerender the widget. + * + * @param {any} record + * @param {OdooEvent} [event] an event that triggered the reset action. It + * is optional, and may be used by a widget to share information from the + * moment a field change event is triggered to the moment a reset + * operation is applied. + * @returns {Promise} A promise, which resolves when the widget rendering + * is complete + */ + reset: function (record, event) { + this._reset(record, event); + return this._render() || Promise.resolve(); + }, + /** + * Remove the invalid class on a field + */ + removeInvalidClass: function () { + this.$el.removeClass('o_field_invalid'); + this.$el.removeAttr('aria-invalid'); + }, + /** + * Sets the given id on the focusable element of the field and as 'for' + * attribute of potential internal labels. + * + * @param {string} id + */ + setIDForLabel: function (id) { + this.getFocusableElement().attr('id', id); + }, + /** + * add the invalid class on a field + */ + setInvalidClass: function () { + this.$el.addClass('o_field_invalid'); + this.$el.attr('aria-invalid', 'true'); + }, + /** + * Update the modifiers with the newest value. + * Now this.attrs.modifiersValue can be used consistantly even with + * conditional modifiers inside field widgets, and without needing new + * events or synchronization between the widgets, renderer and controller + * + * @param {Object | null} modifiers the updated modifiers + * @override + */ + updateModifiersValue: function(modifiers) { + this.attrs.modifiersValue = modifiers || {}; + }, + + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply field decorations (only if field-specific decorations have been + * defined in an attribute). + * + * @private + */ + _applyDecorations: function () { + var self = this; + this.attrs.decorations.forEach(function (dec) { + var isToggled = py.PY_isTrue( + py.evaluate(dec.expression, self.record.evalContext) + ); + const className = self._getClassFromDecoration(dec.name); + self.$el.toggleClass(className, isToggled); + }); + }, + /** + * Converts the value from the field to a string representation. + * + * @private + * @param {any} value (from the field type) + * @param {string} [formatType=this.formatType] the formatter to use + * @returns {string} + */ + _formatValue: function (value, formatType) { + var options = _.extend({}, this.nodeOptions, { data: this.recordData }, this.formatOptions); + return field_utils.format[formatType || this.formatType](value, this.field, options); + }, + /** + * Returns the className corresponding to a given decoration. A + * decoration is of the form 'decoration-%s'. By default, replaces + * 'decoration' by 'text'. + * + * @private + * @param {string} decoration must be of the form 'decoration-%s' + * @returns {string} + */ + _getClassFromDecoration: function (decoration) { + return `text-${decoration.split('-')[1]}`; + }, + /** + * Compares the given value with the last value that has been set. + * Note that we compare unparsed values. Handles the special case where no + * value has been set yet, and the given value is the empty string. + * + * @private + * @param {any} value + * @returns {boolean} true iff values are the same + */ + _isLastSetValue: function (value) { + return this.lastSetValue === value || (this.value === false && value === ''); + }, + /** + * This method check if a value is the same as the current value of the + * field. For example, a fieldDate widget might want to use the moment + * specific value isSame instead of ===. + * + * This method is used by the _setValue method. + * + * @private + * @param {any} value + * @returns {boolean} + */ + _isSameValue: function (value) { + return this.value === value; + }, + /** + * Converts a string representation to a valid value. + * + * @private + * @param {string} value + * @returns {any} + */ + _parseValue: function (value) { + return field_utils.parse[this.formatType](value, this.field, this.parseOptions); + }, + /** + * main rendering function. Override this if your widget has the same render + * for each mode. Note that this function is supposed to be idempotent: + * the result of calling 'render' twice is the same as calling it once. + * Also, the user experience will be better if your rendering function is + * synchronous. + * + * @private + * @returns {Promise|undefined} + */ + _render: function () { + if (this.attrs.decorations) { + this._applyDecorations(); + } + if (this.mode === 'edit') { + return this._renderEdit(); + } else if (this.mode === 'readonly') { + return this._renderReadonly(); + } + }, + /** + * Render the widget in edit mode. The actual implementation is left to the + * concrete widget. + * + * @private + * @returns {Promise|undefined} + */ + _renderEdit: function () { + }, + /** + * Render the widget in readonly mode. The actual implementation is left to + * the concrete widget. + * + * @private + * @returns {Promise|undefined} + */ + _renderReadonly: function () { + }, + /** + * pure version of reset, can be overridden, called before render() + * + * @private + * @param {any} record + * @param {OdooEvent} event the event that triggered the change + */ + _reset: function (record, event) { + this.lastSetValue = undefined; + this.record = record; + this.value = record.data[this.name]; + this.recordData = record.data; + }, + /** + * this method is called by the widget, to change its value and to notify + * the outside world of its new state. This method also validates the new + * value. Note that this method does not rerender the widget, it should be + * handled by the widget itself, if necessary. + * + * @private + * @param {any} value + * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if true, the basic model + * will not consider that this field is dirty, even though it was changed. + * Please do not use this flag unless you really need it. Our only use + * case is currently the pad widget, which does a _setValue in the + * renderEdit method. + * @param {boolean} [options.notifyChange=true] if false, the basic model + * will not notify and not trigger the onchange, even though it was changed. + * @param {boolean} [options.forceChange=false] if true, the change event will be + * triggered even if the new value is the same as the old one + * @returns {Promise} + */ + _setValue: function (value, options) { + // we try to avoid doing useless work, if the value given has not changed. + if (this._isLastSetValue(value)) { + return Promise.resolve(); + } + this.lastSetValue = value; + try { + value = this._parseValue(value); + this._isValid = true; + } catch (e) { + this._isValid = false; + this.trigger_up('set_dirty', {dataPointID: this.dataPointID}); + return Promise.reject({message: "Value set is not valid"}); + } + if (!(options && options.forceChange) && this._isSameValue(value)) { + return Promise.resolve(); + } + var self = this; + return new Promise(function (resolve, reject) { + var changes = {}; + changes[self.name] = value; + self.trigger_up('field_changed', { + dataPointID: self.dataPointID, + changes: changes, + viewType: self.viewType, + doNotSetDirty: options && options.doNotSetDirty, + notifyChange: !options || options.notifyChange !== false, + allowWarning: options && options.allowWarning, + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Intercepts navigation keyboard events to prevent their default behavior + * and notifies the view so that it can handle it its own way. + * + * Note: the navigation keyboard events are stopped so that potential parent + * abstract field does not trigger the navigation_move event a second time. + * However, this might be controversial, we might wanna let the event + * continue its propagation and flag it to say that navigation has already + * been handled (TODO ?). + * + * @private + * @param {KeyEvent} ev + */ + _onKeydown: function (ev) { + switch (ev.which) { + case $.ui.keyCode.TAB: + var event = this.trigger_up('navigation_move', { + direction: ev.shiftKey ? 'previous' : 'next', + }); + if (event.is_stopped()) { + ev.preventDefault(); + ev.stopPropagation(); + } + break; + case $.ui.keyCode.ENTER: + // We preventDefault the ENTER key because of two coexisting behaviours: + // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click' + // - When creating and opening a dialog, the focus is automatically given to the primary button + // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving + // changes in multiple edition) confirmed the modal without any intentionnal user input. + ev.preventDefault(); + ev.stopPropagation(); + this.trigger_up('navigation_move', {direction: 'next_line'}); + break; + case $.ui.keyCode.ESCAPE: + this.trigger_up('navigation_move', {direction: 'cancel', originalEvent: ev}); + break; + case $.ui.keyCode.UP: + ev.stopPropagation(); + this.trigger_up('navigation_move', {direction: 'up'}); + break; + case $.ui.keyCode.RIGHT: + ev.stopPropagation(); + this.trigger_up('navigation_move', {direction: 'right'}); + break; + case $.ui.keyCode.DOWN: + ev.stopPropagation(); + this.trigger_up('navigation_move', {direction: 'down'}); + break; + case $.ui.keyCode.LEFT: + ev.stopPropagation(); + this.trigger_up('navigation_move', {direction: 'left'}); + break; + } + }, + /** + * Updates the target data value with the current AbstractField instance. + * This allows to consider the parent field in case of nested fields. The + * field which triggered the event is still accessible through ev.target. + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + ev.data.target = this; + }, +}); + +return AbstractField; + +}); diff --git a/addons/web/static/src/js/fields/abstract_field_owl.js b/addons/web/static/src/js/fields/abstract_field_owl.js new file mode 100644 index 00000000..43e3d468 --- /dev/null +++ b/addons/web/static/src/js/fields/abstract_field_owl.js @@ -0,0 +1,648 @@ +odoo.define('web.AbstractFieldOwl', function (require) { + "use strict"; + + const field_utils = require('web.field_utils'); + const { useListener } = require('web.custom_hooks'); + + const { onMounted, onPatched } = owl.hooks; + + /** + * This file defines the Owl version of the AbstractField. Specific fields + * written in Owl should override this component. + * + * ========================================================================= + * + * /!\ This api works almost exactly like the legacy one but + * /!\ it still could change! There are already a few methods that will be + * /!\ removed like setIdForLabel, setInvalidClass, etc.. + * + * ========================================================================= + * + * This is the basic field component used by all the views to render a field in a view. + * These field components are mostly common to all views, in particular form and list + * views. + * + * The responsabilities of a field component are mainly: + * - render a visual representation of the current value of a field + * - that representation is either in 'readonly' or in 'edit' mode + * - notify the rest of the system when the field has been changed by + * the user (in edit mode) + * + * Notes + * - the component is not supposed to be able to switch between modes. If another + * mode is required, the view will take care of instantiating another component. + * - notify the system when its value has changed and its mode is changed to 'readonly' + * - notify the system when some action has to be taken, such as opening a record + * - the Field component should not, ever, under any circumstance, be aware of + * its parent. The way it communicates changes with the rest of the system is by + * triggering events. These events bubble up and are interpreted + * by the most appropriate parent. + * + * Also, in some cases, it may not be practical to have the same component for all + * views. In that situation, you can have a 'view specific component'. Just register + * the component in the registry prefixed by the view type and a dot. So, for example, + * a form specific many2one component should be registered as 'form.many2one'. + * + * @module web.AbstractFieldOwl + */ + class AbstractField extends owl.Component { + /** + * Abstract field class + * + * @constructor + * @param {Component} parent + * @param {Object} props + * @param {string} props.fieldName The field name defined in the model + * @param {Object} props.record A record object (result of the get method + * of a basic model) + * @param {Object} [props.options] + * @param {string} [props.options.mode=readonly] should be 'readonly' or 'edit' + * @param {string} [props.options.viewType=default] + */ + constructor() { + super(...arguments); + + this._isValid = true; + // this is the last value that was set by the user, unparsed. This is + // used to avoid setting the value twice in a row with the exact value. + this._lastSetValue = undefined; + + useListener('keydown', this._onKeydown); + useListener('navigation-move', this._onNavigationMove); + onMounted(() => this._applyDecorations()); + onPatched(() => this._applyDecorations()); + } + /** + * Hack: studio tries to find the field with a selector base on its + * name, before it is mounted into the DOM. Ideally, this should be + * done in the onMounted hook, but in this case we are too late, and + * Studio finds nothing. As a consequence, the field can't be edited + * by clicking on its label (or on the row formed by the pair label-field). + * + * TODO: move this to mounted at some point? + * + * @override + */ + __patch() { + const res = super.__patch(...arguments); + this.el.setAttribute('name', this.name); + this.el.classList.add('o_field_widget'); + return res; + } + /** + * @async + * @param {Object} [nextProps] + * @returns {Promise} + */ + async willUpdateProps(nextProps) { + this._lastSetValue = undefined; + return super.willUpdateProps(nextProps); + } + + //---------------------------------------------------------------------- + // Getters + //---------------------------------------------------------------------- + + /** + * This contains the attributes to pass through the context. + * + * @returns {Object} + */ + get additionalContext() { + return this.options.additionalContext || {}; + } + /** + * This contains the attributes of the xml 'field' tag, the inner views... + * + * @returns {Object} + */ + get attrs() { + const fieldsInfo = this.record.fieldsInfo[this.viewType]; + return this.options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {}; + } + /** + * Id corresponding to the current record in the model. + * Its intended use is to be able to tag any messages going upstream, + * so the view knows which records was changed for example. + * + * @returns {string} + */ + get dataPointId() { + return this.record.id; + } + /** + * This is a description of all the various field properties, + * such as the type, the comodel (relation), ... + * + * @returns {string} + */ + get field() { + return this.record.fields[this.name]; + } + /** + * Returns the main field's DOM element which can be focused by the browser. + * + * @returns {HTMLElement|null} main focusable element inside the component + */ + get focusableElement() { + return null; + } + /** + * Returns the additional options pass to the format function. + * Override this getter to add options. + * + * @returns {Object} + */ + get formatOptions() { + return {}; + } + /** + * Used to determine which format (and parse) functions + * to call to format the field's value to insert into the DOM (typically + * put into a span or an input), and to parse the value from the input + * to send it to the server. These functions are chosen according to + * the 'widget' attrs if is is given, and if it is a valid key, with a + * fallback on the field type, ensuring that the value is formatted and + * displayed according to the chosen widget, if any. + * + * @returns {string} + */ + get formatType() { + return this.attrs.widget in field_utils.format ? + this.attrs.widget : this.field.type; + } + /** + * Returns whether or not the field is empty and can thus be hidden. This + * method is typically called when the component is in readonly, to hide it + * (and its label) if it is empty. + * + * @returns {boolean} + */ + get isEmpty() { + return !this.isSet; + } + /** + * Returns true if the component has a visible element that can take the focus + * + * @returns {boolean} + */ + get isFocusable() { + const focusable = this.focusableElement; + // check if element is visible + return focusable && !!(focusable.offsetWidth || + focusable.offsetHeight || focusable.getClientRects().length); + } + /** + * Determines if the field value is set to a meaningful + * value. This is useful to determine if a field should be displayed as empty + * + * @returns {boolean} + */ + get isSet() { + return !!this.value; + } + /** + * Tracks if the component is in a valid state, meaning that the current + * value represented in the DOM is a value that can be parsed and saved. + * For example, a float field can only use a number and not a string. + * + * @returns {boolean} + */ + get isValid() { + return this._isValid; + } + /** + * Fields can be in two modes: 'edit' or 'readonly'. + * + * @returns {string} + */ + get mode() { + return this.options.mode || "readonly"; + } + /** + * Useful mostly to trigger rpcs on the correct model. + * + * @returns {string} + */ + get model() { + return this.record.model; + } + /** + * The field name displayed by this component. + * + * @returns {string} + */ + get name() { + return this.props.fieldName; + } + /** + * Component can often be configured in the 'options' attribute in the + * xml 'field' tag. These options are saved (and evaled) in nodeOptions. + * + * @returns {Object} + */ + get nodeOptions() { + return this.attrs.options || {}; + } + /** + * @returns {Object} + */ + get options() { + return this.props.options || {}; + } + /** + * Returns the additional options passed to the parse function. + * Override this getter to add options. + * + * @returns {Object} + */ + get parseOptions() { + return {}; + } + /** + * The datapoint fetched from the model. + * + * @returns {Object} + */ + get record() { + return this.props.record; + } + /** + * Tracks the values for the other fields for the same record. + * note that it is expected to be mostly a readonly property, you cannot + * use this to try to change other fields value, this is not how it is + * supposed to work. Also, do not use this.recordData[this.name] to get + * the current value, this could be out of sync after a _setValue. + * + * @returns {Object} + */ + get recordData() { + return this.record.data; + } + /** + * If this flag is set to true, the field component will be reset on + * every change which is made in the view (if the view supports it). + * This is currently a form view feature. + * + * /!\ This getter could be removed when basic views (form, list, kanban) + * are converted. + * + * @returns {boolean} + */ + get resetOnAnyFieldChange() { + return !!this.attrs.decorations; + } + /** + * The res_id of the record in database. + * When the user is creating a new record, there is no res_id. + * When the record will be created, the field component will + * be destroyed (when the form view switches to readonly mode) and a + * new component with a res_id in mode readonly will be created. + * + * @returns {Number} + */ + get resId() { + return this.record.res_id; + } + /** + * Human readable (and translated) description of the field. + * Mostly useful to be displayed in various places in the + * UI, such as tooltips or create dialogs. + * + * @returns {string} + */ + get string() { + return this.attrs.string || this.field.string || this.name; + } + /** + * Tracks the current (parsed if needed) value of the field. + * + * @returns {any} + */ + get value() { + return this.record.data[this.name]; + } + /** + * The type of the view in which the field component is instantiated. + * For standalone components, a 'default' viewType is set. + * + * @returns {string} + */ + get viewType() { + return this.options.viewType || 'default'; + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Activates the field component. By default, activation means focusing and + * selecting (if possible) the associated focusable element. The selecting + * part can be disabled. In that case, note that the focused input/textarea + * will have the cursor at the very end. + * + * @param {Object} [options] + * @param {boolean} [options.noselect=false] if false and the input + * is of type text or textarea, the content will also be selected + * @param {Event} [options.event] the event which fired this activation + * @returns {boolean} true if the component was activated, false if the + * focusable element was not found or invisible + */ + activate(options) { + if (this.isFocusable) { + const focusable = this.focusableElement; + focusable.focus(); + if (focusable.matches('input[type="text"], textarea')) { + focusable.selectionStart = focusable.selectionEnd = focusable.value.length; + if (options && !options.noselect) { + focusable.select(); + } + } + return true; + } + return false; + } + /** + * This function should be implemented by components that are not able to + * notify their environment when their value changes (maybe because their + * are not aware of the changes) or that may have a value in a temporary + * state (maybe because some action should be performed to validate it + * before notifying it). This is typically called before trying to save the + * component's value, so it should call _setValue() to notify the environment + * if the value changed but was not notified. + * + * @abstract + * @returns {Promise|undefined} + */ + commitChanges() {} + /** + * Remove the invalid class on a field + * + * This function should be removed when BasicRenderer will be rewritten in owl + */ + removeInvalidClass() { + this.el.classList.remove('o_field_invalid'); + this.el.removeAttribute('aria-invalid'); + } + /** + * Sets the given id on the focusable element of the field and as 'for' + * attribute of potential internal labels. + * + * This function should be removed when BasicRenderer will be rewritten in owl + * + * @param {string} id + */ + setIdForLabel(id) { + if (this.focusableElement) { + this.focusableElement.setAttribute('id', id); + } + } + /** + * add the invalid class on a field + * + * This function should be removed when BasicRenderer will be rewritten in owl + */ + setInvalidClass() { + this.el.classList.add('o_field_invalid'); + this.el.setAttribute('aria-invalid', 'true'); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Apply field decorations (only if field-specific decorations have been + * defined in an attribute). + * + * This function should be removed when BasicRenderer will be rewritten in owl + * + * @private + */ + _applyDecorations() { + for (const dec of this.attrs.decorations || []) { + const isToggled = py.PY_isTrue( + py.evaluate(dec.expression, this.record.evalContext) + ); + const className = this._getClassFromDecoration(dec.name); + this.el.classList.toggle(className, isToggled); + } + } + /** + * Converts the value from the field to a string representation. + * + * @private + * @param {any} value (from the field type) + * @returns {string} + */ + _formatValue(value) { + const options = Object.assign({}, this.nodeOptions, + { data: this.recordData }, this.formatOptions); + return field_utils.format[this.formatType](value, this.field, options); + } + /** + * Returns the className corresponding to a given decoration. A + * decoration is of the form 'decoration-%s'. By default, replaces + * 'decoration' by 'text'. + * + * @private + * @param {string} decoration must be of the form 'decoration-%s' + * @returns {string} + */ + _getClassFromDecoration(decoration) { + return `text-${decoration.split('-')[1]}`; + } + /** + * Compares the given value with the last value that has been set. + * Note that we compare unparsed values. Handles the special case where no + * value has been set yet, and the given value is the empty string. + * + * @private + * @param {any} value + * @returns {boolean} true iff values are the same + */ + _isLastSetValue(value) { + return this._lastSetValue === value || (this.value === false && value === ''); + } + /** + * This method check if a value is the same as the current value of the + * field. For example, a fieldDate component might want to use the moment + * specific value isSame instead of ===. + * + * This method is used by the _setValue method. + * + * @private + * @param {any} value + * @returns {boolean} + */ + _isSameValue(value) { + return this.value === value; + } + /** + * Converts a string representation to a valid value. + * + * @private + * @param {string} value + * @returns {any} + */ + _parseValue(value) { + return field_utils.parse[this.formatType](value, this.field, this.parseOptions); + } + /** + * This method is called by the component, to change its value and to notify + * the outside world of its new state. This method also validates the new + * value. Note that this method does not rerender the component, it should be + * handled by the component itself, if necessary. + * + * @private + * @param {any} value + * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if true, the basic model + * will not consider that this field is dirty, even though it was changed. + * Please do not use this flag unless you really need it. Our only use + * case is currently the pad component, which does a _setValue in the + * renderEdit method. + * @param {boolean} [options.notifyChange=true] if false, the basic model + * will not notify and not trigger the onchange, even though it was changed. + * @param {boolean} [options.forceChange=false] if true, the change event will be + * triggered even if the new value is the same as the old one + * @returns {Promise} + */ + _setValue(value, options) { + // we try to avoid doing useless work, if the value given has not changed. + if (this._isLastSetValue(value)) { + return Promise.resolve(); + } + this._lastSetValue = value; + try { + value = this._parseValue(value); + this._isValid = true; + } catch (e) { + this._isValid = false; + this.trigger('set-dirty', {dataPointID: this.dataPointId}); + return Promise.reject({message: "Value set is not valid"}); + } + if (!(options && options.forceChange) && this._isSameValue(value)) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const changes = {}; + changes[this.name] = value; + this.trigger('field-changed', { + dataPointID: this.dataPointId, + changes: changes, + viewType: this.viewType, + doNotSetDirty: options && options.doNotSetDirty, + notifyChange: !options || options.notifyChange !== false, + allowWarning: options && options.allowWarning, + onSuccess: resolve, + onFailure: reject, + }); + }); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Intercepts navigation keyboard events to prevent their default behavior + * and notifies the view so that it can handle it its own way. + * + * @private + * @param {KeyEvent} ev + */ + _onKeydown(ev) { + switch (ev.which) { + case $.ui.keyCode.TAB: + this.trigger('navigation-move', { + direction: ev.shiftKey ? 'previous' : 'next', + originalEvent: ev, + }); + break; + case $.ui.keyCode.ENTER: + // We preventDefault the ENTER key because of two coexisting behaviours: + // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click' + // - When creating and opening a dialog, the focus is automatically given to the primary button + // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving + // changes in multiple edition) confirmed the modal without any intentionnal user input. + ev.preventDefault(); + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'next_line'}); + break; + case $.ui.keyCode.ESCAPE: + this.trigger('navigation-move', {direction: 'cancel', originalEvent: ev}); + break; + case $.ui.keyCode.UP: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'up'}); + break; + case $.ui.keyCode.RIGHT: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'right'}); + break; + case $.ui.keyCode.DOWN: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'down'}); + break; + case $.ui.keyCode.LEFT: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'left'}); + break; + } + } + /** + * Updates the target data value with the current AbstractField instance. + * This allows to consider the parent field in case of nested fields. The + * field which triggered the event is still accessible through ev.target. + * + * @private + * @param {CustomEvent} ev + */ + _onNavigationMove(ev) { + ev.detail.target = this; + } + } + + /** + * An object representing fields to be fetched by the model even though not + * present in the view. + * This object contains "field name" as key and an object as value. + * That value object must contain the key "type" + * @see FieldBinaryImage for an example. + */ + AbstractField.fieldDependencies = {}; + /** + * If this flag is given a string, the related BasicModel will be used to + * initialize specialData the field might need. This data will be available + * through this.record.specialData[this.name]. + * + * @see BasicModel._fetchSpecialData + */ + AbstractField.specialData = false; + /** + * to override to indicate which field types are supported by the component + * + * @type Array<string> + */ + AbstractField.supportedFieldTypes = []; + /** + * To override to give a user friendly name to the component. + * + * @type string + */ + AbstractField.description = ""; + /** + * Currently only used in list view. + * If this flag is set to true, the list column name will be empty. + */ + AbstractField.noLabel = false; + /** + * Currently only used in list view. + * If set, this value will be displayed as column name. + */ + AbstractField.label = ""; + + return AbstractField; +}); diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js new file mode 100644 index 00000000..d80bb933 --- /dev/null +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -0,0 +1,3757 @@ +odoo.define('web.basic_fields', function (require) { +"use strict"; + +/** + * This module contains most of the basic (meaning: non relational) field + * widgets. Field widgets are supposed to be used in views inheriting from + * BasicView, so, they can work with the records obtained from a BasicModel. + */ + +var AbstractField = require('web.AbstractField'); +var config = require('web.config'); +var core = require('web.core'); +var datepicker = require('web.datepicker'); +var deprecatedFields = require('web.basic_fields.deprecated'); +var dom = require('web.dom'); +var Domain = require('web.Domain'); +var DomainSelector = require('web.DomainSelector'); +var DomainSelectorDialog = require('web.DomainSelectorDialog'); +var framework = require('web.framework'); +var py_utils = require('web.py_utils'); +var session = require('web.session'); +var utils = require('web.utils'); +var view_dialogs = require('web.view_dialogs'); +var field_utils = require('web.field_utils'); +var time = require('web.time'); +const {ColorpickerDialog} = require('web.Colorpicker'); + +let FieldBoolean = deprecatedFields.FieldBoolean; + +require("web.zoomodoo"); + +var qweb = core.qweb; +var _t = core._t; +var _lt = core._lt; + +var TranslatableFieldMixin = { + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {jQuery} + */ + _renderTranslateButton: function () { + if (_t.database.multi_lang && this.field.translate) { + var lang = _t.database.parameters.code.split('_')[0].toUpperCase(); + return $(`<span class="o_field_translate btn btn-link">${lang}</span>`) + .on('click', this._onTranslate.bind(this)); + } + return $(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * open the translation view for the current field + * + * @param {MouseEvent} ev + * @private + */ + _onTranslate: function (ev) { + ev.preventDefault(); + this.trigger_up('translate', { + fieldName: this.name, + id: this.dataPointID, + isComingFromTranslationAlert: false, + }); + }, +}; + +var DebouncedField = AbstractField.extend({ + /** + * For field widgets that may have a large number of field changes quickly, + * it could be a good idea to debounce the changes. In that case, this is + * the suggested value. + */ + DEBOUNCE: 1000000000, + + /** + * Override init to debounce the field "_doAction" method (by creating a new + * one called "_doDebouncedAction"). By default, this method notifies the + * current value of the field and we do not want that to happen for each + * keystroke. Note that this is done here and not on the prototype, so that + * each DebouncedField has its own debounced function to work with. Also, if + * the debounce value is set to 0, no debouncing is done, which is really + * useful for the unit tests. + * + * @constructor + * @override + */ + init: function () { + this._super.apply(this, arguments); + + // _isDirty is used to detect that the user interacted at least + // once with the widget, so that we can prevent it from triggering a + // field_changed in commitChanges if the user didn't change anything + this._isDirty = false; + if (this.mode === 'edit') { + if (this.DEBOUNCE) { + this._doDebouncedAction = _.debounce(this._doAction, this.DEBOUNCE); + } else { + this._doDebouncedAction = this._doAction; + } + + var self = this; + var debouncedFunction = this._doDebouncedAction; + this._doDebouncedAction = function () { + self._isDirty = true; + debouncedFunction.apply(self, arguments); + }; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This field main action is debounced and might sets the field's value. + * When the changes are asked to be commited, the debounced action has to + * be done immediately. + * + * @override + */ + commitChanges: function () { + if (this._isDirty && this.mode === 'edit') { + return this._doAction(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * By default, notifies the outside world of the new value (checked from the + * DOM). This method has an automatically-created (@see init) associated + * debounced version called _doDebouncedAction. + * + * @private + */ + _doAction: function () { + // as _doAction may be debounced, it may happen that it is called after + // the widget has been destroyed, and in this case, we don't want it to + // do anything (commitChanges ensures that if it has local changes, they + // are triggered up before the widget is destroyed, if necessary). + if (!this.isDestroyed()) { + return this._setValue(this._getValue()); + } + }, + /** + * Should return the current value of the field, in the DOM (for example, + * the content of the input) + * + * @abstract + * @private + * @returns {*} + */ + _getValue: function () {}, + /** + * Should make an action on lost focus. + * + * @abstract + * @private + * @returns {*} + */ + _onBlur: function () {}, +}); + +var InputField = DebouncedField.extend({ + custom_events: _.extend({}, DebouncedField.prototype.custom_events, { + field_changed: '_onFieldChanged', + }), + events: _.extend({}, DebouncedField.prototype.events, { + 'input': '_onInput', + 'change': '_onChange', + 'blur' : '_onBlur', + }), + + /** + * Prepares the rendering so that it creates an element the user can type + * text into in edit mode. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.nodeOptions.isPassword = 'password' in this.attrs; + if (this.mode === 'edit') { + this.tagName = 'input'; + } + // We need to know if the widget is dirty (i.e. if the user has changed + // the value, and those changes haven't been acknowledged yet by the + // environment), to prevent erasing that new value on a reset (e.g. + // coming by an onchange on another field) + this.isDirty = false; + this.lastChangeEvent = undefined; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the associated <input/> element. + * + * @override + */ + getFocusableElement: function () { + return this.$input || $(); + }, + /** + * Re-renders the widget if it isn't dirty. The widget is dirty if the user + * changed the value, and that change hasn't been acknowledged yet by the + * environment. For example, another field with an onchange has been updated + * and this field is updated before the onchange returns. Two '_setValue' + * are done (this is sequential), the first one returns and this widget is + * reset. However, it has pending changes, so we don't re-render. + * + * @override + */ + reset: function (record, event) { + this._reset(record, event); + if (!event || event === this.lastChangeEvent) { + this.isDirty = false; + } + if (this.isDirty || (event && event.target === this && + event.data.changes && + event.data.changes[this.name] === this.value)) { + if (this.attrs.decorations) { + // if a field is modified, then it could have triggered an onchange + // which changed some of its decorations. Since we bypass the + // render function, we need to apply decorations here to make + // sure they are recomputed. + this._applyDecorations(); + } + return Promise.resolve(); + } else { + return this._render(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {string} the content of the input + */ + _getValue: function () { + return this.$input.val(); + }, + /** + * Formats an input element for edit mode. This is in a separate function so + * extending widgets can use it on their input without having input as tagName. + * + * @private + * @param {jQuery|undefined} $input + * The <input/> element to prepare and save as the $input attribute. + * If no element is given, the <input/> is created. + * @returns {jQuery} the prepared this.$input element + */ + _prepareInput: function ($input) { + this.$input = $input || $("<input/>"); + this.$input.addClass('o_input'); + + var inputAttrs = { placeholder: this.attrs.placeholder || "" }; + var inputVal; + if (this.nodeOptions.isPassword) { + inputAttrs = _.extend(inputAttrs, { type: 'password', autocomplete: this.attrs.autocomplete || 'new-password' }); + inputVal = this.value || ''; + } else { + inputAttrs = _.extend(inputAttrs, { type: 'text', autocomplete: this.attrs.autocomplete || 'off'}); + inputVal = this._formatValue(this.value); + } + + this.$input.attr(inputAttrs); + this.$input.val(inputVal); + + return this.$input; + }, + /** + * Formats the HTML input tag for edit mode and stores selection status. + * + * @override + * @private + */ + _renderEdit: function () { + // Keep a reference to the input so $el can become something else + // without losing track of the actual input. + this._prepareInput(this.$el); + }, + /** + * Resets the content to the formated value in readonly mode. + * + * @override + * @private + */ + _renderReadonly: function () { + this.$el.text(this._formatValue(this.value)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * We immediately notify the outside world when this field confirms its + * changes. + * + * @private + */ + _onChange: function () { + this._doAction(); + }, + /** + * Listens to events 'field_changed' to keep track of the last event that + * has been trigerred. This allows to detect that all changes have been + * acknowledged by the environment. + * + * @param {OdooEvent} event 'field_changed' event + */ + _onFieldChanged: function (event) { + this.lastChangeEvent = event; + }, + /** + * Called when the user is typing text -> By default this only calls a + * debounced method to notify the outside world of the changes. + * @see _doDebouncedAction + * + * @private + */ + _onInput: function () { + this.isDirty = !this._isLastSetValue(this.$input.val()); + this._doDebouncedAction(); + }, + /** + * Stops the left/right navigation move event if the cursor is not at the + * start/end of the input element. + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + this._super.apply(this, arguments); + + // the following code only makes sense in edit mode, with an input + if (this.mode === 'edit' && ev.data.direction !== 'cancel') { + var input = this.$input[0]; + var selecting = (input.selectionEnd !== input.selectionStart); + if ((ev.data.direction === "left" && (selecting || input.selectionStart !== 0)) + || (ev.data.direction === "right" && (selecting || input.selectionStart !== input.value.length))) { + ev.stopPropagation(); + } + if (ev.data.direction ==='next' && + this.attrs.modifiersValue && + this.attrs.modifiersValue.required && + this.viewType !== 'list') { + if (!this.$input.val()){ + this.setInvalidClass(); + ev.stopPropagation(); + } else { + this.removeInvalidClass(); + } + } + } + }, +}); + +var NumericField = InputField.extend({ + tagName: 'span', + + /** + * @override + */ + init() { + this._super.apply(this, arguments); + this.shouldFormat = Boolean( + JSON.parse('format' in this.nodeOptions ? this.nodeOptions.format : true) + ); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * For numeric fields, 0 is a valid value. + * + * @override + */ + isSet: function () { + return this.value === 0 || this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Evaluate a string representing a simple formula, + * a formula is composed of numbers and arithmetic operations + * (ex: 4+3*2) + * + * Supported arithmetic operations: + - * / ^ ( ) + * Since each number in the formula can be expressed in user locale, + * we parse each float value inside the formula using the user context + * This function uses py_eval to safe eval the formula. + * We assume that this function is used as a calculator so operand ^ (xor) + * is replaced by operand ** (power) so that users that are used to + * excel or libreoffice are not confused + * + * @private + * @param expr + * @return a float representing the result of the evaluated formula + * @throws error if formula can't be evaluated + */ + _evalFormula: function (expr, context) { + // remove extra space + var val = expr.replace(new RegExp(/( )/g), ''); + var safeEvalString = ''; + for (let v of val.split(new RegExp(/([-+*/()^])/g))) { + if (!['+','-','*','/','(',')','^'].includes(v) && v.length) { + // check if this is a float and take into account user delimiter preference + v = field_utils.parse.float(v); + } + if (v === '^') { + v = '**'; + } + safeEvalString += v; + }; + return py_utils.py_eval(safeEvalString, context); + }, + + /** + * Format numerical value (integer or float) + * + * Note: We have to overwrite this method to skip the format if we are into + * edit mode on a input type number. + * + * @override + * @private + */ + _formatValue: function (value) { + if (!this.shouldFormat || (this.mode === 'edit' && this.nodeOptions.type === 'number')) { + return value; + } + return this._super.apply(this, arguments); + }, + + /** + * Parse numerical value (integer or float) + * + * Note: We have to overwrite this method to skip the format if we are into + * edit mode on a input type number. + * + * @override + * @private + */ + _parseValue: function (value) { + if (this.mode === 'edit' && this.nodeOptions.type === 'number') { + return Number(value); + } + return this._super.apply(this, arguments); + }, + + /** + * Formats an input element for edit mode. This is in a separate function so + * extending widgets can use it on their input without having input as tagName. + * + * Note: We have to overwrite this method to set the input's type to number if + * option setted into the field. + * + * @override + * @private + */ + _prepareInput: function ($input) { + var result = this._super.apply(this, arguments); + if (this.nodeOptions.type === 'number') { + this.$input.attr({type: 'number'}); + } + if (this.nodeOptions.step) { + this.$input.attr({step: this.nodeOptions.step}); + } + return result; + }, + + /** + * Evaluate value set by user if starts with = + * + * @override + * @private + * @param {any} value + * @param {Object} [options] + */ + _setValue: function (value, options) { + var originalValue = value; + value = value.trim(); + if (value.startsWith('=')) { + try { + // Evaluate the formula + value = this._evalFormula(value.substr(1)); + // Format back the value in user locale + value = this._formatValue(value); + // Set the computed value in the input + this.$input.val(value); + } catch (err) { + // in case of exception, set value as the original value + // that way the Webclient will show an error as + // it is expecting a numeric value. + value = originalValue; + } + } + return this._super(value, options); + }, +}); + +var FieldChar = InputField.extend(TranslatableFieldMixin, { + description: _lt("Text"), + className: 'o_field_char', + tagName: 'span', + supportedFieldTypes: ['char'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add translation button + * + * @override + * @private + */ + _renderEdit: function () { + var def = this._super.apply(this, arguments); + if (this.field.size && this.field.size > 0) { + this.$el.attr('maxlength', this.field.size); + } + if (this.field.translate) { + this.$el = this.$el.add(this._renderTranslateButton()); + this.$el.addClass('o_field_translate'); + } + return def; + }, + /** + * Trim the value input by the user. + * + * @override + * @private + * @param {any} value + * @param {Object} [options] + */ + _setValue: function (value, options) { + if (this.field.trim) { + value = value.trim(); + } + return this._super(value, options); + }, +}); + +var LinkButton = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'click': '_onClick' + }), + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Display button + * @override + * @private + */ + _render: function () { + if (this.value) { + var className = this.attrs.icon || 'fa-globe'; + + this.$el.html("<span role='img'/>"); + this.$el.addClass("fa "+ className); + this.$el.attr('title', this.value); + this.$el.attr('aria-label', this.value); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Open link button + * + * @private + * @param {MouseEvent} event + */ + _onClick: function (event) { + event.stopPropagation(); + window.open(this.value, '_blank'); + }, +}); + +var FieldDateRange = InputField.extend({ + className: 'o_field_date_range', + tagName: 'span', + jsLibs: [ + '/web/static/lib/daterangepicker/daterangepicker.js', + '/web/static/src/js/libs/daterangepicker.js', + ], + cssLibs: [ + '/web/static/lib/daterangepicker/daterangepicker.css', + ], + supportedFieldTypes: ['date', 'datetime'], + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.isDateField = this.formatType === 'date'; + this.dateRangePickerOptions = _.defaults( + {}, + this.nodeOptions.picker_options || {}, + { + timePicker: !this.isDateField, + timePicker24Hour: _t.database.parameters.time_format.search('%H') !== -1, + autoUpdateInput: false, + timePickerIncrement: 5, + locale: { + format: this.isDateField ? time.getLangDateFormat() : time.getLangDatetimeFormat(), + }, + } + ); + this.relatedEndDate = this.nodeOptions.related_end_date; + this.relatedStartDate = this.nodeOptions.related_start_date; + }, + /** + * @override + */ + destroy: function () { + if (this.$pickerContainer) { + this.$pickerContainer.remove(); + } + if (this._onScroll) { + window.removeEventListener('scroll', this._onScroll, true); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Field widget is valid if value entered can convered to date/dateime value + * while parsing input value to date/datetime throws error then widget considered + * invalid + * + * @override + */ + isValid: function () { + const value = this.mode === "readonly" ? this.value : this.$input.val(); + try { + return field_utils.parse[this.formatType](value, this.field, { timezone: true }) || true; + } catch (error) { + return false; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return the date written in the input, in UTC. + * + * @private + * @returns {Moment|false} + */ + _getValue: function () { + try { + // user may enter manual value in input and it may not be parsed as date/datetime value + this.removeInvalidClass(); + return field_utils.parse[this.formatType](this.$input.val(), this.field, { timezone: true }); + } catch (error) { + this.setInvalidClass(); + return false; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + * @param {Object} picker + */ + _applyChanges: function (ev, picker) { + var changes = {}; + var displayStartDate = field_utils.format[this.formatType](picker.startDate, {}, {timezone: false}); + var displayEndDate = field_utils.format[this.formatType](picker.endDate, {}, {timezone: false}); + var changedStartDate = picker.startDate; + var changedEndDate = picker.endDate; + if (this.isDateField) { + // In date mode, the library will give moment object of start and end date having + // time at 00:00:00. So, Odoo will consider it as UTC. To fix this added browser + // timezone offset in dates to get a correct selected date. + changedStartDate = picker.startDate.add(session.getTZOffset(picker.startDate), 'minutes'); + changedEndDate = picker.endDate.startOf('day').add(session.getTZOffset(picker.endDate), 'minutes'); + } + if (this.relatedEndDate) { + this.$el.val(displayStartDate); + changes[this.name] = this._parseValue(changedStartDate); + changes[this.relatedEndDate] = this._parseValue(changedEndDate); + } + if (this.relatedStartDate) { + this.$el.val(displayEndDate); + changes[this.name] = this._parseValue(changedEndDate); + changes[this.relatedStartDate] = this._parseValue(changedStartDate); + } + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + viewType: this.viewType, + changes: changes, + }); + }, + /** + * @override + */ + _renderEdit: function () { + this._super.apply(this, arguments); + var self = this; + var startDate; + var endDate; + if (this.relatedEndDate) { + startDate = this._formatValue(this.value); + endDate = this._formatValue(this.recordData[this.relatedEndDate]); + } + if (this.relatedStartDate) { + startDate = this._formatValue(this.recordData[this.relatedStartDate]); + endDate = this._formatValue(this.value); + } + this.dateRangePickerOptions.startDate = startDate || moment(); + this.dateRangePickerOptions.endDate = endDate || moment(); + + this.$el.daterangepicker(this.dateRangePickerOptions); + this.$el.on('apply.daterangepicker', this._applyChanges.bind(this)); + this.$el.on('show.daterangepicker', this._onDateRangePickerShow.bind(this)); + this.$el.on('hide.daterangepicker', this._onDateRangePickerHide.bind(this)); + this.$el.off('keyup.daterangepicker'); + this.$pickerContainer = this.$el.data('daterangepicker').container; + + // Prevent from leaving the edition of a row in editable list view + this.$pickerContainer.on('click', function (ev) { + ev.stopPropagation(); + if ($(ev.target).hasClass('applyBtn')) { + self.$el.data('daterangepicker').hide(); + } + }); + + // Prevent bootstrap from focusing on modal (which breaks hours drop-down in firefox) + this.$pickerContainer.on('focusin.bs.modal', 'select', function (ev) { + ev.stopPropagation(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Unbind the scroll event handler when the daterangepicker is closed. + * + * @private + */ + _onDateRangePickerHide() { + if (this._onScroll) { + window.removeEventListener('scroll', this._onScroll, true); + } + }, + /** + * Bind the scroll event handle when the daterangepicker is open. + * + * @private + */ + _onDateRangePickerShow() { + this._onScroll = ev => { + if (!config.device.isMobile && !this.$pickerContainer.get(0).contains(ev.target)) { + this.$el.data('daterangepicker').hide(); + } + }; + window.addEventListener('scroll', this._onScroll, true); + }, +}); + +var FieldDate = InputField.extend({ + description: _lt("Date"), + className: "o_field_date", + tagName: "span", + supportedFieldTypes: ['date', 'datetime'], + // we don't need to listen on 'input' nor 'change' events because the + // datepicker widget is already listening, and will correctly notify changes + events: AbstractField.prototype.events, + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + // use the session timezone when formatting dates + this.formatOptions.timezone = true; + this.datepickerOptions = _.defaults( + {}, + this.nodeOptions.datepicker || {}, + {defaultDate: this.value} + ); + }, + /** + * In edit mode, instantiates a DateWidget datepicker and listen to changes. + * + * @override + */ + start: function () { + var self = this; + var prom; + if (this.mode === 'edit') { + this.datewidget = this._makeDatePicker(); + this.datewidget.on('datetime_changed', this, function () { + var value = this._getValue(); + if ((!value && this.value) || (value && !this._isSameValue(value))) { + this._setValue(value); + } + }); + prom = this.datewidget.appendTo('<div>').then(function () { + self.datewidget.$el.addClass(self.$el.attr('class')); + self._prepareInput(self.datewidget.$input); + self._replaceElement(self.datewidget.$el); + }); + } + return Promise.resolve(prom).then(this._super.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks the datepicker widget to activate the input, instead of doing it + * ourself, such that 'input' events triggered by the lib are correctly + * intercepted, and don't produce unwanted 'field_changed' events. + * + * @override + */ + activate: function () { + if (this.isFocusable() && this.datewidget) { + this.datewidget.$input.select(); + return true; + } + return false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _doDebouncedAction: function () { + this.datewidget.changeDatetime(); + }, + + /** + * return the datepicker value + * + * @private + */ + _getValue: function () { + return this.datewidget.getValue(); + }, + /** + * @override + * @private + * @param {Moment|false} value + * @returns {boolean} + */ + _isSameValue: function (value) { + if (value === false) { + return this.value === false; + } + return value.isSame(this.value, 'day'); + }, + /** + * Instantiates a new DateWidget datepicker. + * + * @private + */ + _makeDatePicker: function () { + return new datepicker.DateWidget(this, this.datepickerOptions); + }, + + /** + * Set the datepicker to the right value rather than the default one. + * + * @override + * @private + */ + _renderEdit: function () { + this.datewidget.setValue(this.value); + this.$input = this.datewidget.$input; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Confirm the value on hit enter and re-render + * + * @private + * @override + * @param {KeyboardEvent} ev + */ + async _onKeydown(ev) { + this._super(...arguments); + if (ev.which === $.ui.keyCode.ENTER) { + await this._setValue(this.$input.val()); + this._render(); + } + }, +}); + +var FieldDateTime = FieldDate.extend({ + description: _lt("Date & Time"), + supportedFieldTypes: ['datetime'], + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.value) { + var offset = this.getSession().getTZOffset(this.value); + var displayedValue = this.value.clone().add(offset, 'minutes'); + this.datepickerOptions.defaultDate = displayedValue; + } + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * return the datepicker value + * + * @private + */ + _getValue: function () { + var value = this.datewidget.getValue(); + return value && value.add(-this.getSession().getTZOffset(value), 'minutes'); + }, + /** + * @override + * @private + */ + _isSameValue: function (value) { + if (value === false) { + return this.value === false; + } + return value.isSame(this.value); + }, + /** + * Instantiates a new DateTimeWidget datepicker rather than DateWidget. + * + * @override + * @private + */ + _makeDatePicker: function () { + return new datepicker.DateTimeWidget(this, this.datepickerOptions); + }, + /** + * Set the datepicker to the right value rather than the default one. + * + * @override + * @private + */ + _renderEdit: function () { + var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes'); + this.datewidget.setValue(value); + this.$input = this.datewidget.$input; + }, +}); + +const RemainingDays = AbstractField.extend({ + description: _lt("Remaining Days"), + supportedFieldTypes: ['date', 'datetime'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Displays the delta (in days) between the value of the field and today. If + * the delta is larger than 99 days, displays the date as usual (without + * time). + * + * @override + */ + _render() { + if (this.value === false) { + this.$el.removeClass('text-bf text-danger text-warning'); + return; + } + // compare the value (in the user timezone) with now (also in the user + // timezone), to get a meaningful delta for the user + const nowUTC = moment().utc(); + const nowUserTZ = nowUTC.clone().add(session.getTZOffset(nowUTC), 'minutes'); + const fieldValue = this.field.type == "datetime" ? this.value.clone().add(session.getTZOffset(this.value), 'minutes') : this.value; + const diffDays = fieldValue.startOf('day').diff(nowUserTZ.startOf('day'), 'days'); + let text; + if (Math.abs(diffDays) > 99) { + text = this._formatValue(this.value, 'date'); + } else if (diffDays === 0) { + text = _t("Today"); + } else if (diffDays < 0) { + text = diffDays === -1 ? _t("Yesterday") : _.str.sprintf(_t('%s days ago'), -diffDays); + } else { + text = diffDays === 1 ? _t("Tomorrow") : _.str.sprintf(_t('In %s days'), diffDays); + } + this.$el.text(text).attr('title', this._formatValue(this.value, 'date')); + this.$el.toggleClass('text-bf', diffDays <= 0); + this.$el.toggleClass('text-danger', diffDays < 0); + this.$el.toggleClass('text-warning', diffDays === 0); + }, +}); + +var FieldMonetary = NumericField.extend({ + description: _lt("Monetary"), + className: 'o_field_monetary o_field_number', + tagName: 'span', + supportedFieldTypes: ['float', 'monetary'], + resetOnAnyFieldChange: true, // Have to listen to currency changes + + /** + * Float fields using a monetary widget have an additional currency_field + * parameter which defines the name of the field from which the currency + * should be read. + * + * They are also displayed differently than other inputs in + * edit mode. They are a div containing a span with the currency symbol and + * the actual input. + * + * If no currency field is given or the field does not exist, we fallback + * to the default input behavior instead. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + + this._setCurrency(); + + if (this.mode === 'edit') { + this.tagName = 'div'; + this.className += ' o_input'; + + // do not display currency symbol in edit + this.formatOptions.noSymbol = true; + } + + this.formatOptions.currency = this.currency; + this.formatOptions.digits = [16, 2]; + this.formatOptions.field_digits = this.nodeOptions.field_digits; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * For monetary fields, 0 is a valid value. + * + * @override + */ + isSet: function () { + return this.value === 0 || this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * For monetary fields, the input is inside a div, alongside a span + * containing the currency symbol. + * + * @override + * @private + */ + _renderEdit: function () { + this.$el.empty(); + + // Prepare and add the input + var def = this._prepareInput(this.$input).appendTo(this.$el); + + if (this.currency) { + // Prepare and add the currency symbol + var $currencySymbol = $('<span>', {text: this.currency.symbol}); + if (this.currency.position === "after") { + this.$el.append($currencySymbol); + } else { + this.$el.prepend($currencySymbol); + } + } + return def; + }, + /** + * @override + * @private + */ + _renderReadonly: function () { + this.$el.html(this._formatValue(this.value)); + }, + /** + * Re-gets the currency as its value may have changed. + * @see FieldMonetary.resetOnAnyFieldChange + * + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this._setCurrency(); + }, + /** + * Deduces the currency description from the field options and view state. + * The description is then available at this.currency. + * + * @private + */ + _setCurrency: function () { + var currencyField = this.nodeOptions.currency_field || this.field.currency_field || 'currency_id'; + var currencyID = this.record.data[currencyField] && this.record.data[currencyField].res_id; + this.currency = session.get_currency(currencyID); + this.formatOptions.currency = this.currency; // _formatValue() uses formatOptions + }, +}); + +var FieldInteger = NumericField.extend({ + description: _lt("Integer"), + className: 'o_field_integer o_field_number', + supportedFieldTypes: ['integer'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Format integer value + * + * Note: We have to overwrite this method to allow virtual ids. A virtual id + * is a character string composed of an integer and has a dash and other + * information. + * E.g: in calendar, the recursive event have virtual id linked to a real id + * virtual event id "23-20170418020000" is linked to the event id 23 + * + * @override + * @private + * @param {integer|string} value + * @returns {string} + */ + _formatValue: function (value) { + if (typeof value === 'string') { + if (!/^[0-9]+-/.test(value)) { + throw new Error('"' + value + '" is not an integer or a virtual id'); + } + return value; + } + return this._super.apply(this, arguments); + }, +}); + +var FieldFloat = NumericField.extend({ + description: _lt("Decimal"), + className: 'o_field_float o_field_number', + supportedFieldTypes: ['float'], + + /** + * Float fields have an additional precision parameter that is read from + * either the field node in the view or the field python definition itself. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.attrs.digits) { + this.nodeOptions.digits = JSON.parse(this.attrs.digits); + } + }, +}); + +var FieldFloatTime = FieldFloat.extend({ + description: _lt("Time"), + // this is not strictly necessary, as for this widget to be used, the 'widget' + // attrs must be set to 'float_time', so the formatType is automatically + // 'float_time', but for the sake of clarity, we explicitely define a + // FieldFloatTime widget with formatType = 'float_time'. + formatType: 'float_time', + + init: function () { + this._super.apply(this, arguments); + this.formatType = 'float_time'; + } +}); + +var FieldFloatFactor = FieldFloat.extend({ + supportedFieldTypes: ['float'], + className: 'o_field_float_factor', + formatType: 'float_factor', + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + // default values + if (!this.nodeOptions.factor){ + this.nodeOptions.factor = 1; + } + // use as format and parse options + this.parseOptions = this.nodeOptions; + } +}); + +/** + * The goal of this widget is to replace the input field by a button containing a + * range of possible values (given in the options). Each click allows the user to loop + * in the range. The purpose here is to restrict the field value to a predefined selection. + * Also, the widget support the factor conversion as the *float_factor* widget (Range values + * should be the result of the conversion). + **/ +var FieldFloatToggle = AbstractField.extend({ + supportedFieldTypes: ['float'], + formatType: 'float_factor', + className: 'o_field_float_toggle', + tagName: 'span', + events: { + click: '_onClick' + }, + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this.formatType = 'float_factor'; + + if (this.mode === 'edit') { + this.tagName = 'button'; + } + + // we don't inherit Float Field + if (this.attrs.digits) { + this.nodeOptions.digits = JSON.parse(this.attrs.digits); + } + // default values + if (!this.nodeOptions.factor){ + this.nodeOptions.factor = 1; + } + if (!this.nodeOptions.range){ + this.nodeOptions.range = [0.0, 0.5, 1.0]; + } + + // use as format and parse options + this.parseOptions = this.nodeOptions; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get the display value but in real type to use it in calculations + * + * @private + * @returns {float} The current formatted value + */ + _getDisplayedValue: function () { + // this.value is a plain float + // Matches what is in Database + var usrFormatValue = this._formatValue(this.value); + // usrFormatValue is string + // contains a float represented in a user specific format + // the float is the fraction by [this.factor] of this.value + return field_utils.parse['float'](usrFormatValue); + }, + /** + * Formats the HTML input tag for edit mode and stores selection status. + * + * @override + * @private + */ + _renderEdit: function () { + // Keep a reference to the input so $el can become something else + // without losing track of the actual input. + this.$el.text(this._formatValue(this.value)); + }, + /** + * Resets the content to the formated value in readonly mode. + * + * @override + * @private + */ + _renderReadonly: function () { + this.$el.text(this._formatValue(this.value)); + }, + /** + * Get the next value in the range, from the current one. If the current + * one is not in the range, the next value of the closest one will be chosen. + * + * @private + * @returns {number} The next value in the range + */ + _nextValue: function () { + var range = this.nodeOptions.range; + var val = utils.closestNumber(this._getDisplayedValue(), range); + var index = _.indexOf(range, val); + if (index !== -1) { + if (index + 1 < range.length) { + return range[index + 1]; + } + } + return range[0]; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Clicking on the button triggers the change of value; the next one of + * the range will be displayed. + * + * @private + * @param {OdooEvent} ev + */ + _onClick: function(ev) { + if (this.mode === 'edit') { + ev.stopPropagation(); // only stop propagation in edit mode + var next_val = this._nextValue(); + next_val = field_utils.format['float'](next_val); + this._setValue(next_val); // will be parsed in _setValue + } + }, + /** + * For float toggle fields, 0 is a valid value. + * + * @override + */ + isSet: function () { + return this.value === 0 || this._super(...arguments); + }, +}); + +var FieldPercentage = FieldFloat.extend({ + className: 'o_field_float_percentage o_field_number', + description: _lt("Percentage"), + + /** + * @constructor + */ + init() { + this._super(...arguments); + if (this.mode === 'edit') { + this.tagName = 'div'; + this.className += ' o_input'; + + // do not display % in the input in edit + this.formatOptions.noSymbol = true; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * For percentage widget, the input is inside a div, alongside a span + * containing the percentage(%) symbol. + * + * @override + * @private + */ + _renderEdit() { + this.$el.empty(); + // Prepare and add the input + this._prepareInput(this.$input).appendTo(this.$el); + const $percentageSymbol = $('<span>', { text: '%' }); + this.$el.append($percentageSymbol); + }, +}); + +var FieldText = InputField.extend(TranslatableFieldMixin, { + description: _lt("Multiline Text"), + className: 'o_field_text', + supportedFieldTypes: ['text', 'html'], + tagName: 'span', + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + if (this.mode === 'edit') { + this.tagName = 'textarea'; + } + this.autoResizeOptions = {parent: this}; + }, + /** + * As it it done in the start function, the autoresize is done only once. + * + * @override + */ + start: function () { + if (this.mode === 'edit') { + dom.autoresize(this.$el, this.autoResizeOptions); + if (this.field.translate) { + this.$el = this.$el.add(this._renderTranslateButton()); + this.$el.addClass('o_field_translate'); + } + } + return this._super(); + }, + /** + * Override to force a resize of the textarea when its value has changed + * + * @override + */ + reset: function () { + var self = this; + return Promise.resolve(this._super.apply(this, arguments)).then(function () { + if (self.mode === 'edit') { + self.$input.trigger('change'); + } + }); + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Stops the enter navigation in a text area. + * + * @private + * @param {OdooEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ENTER) { + ev.stopPropagation(); + return; + } + this._super.apply(this, arguments); + }, +}); + +var ListFieldText = FieldText.extend({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.autoResizeOptions.min_height = 0; + }, +}); + +/** + * Displays a handle to modify the sequence. + */ +var HandleWidget = AbstractField.extend({ + description: _lt("Handle"), + noLabel: true, + className: 'o_row_handle fa fa-arrows ui-sortable-handle', + widthInList: '33px', + tagName: 'span', + supportedFieldTypes: ['integer'], + + /* + * @override + */ + isSet: function () { + return true; + }, +}); + +var FieldEmail = InputField.extend({ + description: _lt("Email"), + className: 'o_field_email', + events: _.extend({}, InputField.prototype.events, { + 'click': '_onClick', + }), + prefix: 'mailto', + supportedFieldTypes: ['char'], + + /** + * In readonly, emails should be a link, not a span. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.tagName = this.mode === 'readonly' ? 'a' : 'input'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the associated link. + * + * @override + */ + getFocusableElement: function () { + return this.mode === 'readonly' ? this.$el : this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * In readonly, emails should be a mailto: link with proper formatting. + * + * @override + * @private + */ + _renderReadonly: function () { + if (this.value) { + // Odoo legacy widgets can have multiple nodes inside their $el JQuery object + // so, select the proper one (other nodes are assumed not to contain proper data) + this.$el.closest("." + this.className).text(this.value) + .addClass('o_form_uri o_text_overflow') + .attr('href', this.prefix + ':' + this.value); + } else { + this.$el.text(''); + } + }, + /** + * Trim the value input by the user. + * + * @override + * @private + * @param {any} value + * @param {Object} [options] + */ + _setValue: function (value, options) { + if (this.field.trim) { + value = value.trim(); + } + return this._super(value, options); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Prevent the URL click from opening the record (when used on a list). + * + * @private + * @param {MouseEvent} ev + */ + _onClick: function (ev) { + ev.stopPropagation(); + }, +}); + +var FieldPhone = FieldEmail.extend({ + description: _lt("Phone"), + className: 'o_field_phone', + prefix: 'tel', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _renderReadonly: function () { + this._super(); + + // This class should technically be there in case of a very very long + // phone number, but it breaks the o_row mechanism, which is more + // important right now. + this.$el.removeClass('o_text_overflow'); + }, +}); + +var UrlWidget = InputField.extend({ + description: _lt("URL"), + className: 'o_field_url', + events: _.extend({}, InputField.prototype.events, { + 'click': '_onClick', + }), + supportedFieldTypes: ['char'], + + /** + * Urls are links in readonly mode. + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.tagName = this.mode === 'readonly' ? 'a' : 'input'; + this.websitePath = this.nodeOptions.website_path || false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the associated link. + * + * @override + */ + getFocusableElement: function () { + return this.mode === 'readonly' ? this.$el : this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * In readonly, the widget needs to be a link with proper href and proper + * support for the design, which is achieved by the added classes. + * + * @override + * @private + */ + _renderReadonly: function () { + let href = this.value; + if (this.value && !this.websitePath) { + const regex = /^(?:[fF]|[hH][tT])[tT][pP][sS]?:\/\//; + href = !regex.test(this.value) ? `http://${href}` : href; + } + this.$el.text(this.attrs.text || this.value) + .addClass('o_form_uri o_text_overflow') + .attr('target', '_blank') + .attr('href', href); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Prevent the URL click from opening the record (when used on a list). + * + * @private + * @param {MouseEvent} ev + */ + _onClick: function (ev) { + ev.stopPropagation(); + }, +}); + +var CopyClipboard = { + + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + if (this.clipboard) { + this.clipboard.destroy(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instatiates the Clipboad lib. + */ + _initClipboard: function () { + var self = this; + var $clipboardBtn = this.$('.o_clipboard_button'); + $clipboardBtn.tooltip({title: _t('Copied !'), trigger: 'manual', placement: 'right'}); + this.clipboard = new ClipboardJS($clipboardBtn[0], { + text: function () { + return self.value.trim(); + }, + // Container added because of Bootstrap modal that give the focus to another element. + // We need to give to correct focus to ClipboardJS (see in ClipboardJS doc) + // https://github.com/zenorocha/clipboard.js/issues/155 + container: self.$el[0] + }); + this.clipboard.on('success', function () { + _.defer(function () { + $clipboardBtn.tooltip('show'); + _.delay(function () { + $clipboardBtn.tooltip('hide'); + }, 800); + }); + }); + }, + /** + * @override + */ + _renderReadonly: function () { + this._super.apply(this, arguments); + if (this.value) { + this.$el.append($(qweb.render(this.clipboardTemplate))); + this._initClipboard(); + } + } +}; + +var TextCopyClipboard = FieldText.extend(CopyClipboard, { + description: _lt("Copy to Clipboard"), + clipboardTemplate: 'CopyClipboardText', + className: "o_field_copy", +}); + +var CharCopyClipboard = FieldChar.extend(CopyClipboard, { + description: _lt("Copy to Clipboard"), + clipboardTemplate: 'CopyClipboardChar', + className: 'o_field_copy o_text_overflow', +}); + +var AbstractFieldBinary = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'change .o_input_file': 'on_file_change', + 'click .o_select_file_button': function () { + this.$('.o_input_file').click(); + }, + 'click .o_clear_file_button': '_onClearClick', + }), + init: function (parent, name, record) { + this._super.apply(this, arguments); + this.fields = record.fields; + this.useFileAPI = !!window.FileReader; + this.max_upload_size = session.max_file_upload_size || 128 * 1024 * 1024; + this.accepted_file_extensions = (this.nodeOptions && this.nodeOptions.accepted_file_extensions) || this.accepted_file_extensions || '*'; + if (!this.useFileAPI) { + var self = this; + this.fileupload_id = _.uniqueId('o_fileupload'); + $(window).on(this.fileupload_id, function () { + var args = [].slice.call(arguments).slice(1); + self.on_file_uploaded.apply(self, args); + }); + } + }, + destroy: function () { + if (this.fileupload_id) { + $(window).off(this.fileupload_id); + } + this._super.apply(this, arguments); + }, + on_file_change: function (e) { + var self = this; + var file_node = e.target; + if ((this.useFileAPI && file_node.files.length) || (!this.useFileAPI && $(file_node).val() !== '')) { + if (this.useFileAPI) { + var file = file_node.files[0]; + if (file.size > this.max_upload_size) { + var msg = _t("The selected file exceed the maximum file size of %s."); + this.do_warn(_t("File upload"), _.str.sprintf(msg, utils.human_size(this.max_upload_size))); + return false; + } + utils.getDataURLFromFile(file).then(function (data) { + data = data.split(',')[1]; + self.on_file_uploaded(file.size, file.name, file.type, data); + }); + } else { + this.$('form.o_form_binary_form').submit(); + } + this.$('.o_form_binary_progress').show(); + this.$('button').hide(); + } + }, + on_file_uploaded: function (size, name) { + if (size === false) { + this.do_warn(false, _t("There was a problem while uploading your file")); + // TODO: use crashmanager + console.warn("Error while uploading file : ", name); + } else { + this.on_file_uploaded_and_valid.apply(this, arguments); + } + this.$('.o_form_binary_progress').hide(); + this.$('button').show(); + }, + on_file_uploaded_and_valid: function (size, name, content_type, file_base64) { + this.set_filename(name); + this._setValue(file_base64); + this._render(); + }, + /** + * We need to update another field. This method is so deprecated it is not + * even funny. We need to replace this with the mechanism of field widgets + * declaring statically that they need to listen to every changes in other + * fields + * + * @deprecated + * + * @param {any} value + */ + set_filename: function (value) { + var filename = this.attrs.filename; + if (filename && filename in this.fields) { + var changes = {}; + changes[filename] = value; + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: changes, + viewType: this.viewType, + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * Clear the file input + * + * @private + */ + _clearFile: function (){ + var self = this; + this.$('.o_input_file').val(''); + this.set_filename(''); + if (!this.isDestroyed()) { + this._setValue(false).then(function() { + self._render(); + }); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * On "clear file" button click + * + * @param {MouseEvent} ev + * @private + */ + _onClearClick: function (ev) { + this._clearFile(); + }, +}); + +var FieldBinaryImage = AbstractFieldBinary.extend({ + description: _lt("Image"), + fieldDependencies: _.extend({}, AbstractFieldBinary.prototype.fieldDependencies, { + __last_update: {type: 'datetime'}, + }), + + template: 'FieldBinaryImage', + placeholder: "/web/static/src/img/placeholder.png", + events: _.extend({}, AbstractFieldBinary.prototype.events, { + 'click img': function () { + if (this.mode === "readonly") { + this.trigger_up('bounce_edit'); + } + }, + }), + supportedFieldTypes: ['binary'], + file_type_magic_word: { + '/': 'jpg', + 'R': 'gif', + 'i': 'png', + 'P': 'svg+xml', + }, + accepted_file_extensions: 'image/*', + /** + * Returns the image URL from a model. + * + * @private + * @param {string} model model from which to retrieve the image + * @param {string} res_id id of the record + * @param {string} field name of the image field + * @param {string} unique an unique integer for the record, usually __last_update + * @returns {string} URL of the image + */ + _getImageUrl: function (model, res_id, field, unique) { + return session.url('/web/image', { + model: model, + id: JSON.stringify(res_id), + field: field, + // unique forces a reload of the image when the record has been updated + unique: field_utils.format.datetime(unique).replace(/[^0-9]/g, ''), + }); + }, + _render: function () { + var self = this; + var url = this.placeholder; + if (this.value) { + if (!utils.is_bin_size(this.value)) { + // Use magic-word technique for detecting image type + url = 'data:image/' + (this.file_type_magic_word[this.value[0]] || 'png') + ';base64,' + this.value; + } else { + var field = this.nodeOptions.preview_image || this.name; + var unique = this.recordData.__last_update; + url = this._getImageUrl(this.model, this.res_id, field, unique); + } + } + var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url})); + // override css size attributes (could have been defined in css files) + // if specified on the widget + var width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width; + var height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height; + if (width) { + $img.attr('width', width); + $img.css('max-width', width + 'px'); + } + if (height) { + $img.attr('height', height); + $img.css('max-height', height + 'px'); + } + this.$('> img').remove(); + this.$el.prepend($img); + + $img.one('error', function () { + $img.attr('src', self.placeholder); + self.do_warn(false, _t("Could not display the selected image")); + }); + + return this._super.apply(this, arguments); + }, + /** + * Only enable the zoom on image in read-only mode, and if the option is enabled. + * + * @override + * @private + */ + _renderReadonly: function () { + this._super.apply(this, arguments); + + if(this.nodeOptions.zoom) { + var unique = this.recordData.__last_update; + var url = this._getImageUrl(this.model, this.res_id, 'image_1920', unique); + var $img; + var imageField = _.find(Object.keys(this.recordData), function(o) { + return o.startsWith('image_'); + }); + + if(this.nodeOptions.background) + { + if('tag' in this.nodeOptions) { + this.tagName = this.nodeOptions.tag; + } + + if('class' in this.attrs) { + this.$el.addClass(this.attrs.class); + } + + const image_field = this.field.manual ? this.name:'image_128'; + var urlThumb = this._getImageUrl(this.model, this.res_id, image_field, unique); + + this.$el.empty(); + $img = this.$el; + $img.css('backgroundImage', 'url(' + urlThumb + ')'); + } else { + $img = this.$('img'); + } + var zoomDelay = 0; + if (this.nodeOptions.zoom_delay) { + zoomDelay = this.nodeOptions.zoom_delay; + } + + if(this.recordData[imageField]) { + $img.attr('data-zoom', 1); + $img.attr('data-zoom-image', url); + + $img.zoomOdoo({ + event: 'mouseenter', + timer: zoomDelay, + attach: '.o_content', + attachToTarget: true, + onShow: function () { + var zoomHeight = Math.ceil(this.$zoom.height()); + var zoomWidth = Math.ceil(this.$zoom.width()); + if( zoomHeight < 128 && zoomWidth < 128) { + this.hide(); + } + core.bus.on('keydown', this, this.hide); + core.bus.on('click', this, this.hide); + }, + beforeAttach: function () { + this.$flyout.css({ width: '512px', height: '512px' }); + }, + preventClicks: this.nodeOptions.preventClicks, + }); + } + } + }, +}); + +var CharImageUrl = AbstractField.extend({ + className: 'o_field_image', + description: _lt("Image"), + supportedFieldTypes: ['char'], + placeholder: "/web/static/src/img/placeholder.png", + + _renderReadonly: function () { + var self = this; + const url = this.value; + if (url) { + var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url})); + // override css size attributes (could have been defined in css files) + // if specified on the widget + const width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width; + const height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height; + if (width) { + $img.attr('width', width); + $img.css('max-width', width + 'px'); + } + if (height) { + $img.attr('height', height); + $img.css('max-height', height + 'px'); + } + this.$('> img').remove(); + this.$el.prepend($img); + + $img.one('error', function () { + $img.attr('src', self.placeholder); + self.displayNotification({ + type: 'info', + message: _t("Could not display the specified image url."), + }); + }); + } + + return this._super.apply(this, arguments); + }, +}); + +var KanbanFieldBinaryImage = FieldBinaryImage.extend({ + // In kanban views, there is a weird logic to determine whether or not a + // click on a card should open the record in a form view. This logic checks + // if the clicked element has click handlers bound on it, and if so, does + // not open the record (assuming that the click will be handle by someone + // else). In the case of this widget, there are clicks handler but they + // only apply in edit mode, which is never the case in kanban views, so we + // simply remove them. + events: {}, +}); + +var KanbanCharImageUrl = CharImageUrl.extend({ + // In kanban views, there is a weird logic to determine whether or not a + // click on a card should open the record in a form view. This logic checks + // if the clicked element has click handlers bound on it, and if so, does + // not open the record (assuming that the click will be handled by someone + // else). In the case of this widget, there are clicks handler but they + // only apply in edit mode, which is never the case in kanban views, so we + // simply remove them. + events: {}, +}); + +var FieldBinaryFile = AbstractFieldBinary.extend({ + description: _lt("File"), + template: 'FieldBinaryFile', + events: _.extend({}, AbstractFieldBinary.prototype.events, { + 'click': function (event) { + if (this.mode === 'readonly' && this.value && this.recordData.id) { + this.on_save_as(event); + } + }, + 'click .o_input': function () { // eq[0] + this.$('.o_input_file').click(); + }, + }), + supportedFieldTypes: ['binary'], + init: function () { + this._super.apply(this, arguments); + this.filename_value = this.recordData[this.attrs.filename]; + }, + _renderReadonly: function () { + this.do_toggle(!!this.value); + if (this.value) { + this.$el.empty().append($("<span/>").addClass('fa fa-download')); + if (this.recordData.id) { + this.$el.css('cursor', 'pointer'); + } else { + this.$el.css('cursor', 'not-allowed'); + } + if (this.filename_value) { + this.$el.append(" " + this.filename_value); + } + } + if (!this.res_id) { + this.$el.css('cursor', 'not-allowed'); + } else { + this.$el.css('cursor', 'pointer'); + } + }, + _renderEdit: function () { + if (this.value) { + this.$el.children().removeClass('o_hidden'); + this.$('.o_select_file_button').first().addClass('o_hidden'); + this.$('.o_input').eq(0).val(this.filename_value || this.value); + } else { + this.$el.children().addClass('o_hidden'); + this.$('.o_select_file_button').first().removeClass('o_hidden'); + } + }, + set_filename: function (value) { + this._super.apply(this, arguments); + this.filename_value = value; // will be used in the re-render + // the filename being edited but not yet saved, if the user clicks on + // download, he'll get the file corresponding to the current value + // stored in db, which isn't the one whose filename is displayed in the + // input, so we disable the download button + this.$('.o_save_file_button').prop('disabled', true); + }, + on_save_as: function (ev) { + if (!this.value) { + this.do_warn(false, _t("The field is empty, there's nothing to save.")); + ev.stopPropagation(); + } else if (this.res_id) { + framework.blockUI(); + var filename_fieldname = this.attrs.filename; + this.getSession().get_file({ + complete: framework.unblockUI, + data: { + 'model': this.model, + 'id': this.res_id, + 'field': this.name, + 'filename_field': filename_fieldname, + 'filename': this.recordData[filename_fieldname] || "", + 'download': true, + 'data': utils.is_bin_size(this.value) ? null : this.value, + }, + error: (error) => this.call('crash_manager', 'rpc_error', error), + url: '/web/content', + }); + ev.stopPropagation(); + } + }, +}); + +var FieldPdfViewer = FieldBinaryFile.extend({ + description: _lt("PDF Viewer"), + supportedFieldTypes: ['binary'], + template: 'FieldPdfViewer', + accepted_file_extensions: 'application/pdf', + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.PDFViewerApplication = false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {DOMElement} iframe + */ + _disableButtons: function (iframe) { + $(iframe).contents().find('button#openFile').hide(); + }, + /** + * @private + * @param {string} [fileURI] file URI if specified + * @returns {string} the pdf viewer URI + */ + _getURI: function (fileURI) { + var page = this.recordData[this.name + '_page'] || 1; + if (!fileURI) { + var queryObj = { + model: this.model, + field: this.name, + id: this.res_id, + }; + var queryString = $.param(queryObj); + fileURI = '/web/content?' + queryString; + } + fileURI = encodeURIComponent(fileURI); + var viewerURL = '/web/static/lib/pdfjs/web/viewer.html?file='; + return viewerURL + fileURI + '#page=' + page; + }, + /** + * @private + * @override + */ + _render: function () { + var self = this; + var $pdfViewer = this.$('.o_form_pdf_controls').children().add(this.$('.o_pdfview_iframe')); + var $selectUpload = this.$('.o_select_file_button').first(); + var $iFrame = this.$('.o_pdfview_iframe'); + + $iFrame.on('load', function () { + self.PDFViewerApplication = this.contentWindow.window.PDFViewerApplication; + self._disableButtons(this); + }); + if (this.mode === "readonly" && this.value) { + $iFrame.attr('src', this._getURI()); + } else { + if (this.value) { + var binSize = utils.is_bin_size(this.value); + $pdfViewer.removeClass('o_hidden'); + $selectUpload.addClass('o_hidden'); + if (binSize) { + $iFrame.attr('src', this._getURI()); + } + } else { + $pdfViewer.addClass('o_hidden'); + $selectUpload.removeClass('o_hidden'); + } + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @param {Event} ev + */ + on_file_change: function (ev) { + this._super.apply(this, arguments); + var files = ev.target.files; + if (!files || files.length === 0) { + return; + } + // TOCheck: is there requirement to fallback on FileReader if browser don't support URL + var fileURI = URL.createObjectURL(files[0]); + if (this.PDFViewerApplication) { + this.PDFViewerApplication.open(fileURI, 0); + } else { + this.$('.o_pdfview_iframe').attr('src', this._getURI(fileURI)); + } + }, + /** + * Remove the behaviour of on_save_as in FieldBinaryFile. + * + * @override + * @private + * @param {MouseEvent} ev + */ + on_save_as: function (ev) { + ev.stopPropagation(); + }, + +}); + +var PriorityWidget = AbstractField.extend({ + description: _lt("Priority"), + // the current implementation of this widget makes it + // only usable for fields of type selection + className: "o_priority", + attributes: { + 'role': 'radiogroup', + }, + events: { + 'mouseover > a': '_onMouseOver', + 'mouseout > a': '_onMouseOut', + 'click > a': '_onClick', + 'keydown > a': '_onKeydown', + }, + supportedFieldTypes: ['selection'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Like boolean fields, this widget always has a value, since the default + * value is already a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + + /** + * Returns the currently-checked star, or the first one if no star is + * checked. + * + * @override + */ + getFocusableElement: function () { + var checked = this.$("[aria-checked='true']"); + return checked.length ? checked : this.$("[data-index='1']"); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders a star for each possible value, readonly or edit mode doesn't matter. + * + * @override + * @private + */ + _render: function () { + var self = this; + var index_value = this.value ? _.findIndex(this.field.selection, function (v) { + return v[0] === self.value; + }) : 0; + this.$el.empty(); + this.empty_value = this.field.selection[0][0]; + this.$el.attr('aria-label', this.string); + const isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly; + _.each(this.field.selection.slice(1), function (choice, index) { + const tag = isReadonly ? '<span>' : '<a href="#">'; + self.$el.append(self._renderStar(tag, index_value >= index + 1, index + 1, choice[1], index_value)); + }); + }, + + /** + * Renders a star representing a particular value for this field. + * + * @param {string} tag html tag to be passed to jquery to hold the star + * @param {boolean} isFull whether the star is a full star or not + * @param {integer} index the index of the star in the series + * @param {string} tip tooltip for this star's meaning + * @param {integer} indexValue the index of the last full star or 0 + * @private + */ + _renderStar: function (tag, isFull, index, tip, indexValue) { + var isChecked = indexValue === index; + var defaultFocus = indexValue === 0 && index === 1; + return $(tag) + .attr('role', 'radio') + .attr('aria-checked', isChecked) + .attr('title', tip) + .attr('aria-label', tip) + .attr('tabindex', isChecked || defaultFocus ? 0 : -1) + .attr('data-index', index) + .addClass('o_priority_star fa') + .toggleClass('fa-star', isFull) + .toggleClass('fa-star-o', !isFull); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Update the value of the field based on which star the user clicked on. + * + * @param {MouseEvent} event + * @private + */ + _onClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + var index = $(event.currentTarget).data('index'); + var newValue = this.field.selection[index][0]; + if (newValue === this.value) { + newValue = this.empty_value; + } + this._setValue(newValue); + }, + + /** + * Reset the star display status. + * + * @private + */ + _onMouseOut: function () { + clearTimeout(this.hoverTimer); + var self = this; + this.hoverTimer = setTimeout(function () { + self._render(); + }, 200); + }, + + /** + * Colors the stars to show the user the result when clicking on it. + * + * @param {MouseEvent} event + * @private + */ + _onMouseOver: function (event) { + clearTimeout(this.hoverTimer); + this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star'); + $(event.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o'); + }, + + /** + * Runs the default behavior when <enter> is pressed over a star + * (the same as if it was clicked); otherwise forwards event to the widget. + * + * @param {KeydownEvent} event + * @private + */ + _onKeydown: function (event) { + if (event.which === $.ui.keyCode.ENTER) { + return; + } + this._super.apply(this, arguments); + }, + + _onNavigationMove: function (ev) { + var $curControl = this.$('a:focus'); + var $nextControl; + if (ev.data.direction === 'right' || ev.data.direction === 'down') { + $nextControl = $curControl.next('a'); + } else if (ev.data.direction === 'left' || ev.data.direction === 'up') { + $nextControl = $curControl.prev('a'); + } + if ($nextControl && $nextControl.length) { + ev.stopPropagation(); + $nextControl.focus(); + return; + } + this._super.apply(this, arguments); + }, +}); + +var AttachmentImage = AbstractField.extend({ + className: 'o_attachment_image', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Reset cover image when widget value change + * + * @private + */ + _render: function () { + if (this.value) { + this.$el.empty().append($('<img>/', { + src: "/web/image/" + this.value.data.id + "?unique=1", + title: this.value.data.display_name, + alt: _t("Image") + })); + } + } +}); + +var StateSelectionWidget = AbstractField.extend({ + template: 'FormSelection', + events: { + 'click .dropdown-item': '_setSelection', + }, + supportedFieldTypes: ['selection'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the drop down button. + * + * @override + */ + getFocusableElement: function () { + return this.$("a[data-toggle='dropdown']"); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Prepares the state values to be rendered using the FormSelection.Items template. + * + * @private + */ + _prepareDropdownValues: function () { + var self = this; + var _data = []; + var current_stage_id = self.recordData.stage_id && self.recordData.stage_id[0]; + var stage_data = { + id: current_stage_id, + legend_normal: this.recordData.legend_normal || undefined, + legend_blocked : this.recordData.legend_blocked || undefined, + legend_done: this.recordData.legend_done || undefined, + }; + _.map(this.field.selection || [], function (selection_item) { + var value = { + 'name': selection_item[0], + 'tooltip': selection_item[1], + }; + if (selection_item[0] === 'normal') { + value.state_name = stage_data.legend_normal ? stage_data.legend_normal : selection_item[1]; + } else if (selection_item[0] === 'done') { + value.state_class = 'o_status_green'; + value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1]; + } else { + value.state_class = 'o_status_red'; + value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1]; + } + _data.push(value); + }); + return _data; + }, + + /** + * This widget uses the FormSelection template but needs to customize it a bit. + * + * @private + * @override + */ + _render: function () { + var states = this._prepareDropdownValues(); + // Adapt "FormSelection" + // Like priority, default on the first possible value if no value is given. + var currentState = _.findWhere(states, {name: this.value}) || states[0]; + this.$('.o_status') + .removeClass('o_status_red o_status_green') + .addClass(currentState.state_class) + .prop('special_click', true) + .parent().attr('title', currentState.state_name) + .attr('aria-label', this.string + ": " + currentState.state_name); + + // Render "FormSelection.Items" and move it into "FormSelection" + var $items = $(qweb.render('FormSelection.items', { + states: _.without(states, currentState) + })); + var $dropdown = this.$('.dropdown-menu'); + $dropdown.children().remove(); // remove old items + $items.appendTo($dropdown); + + // Disable edition if the field is readonly + var isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly; + this.$('a[data-toggle=dropdown]').toggleClass('disabled', isReadonly || false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Intercepts the click on the FormSelection.Item to set the widget value. + * + * @private + * @param {MouseEvent} ev + */ + _setSelection: function (ev) { + ev.preventDefault(); + var $item = $(ev.currentTarget); + var value = String($item.data('value')); + this._setValue(value); + if (this.mode === 'edit') { + this._render(); + } + }, +}); + +var FavoriteWidget = AbstractField.extend({ + className: 'o_favorite', + events: { + 'click': '_setFavorite' + }, + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render favorite icon based on state + * + * @override + * @private + */ + _render: function () { + var tip = this.value ? _t('Remove from Favorites') : _t('Add to Favorites'); + var template = this.attrs.nolabel ? '<a href="#"><i class="fa %s" title="%s" aria-label="%s" role="img"></i></a>' : '<a href="#"><i class="fa %s" role="img" aria-label="%s"></i> %s</a>'; + this.$el.empty().append(_.str.sprintf(template, this.value ? 'fa-star' : 'fa-star-o', tip, tip)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Toggle favorite state + * + * @private + * @param {MouseEvent} event + */ + _setFavorite: function (event) { + event.preventDefault(); + event.stopPropagation(); + this._setValue(!this.value); + }, +}); + +var LabelSelection = AbstractField.extend({ + supportedFieldTypes: ['selection'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This widget renders a simple non-editable label. Color classes can be set + * using the 'classes' key from the options tag, such as: + * <field [...] options="{'classes': {'value': 'className', ...}}"/> + * + * @private + * @override + */ + _render: function () { + this.classes = this.nodeOptions && this.nodeOptions.classes || {}; + var labelClass = this.classes[this.value] || 'primary'; + this.$el.addClass('badge badge-' + labelClass).text(this._formatValue(this.value)); + }, +}); + +var BooleanToggle = FieldBoolean.extend({ + description: _lt("Toggle"), + className: FieldBoolean.prototype.className + ' o_boolean_toggle', + events: { + 'click': '_onClick' + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Toggle active value + * + * @private + * @param {MouseEvent} event + */ + _onClick: function (event) { + event.stopPropagation(); + this._setValue(!this.value); + }, +}); + +var StatInfo = AbstractField.extend({ + supportedFieldTypes: ['integer', 'float'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * StatInfo widgets are always set since they basically only display info. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders the field value using the StatInfo template. The text part of the + * widget is either the string attribute of this node in the view or the + * label of the field itself if no string attribute is given. + * + * @override + * @private + */ + _render: function () { + var options = { + value: this._formatValue(this.value || 0), + }; + if (! this.attrs.nolabel) { + if (this.nodeOptions.label_field && this.recordData[this.nodeOptions.label_field]) { + options.text = this.recordData[this.nodeOptions.label_field]; + } else { + options.text = this.string; + } + } + this.$el.html(qweb.render("StatInfo", options)); + this.$el.addClass('o_stat_info'); + }, +}); + +var FieldPercentPie = AbstractField.extend({ + description: _lt("Percentage Pie"), + template: 'FieldPercentPie', + supportedFieldTypes: ['integer', 'float'], + + /** + * Register some useful references for later use throughout the widget. + * + * @override + */ + start: function () { + this.$leftMask = this.$('.o_mask').first(); + this.$rightMask = this.$('.o_mask').last(); + this.$pieValue = this.$('.o_pie_value'); + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * PercentPie widgets are always set since they basically only display info. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This widget template needs javascript to apply the transformation + * associated with the rotation of the pie chart. + * + * @override + * @private + */ + _render: function () { + var value = this.value || 0; + var degValue = 360*value/100; + + this.$rightMask.toggleClass('o_full', degValue >= 180); + + var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)'; + var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)'; + this.$leftMask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg}); + this.$rightMask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg}); + + this.$pieValue.text(Math.round(value) + '%'); + }, +}); + +/** + * Node options: + * + * - title: title of the bar, displayed on top of the bar options + * - editable: boolean if value is editable + * - current_value: get the current_value from the field that must be present in the view + * - max_value: get the max_value from the field that must be present in the view + * - edit_max_value: boolean if the max_value is editable + * - title: title of the bar, displayed on top of the bar --> not translated, use parameter "title" instead + */ +var FieldProgressBar = AbstractField.extend({ + description: _lt("Progress Bar"), + template: "ProgressBar", + events: { + 'change input': 'on_change_input', + 'input input': 'on_change_input', + 'keyup input': function (e) { + if (e.which === $.ui.keyCode.ENTER) { + this.on_change_input(e); + } + }, + }, + supportedFieldTypes: ['integer', 'float'], + init: function () { + this._super.apply(this, arguments); + + // the progressbar needs the values and not the field name, passed in options + if (this.recordData[this.nodeOptions.current_value]) { + this.value = this.recordData[this.nodeOptions.current_value]; + } + + // The few next lines determine if the widget can write on the record or not + this.editable_readonly = !!this.nodeOptions.editable_readonly; + // "hard" readonly + this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable; + + this.canWrite = !this.readonly && ( + this.mode === 'edit' || + (this.editable_readonly && this.mode === 'readonly') || + (this.viewType === 'kanban') // Keep behavior before commit + ); + + // Boolean to toggle if we edit the numerator (value) or the denominator (max_value) + this.edit_max_value = !!this.nodeOptions.edit_max_value; + this.max_value = this.recordData[this.nodeOptions.max_value] || 100; + + this.title = _t(this.attrs.title || this.nodeOptions.title) || ''; + + // Ability to edit the field through the bar + // /!\ this feature is disabled + this.enableBarAsInput = false; + this.edit_on_click = this.enableBarAsInput && this.mode === 'readonly' && !this.edit_max_value; + + this.write_mode = false; + }, + _render: function () { + var self = this; + this._render_value(); + + if (this.canWrite) { + if (this.edit_on_click) { + this.$el.on('click', '.o_progress', function (e) { + var $target = $(e.currentTarget); + var numValue = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); + self.on_update(numValue); + self._render_value(); + }); + } else { + this.$el.on('click', function () { + if (!self.write_mode) { + var $input = $('<input>', {type: 'text', class: 'o_progressbar_value o_input'}); + $input.on('blur', self.on_change_input.bind(self)); + self.$('.o_progressbar_value').replaceWith($input); + self.write_mode = true; + self._render_value(); + } + }); + } + } + return this._super(); + }, + /** + * Updates the widget with value + * + * @param {Number} value + */ + on_update: function (value) { + if (this.edit_max_value) { + this.max_value = value; + this._isValid = true; + var changes = {}; + changes[this.nodeOptions.max_value] = this.max_value; + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: changes, + }); + } else { + // _setValues accepts string and will parse it + var formattedValue = this._formatValue(value); + this._setValue(formattedValue); + } + }, + on_change_input: function (e) { + var $input = $(e.target); + if (e.type === 'change' && !$input.is(':focus')) { + return; + } + + var parsedValue; + try { + // Cover all numbers with parseFloat + parsedValue = field_utils.parse.float($input.val()); + } catch (error) { + this.do_warn(false, _t("Please enter a numerical value")); + } + + if (parsedValue !== undefined) { + if (e.type === 'input') { // ensure what has just been typed in the input is a number + // returns NaN if not a number + this._render_value(parsedValue); + if (parsedValue === 0) { + $input.select(); + } + } else { // Implicit type === 'blur': we commit the value + if (this.edit_max_value) { + parsedValue = parsedValue || 100; + } + + var $div = $('<div>', {class: 'o_progressbar_value'}); + this.$('.o_progressbar_value').replaceWith($div); + this.write_mode = false; + + this.on_update(parsedValue); + this._render_value(); + } + } + }, + /** + * Renders the value + * + * @private + * @param {Number} v + */ + _render_value: function (v) { + var value = this.value; + var max_value = this.max_value; + if (!isNaN(v)) { + if (this.edit_max_value) { + max_value = v; + } else { + value = v; + } + } + value = value || 0; + max_value = max_value || 0; + + var widthComplete; + if (value <= max_value) { + widthComplete = value/max_value * 100; + } else { + widthComplete = 100; + } + + this.$('.o_progress').toggleClass('o_progress_overflow', value > max_value) + .attr('aria-valuemin', '0') + .attr('aria-valuemax', max_value) + .attr('aria-valuenow', value); + this.$('.o_progressbar_complete').css('width', widthComplete + '%'); + + if (!this.write_mode) { + if (max_value !== 100) { + this.$('.o_progressbar_value').text(utils.human_number(value) + " / " + utils.human_number(max_value)); + } else { + this.$('.o_progressbar_value').text(utils.human_number(value) + "%"); + } + } else if (isNaN(v)) { + this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value); + this.$('.o_progressbar_value').focus().select(); + } + }, + /** + * The progress bar has more than one field/value to deal with + * i.e. max_value + * + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + var new_max_value = this.recordData[this.nodeOptions.max_value]; + this.max_value = new_max_value !== undefined ? new_max_value : this.max_value; + }, + isSet: function () { + return true; + }, +}); + +/** + * This widget is intended to be used on boolean fields. It toggles a button + * switching between a green bullet / gray bullet. +*/ +var FieldToggleBoolean = AbstractField.extend({ + description: _lt("Button"), + template: "toggle_button", + events: { + 'click': '_onToggleButton' + }, + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this.$('i') + .toggleClass('o_toggle_button_success', !!this.value) + .toggleClass('text-muted', !this.value); + var title = this.value ? this.attrs.options.active : this.attrs.options.inactive; + this.$el.attr('title', title); + this.$el.attr('aria-pressed', this.value); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Toggle the button + * + * @private + * @param {MouseEvent} event + */ + _onToggleButton: function (event) { + event.stopPropagation(); + this._setValue(!this.value); + }, +}); + +var JournalDashboardGraph = AbstractField.extend({ + className: "o_dashboard_graph", + jsLibs: [ + '/web/static/lib/Chart/Chart.js', + ], + init: function () { + this._super.apply(this, arguments); + this.graph_type = this.attrs.graph_type; + this.data = JSON.parse(this.value); + }, + /** + * The widget view uses the ChartJS lib to render the graph. This lib + * requires that the rendering is done directly into the DOM (so that it can + * correctly compute positions). However, the views are always rendered in + * fragments, and appended to the DOM once ready (to prevent them from + * flickering). We here use the on_attach_callback hook, called when the + * widget is attached to the DOM, to perform the rendering. This ensures + * that the rendering is always done in the DOM. + */ + on_attach_callback: function () { + this._isInDOM = true; + this._renderInDOM(); + }, + /** + * Called when the field is detached from the DOM. + */ + on_detach_callback: function () { + this._isInDOM = false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render the widget only when it is in the DOM. + * + * @override + * @private + */ + _render: function () { + if (this._isInDOM) { + return this._renderInDOM(); + } + return Promise.resolve(); + }, + /** + * Render the widget. This function assumes that it is attached to the DOM. + * + * @private + */ + _renderInDOM: function () { + this.$el.empty(); + var config, cssClass; + if (this.graph_type === 'line') { + config = this._getLineChartConfig(); + cssClass = 'o_graph_linechart'; + } else if (this.graph_type === 'bar') { + config = this._getBarChartConfig(); + cssClass = 'o_graph_barchart'; + } + this.$canvas = $('<canvas/>'); + this.$el.addClass(cssClass); + this.$el.empty(); + this.$el.append(this.$canvas); + var context = this.$canvas[0].getContext('2d'); + this.chart = new Chart(context, config); + }, + _getLineChartConfig: function () { + var labels = this.data[0].values.map(function (pt) { + return pt.x; + }); + var borderColor = this.data[0].is_sample_data ? '#dddddd' : '#875a7b'; + var backgroundColor = this.data[0].is_sample_data ? '#ebebeb' : '#dcd0d9'; + return { + type: 'line', + data: { + labels: labels, + datasets: [{ + data: this.data[0].values, + fill: 'start', + label: this.data[0].key, + backgroundColor: backgroundColor, + borderColor: borderColor, + borderWidth: 2, + }] + }, + options: { + legend: {display: false}, + scales: { + yAxes: [{display: false}], + xAxes: [{display: false}] + }, + maintainAspectRatio: false, + elements: { + line: { + tension: 0.000001 + } + }, + tooltips: { + intersect: false, + position: 'nearest', + caretSize: 0, + }, + }, + }; + }, + _getBarChartConfig: function () { + var data = []; + var labels = []; + var backgroundColor = []; + + this.data[0].values.forEach(function (pt) { + data.push(pt.value); + labels.push(pt.label); + var color = pt.type === 'past' ? '#ccbdc8' : (pt.type === 'future' ? '#a5d8d7' : '#ebebeb'); + backgroundColor.push(color); + }); + return { + type: 'bar', + data: { + labels: labels, + datasets: [{ + data: data, + fill: 'start', + label: this.data[0].key, + backgroundColor: backgroundColor, + }] + }, + options: { + legend: {display: false}, + scales: { + yAxes: [{display: false}], + }, + maintainAspectRatio: false, + tooltips: { + intersect: false, + position: 'nearest', + caretSize: 0, + }, + elements: { + line: { + tension: 0.000001 + } + }, + }, + }; + }, +}); + +/** + * The "Domain" field allows the user to construct a technical-prefix domain + * thanks to a tree-like interface and see the selected records in real time. + * In debug mode, an input is also there to be able to enter the prefix char + * domain directly (or to build advanced domains the tree-like interface does + * not allow to). + */ +var FieldDomain = AbstractField.extend({ + /** + * Fetches the number of records which are matched by the domain (if the + * domain is not server-valid, the value is false) and the model the + * field must work with. + */ + specialData: "_fetchSpecialDomain", + + events: _.extend({}, AbstractField.prototype.events, { + "click .o_domain_show_selection_button": "_onShowSelectionButtonClick", + "click .o_field_domain_dialog_button": "_onDialogEditButtonClick", + }), + custom_events: _.extend({}, AbstractField.prototype.custom_events, { + domain_changed: "_onDomainSelectorValueChange", + domain_selected: "_onDomainSelectorDialogValueChange", + open_record: "_onOpenRecord", + }), + /** + * @constructor + * @override init from AbstractField + */ + init: function () { + this._super.apply(this, arguments); + + this.inDialog = !!this.nodeOptions.in_dialog; + this.fsFilters = this.nodeOptions.fs_filters || {}; + + this.className = "o_field_domain"; + if (this.mode === "edit") { + this.className += " o_edit_mode"; + } + if (!this.inDialog) { + this.className += " o_inline_mode"; + } + + this._setState(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * A domain field is always set since the false value is considered to be + * equal to "[]" (match all records). + * + * @override + */ + isSet: function () { + return true; + }, + /** + * @override isValid from AbstractField.isValid + * Parsing the char value is not enough for this field. It is considered + * valid if the internal domain selector was built correctly and that the + * query to the model to test the domain did not fail. + * + * @returns {boolean} + */ + isValid: function () { + return ( + this._super.apply(this, arguments) + && (!this.domainSelector || this.domainSelector.isValid()) + && this._isValidForModel + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @override _render from AbstractField + * @returns {Promise} + */ + _render: function () { + // If there is no model, only change the non-domain-selector content + if (!this._domainModel) { + this._replaceContent(); + return Promise.resolve(); + } + + // Convert char value to array value + var value = this.value || "[]"; + + // Create the domain selector or change the value of the current one... + var def; + if (!this.domainSelector) { + this.domainSelector = new DomainSelector(this, this._domainModel, value, { + readonly: this.mode === "readonly" || this.inDialog, + filters: this.fsFilters, + debugMode: config.isDebug(), + }); + def = this.domainSelector.prependTo(this.$el); + } else { + def = this.domainSelector.setDomain(value); + } + // ... then replace the other content (matched records, etc) + return def.then(this._replaceContent.bind(this)); + }, + /** + * Render the field DOM except for the domain selector part. The full field + * DOM is composed of a DIV which contains the domain selector widget, + * followed by other content. This other content is handled by this method. + * + * @private + */ + _replaceContent: function () { + if (this._$content) { + this._$content.remove(); + } + this._$content = $(qweb.render("FieldDomain.content", { + hasModel: !!this._domainModel, + isValid: !!this._isValidForModel, + nbRecords: this.record.specialData[this.name].nbRecords || 0, + inDialogEdit: this.inDialog && this.mode === "edit", + })); + this._$content.appendTo(this.$el); + }, + /** + * @override _reset from AbstractField + * Check if the model the field works with has (to be) changed. + * + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + var oldDomainModel = this._domainModel; + this._setState(); + if (this.domainSelector && this._domainModel !== oldDomainModel) { + // If the model has changed, destroy the current domain selector + this.domainSelector.destroy(); + this.domainSelector = null; + } + }, + /** + * Sets the model the field must work with and whether or not the current + * domain value is valid for this particular model. This is inferred from + * the received special data. + * + * @private + */ + _setState: function () { + var specialData = this.record.specialData[this.name]; + this._domainModel = specialData.model; + this._isValidForModel = (specialData.nbRecords !== false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Show selection" button is clicked + * -> Open a modal to see the matched records + * + * @param {Event} e + */ + _onShowSelectionButtonClick: function (e) { + e.preventDefault(); + new view_dialogs.SelectCreateDialog(this, { + title: _t("Selected records"), + res_model: this._domainModel, + context: this.record.getContext({fieldName: this.name, viewType: this.viewType}), + domain: this.value || "[]", + no_create: true, + readonly: true, + disable_multiple_selection: true, + }).open(); + }, + /** + * Called when the "Edit domain" button is clicked (when using the in_dialog + * option) -> Open a DomainSelectorDialog to edit the value + * + * @param {Event} e + */ + _onDialogEditButtonClick: function (e) { + e.preventDefault(); + new DomainSelectorDialog(this, this._domainModel, this.value || "[]", { + readonly: this.mode === "readonly", + filters: this.fsFilters, + debugMode: config.isDebug(), + }).open(); + }, + /** + * Called when the domain selector value is changed (do nothing if it is the + * one which is in a dialog (@see _onDomainSelectorDialogValueChange)) + * -> Adapt the internal value state + * + * @param {OdooEvent} e + */ + _onDomainSelectorValueChange: function (e) { + if (this.inDialog) return; + this._setValue(Domain.prototype.arrayToString(this.domainSelector.getDomain())); + }, + /** + * Called when the in-dialog domain selector value is confirmed + * -> Adapt the internal value state + * + * @param {OdooEvent} e + */ + _onDomainSelectorDialogValueChange: function (e) { + this._setValue(Domain.prototype.arrayToString(e.data.domain)); + }, + /** + * Stops the propagation of the 'open_record' event, as we don't want the + * user to be able to open records from the list opened in a dialog. + * + * @param {OdooEvent} event + */ + _onOpenRecord: function (event) { + event.stopPropagation(); + }, +}); + +/** + * This widget is intended to be used on Text fields. It will provide Ace Editor + * for editing XML and Python. + */ +var AceEditor = DebouncedField.extend({ + template: "AceEditor", + jsLibs: [ + '/web/static/lib/ace/ace.js', + [ + '/web/static/lib/ace/mode-python.js', + '/web/static/lib/ace/mode-xml.js' + ] + ], + events: {}, // events are triggered manually for this debounced widget + /** + * @override start from AbstractField (Widget) + * + * @returns {Promise} + */ + start: function () { + this._startAce(this.$('.ace-view-editor')[0]); + return this._super.apply(this, arguments); + }, + /** + * @override destroy from AbstractField (Widget) + */ + destroy: function () { + if (this.aceEditor) { + this.aceEditor.destroy(); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Format value + * + * Note: We have to overwrite this method to always return a string. + * AceEditor works with string and not boolean value. + * + * @override + * @private + * @param {boolean|string} value + * @returns {string} + */ + _formatValue: function (value) { + return this._super.apply(this, arguments) || ''; + }, + + /** + * @override + * @private + */ + _getValue: function () { + return this.aceSession.getValue(); + }, + /** + * @override _render from AbstractField + * The rendering is the same for edit and readonly mode: changing the ace + * session value. This is only done if the value in the ace editor is not + * already the new one (prevent losing focus / retriggering changes / empty + * the undo stack / ...). + * + * @private + */ + _render: function () { + var newValue = this._formatValue(this.value); + if (this.aceSession.getValue() !== newValue) { + this.aceSession.setValue(newValue); + } + }, + + /** + * Starts the ace library on the given DOM element. This initializes the + * ace editor option according to the edit/readonly mode and binds ace + * editor events. + * + * @private + * @param {Node} node - the DOM element the ace library must initialize on + */ + _startAce: function (node) { + this.aceEditor = ace.edit(node); + this.aceEditor.setOptions({ + maxLines: Infinity, + showPrintMargin: false, + }); + if (this.mode === 'readonly') { + this.aceEditor.renderer.setOptions({ + displayIndentGuides: false, + showGutter: false, + }); + this.aceEditor.setOptions({ + highlightActiveLine: false, + highlightGutterLine: false, + readOnly: true, + }); + this.aceEditor.renderer.$cursorLayer.element.style.display = "none"; + } + this.aceEditor.$blockScrolling = true; + this.aceSession = this.aceEditor.getSession(); + this.aceSession.setOptions({ + useWorker: false, + mode: "ace/mode/" + (this.nodeOptions.mode || 'xml'), + tabSize: 2, + useSoftTabs: true, + }); + if (this.mode === "edit") { + this.aceEditor.on("change", this._doDebouncedAction.bind(this)); + this.aceEditor.on("blur", this._doAction.bind(this)); + } + }, +}); + + +/** + * The FieldColor widget give a visual representation of a color + * Clicking on it bring up an instance of ColorpickerDialog + */ +var FieldColor = AbstractField.extend({ + template: 'FieldColor', + events: _.extend({}, AbstractField.prototype.events, { + 'click .o_field_color': '_onColorClick', + }), + custom_events: _.extend({}, AbstractField.prototype.custom_events, { + 'colorpicker:saved': '_onColorpickerSaved', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getFocusableElement: function () { + return this.$('.o_field_color'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this.$('.o_field_color').data('value', this.value) + .css('background-color', this.value) + .attr('title', this.value); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onColorClick: function () { + if (this.mode === 'edit') { + const dialog = new ColorpickerDialog(this, { + defaultColor: this.value, + noTransparency: true, + }).open(); + dialog.on('closed', this, () => { + // we need to wait for the modal to execute its whole close function. + Promise.resolve().then(() => { + this.getFocusableElement().focus(); + }); + }); + } + }, + + /** + * @private + * @param {OdooEvent} ev + */ + _onColorpickerSaved: function (ev) { + this._setValue(ev.data.hex); + }, + + /** + * @override + * @private + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ENTER) { + ev.preventDefault(); + ev.stopPropagation(); + this._onColorClick(ev); + } else { + this._super.apply(this, arguments); + } + }, +}); + +var FieldColorPicker = FieldInteger.extend({ + RECORD_COLORS: [ + _t('No color'), + _t('Red'), + _t('Orange'), + _t('Yellow'), + _t('Light blue'), + _t('Dark purple'), + _t('Salmon pink'), + _t('Medium blue'), + _t('Dark blue'), + _t('Fushia'), + _t('Green'), + _t('Purple'), + ], + + /** + * Prepares the rendering, since we are based on an input but not using it + * setting tagName after parent init force the widget to not render an input + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.tagName = 'div'; + }, + /** + * Render the widget when it is edited. + * + * @override + */ + _renderEdit: function () { + this.$el.html(qweb.render('ColorPicker')); + this._setupColorPicker(); + this._highlightSelectedColor(); + }, + /** + * Render the widget when it is NOT edited. + * + * @override + */ + _renderReadonly: function () { + var selectedColorName = this.RECORD_COLORS[this.value]; + this.$el.html(qweb.render('ColorPickerReadonly', { active_color: this.value, name_color: selectedColorName })); + this.$el.on('click', 'a', function(ev){ ev.preventDefault(); }); + }, + /** + * Render the kanban colors inside first ul element. + * This is the same template as in KanbanRecord. + * + * <a> elements click are bound to _onColorChanged + * + */ + _setupColorPicker: function () { + var $colorpicker = this.$('ul'); + if (!$colorpicker.length) { + return; + } + $colorpicker.html(qweb.render('KanbanColorPicker', { colors: this.RECORD_COLORS })); + $colorpicker.on('click', 'a', this._onColorChanged.bind(this)); + }, + /** + * Returns the widget value. + * Since NumericField is based on an input, but we don't use it, + * we override this function to use the internal value of the widget. + * + * + * @override + * @returns {string} + */ + _getValue: function (){ + return this.value; + }, + /** + * Listener in edit mode for click on a color. + * The actual color can be found in the data-color + * attribute of the target element. + * + * We re-render the widget after the update because + * the selected color has changed and it should + * be reflected in the ui. + * + * @param ev + */ + _onColorChanged: function(ev) { + ev.preventDefault(); + var color = null; + if(ev.currentTarget && ev.currentTarget.dataset && ev.currentTarget.dataset.color){ + color = ev.currentTarget.dataset.color; + } + if(color){ + this.value = color; + this._onChange(); + this._renderEdit(); + } + }, + /** + * Helper to modify the active color's style + * while in edit mode. + * + */ + _highlightSelectedColor: function(){ + try{ + $(this.$('li')[parseInt(this.value)]).css('border', '2px solid teal'); + } catch(err) { + + } + }, + _onNavigationMove() { + // disable navigation from FieldInput, to prevent a crash + } +}); + +return { + TranslatableFieldMixin: TranslatableFieldMixin, + DebouncedField: DebouncedField, + FieldEmail: FieldEmail, + FieldBinaryFile: FieldBinaryFile, + FieldPdfViewer: FieldPdfViewer, + AbstractFieldBinary: AbstractFieldBinary, + FieldBinaryImage: FieldBinaryImage, + KanbanFieldBinaryImage: KanbanFieldBinaryImage, + CharImageUrl: CharImageUrl, + KanbanCharImageUrl: KanbanCharImageUrl, + FieldBoolean: FieldBoolean, + BooleanToggle: BooleanToggle, + FieldChar: FieldChar, + LinkButton: LinkButton, + FieldDate: FieldDate, + FieldDateTime: FieldDateTime, + FieldDateRange: FieldDateRange, + RemainingDays: RemainingDays, + FieldDomain: FieldDomain, + FieldFloat: FieldFloat, + FieldFloatTime: FieldFloatTime, + FieldFloatFactor: FieldFloatFactor, + FieldFloatToggle: FieldFloatToggle, + FieldPercentage: FieldPercentage, + FieldInteger: FieldInteger, + FieldMonetary: FieldMonetary, + FieldPercentPie: FieldPercentPie, + FieldPhone: FieldPhone, + FieldProgressBar: FieldProgressBar, + FieldText: FieldText, + ListFieldText: ListFieldText, + FieldToggleBoolean: FieldToggleBoolean, + HandleWidget: HandleWidget, + InputField: InputField, + NumericField: NumericField, + AttachmentImage: AttachmentImage, + LabelSelection: LabelSelection, + StateSelectionWidget: StateSelectionWidget, + FavoriteWidget: FavoriteWidget, + PriorityWidget: PriorityWidget, + StatInfo: StatInfo, + UrlWidget: UrlWidget, + TextCopyClipboard: TextCopyClipboard, + CharCopyClipboard: CharCopyClipboard, + JournalDashboardGraph: JournalDashboardGraph, + AceEditor: AceEditor, + FieldColor: FieldColor, + FieldColorPicker: FieldColorPicker, +}; + +}); diff --git a/addons/web/static/src/js/fields/basic_fields_owl.js b/addons/web/static/src/js/fields/basic_fields_owl.js new file mode 100644 index 00000000..507f883a --- /dev/null +++ b/addons/web/static/src/js/fields/basic_fields_owl.js @@ -0,0 +1,132 @@ +odoo.define('web.basic_fields_owl', function (require) { + "use strict"; + + const AbstractField = require('web.AbstractFieldOwl'); + const CustomCheckbox = require('web.CustomCheckbox'); + const { _lt } = require('web.translation'); + + + /** + * FieldBadge displays the field's value inside a bootstrap pill badge. + * The supported field's types are 'char', 'selection' and 'many2one'. + * + * By default, the background color of the badge is a light gray, but it can + * be customized by setting a 'decoration-xxx' attribute on the field. + * For instance, + * <field name="some_field" widget="badge" decoration-danger="state == 'cancel'"/> + * renders a badge with a red background on records matching the condition. + */ + class FieldBadge extends AbstractField { + _getClassFromDecoration(decoration) { + return `bg-${decoration.split('-')[1]}-light`; + } + } + FieldBadge.description = _lt("Badge"); + FieldBadge.supportedFieldTypes = ['selection', 'many2one', 'char']; + FieldBadge.template = 'web.FieldBadge'; + + + class FieldBoolean extends AbstractField { + patched() { + super.patched(); + if (this.props.event && this.props.event.target === this) { + this.activate(); + } + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + * @returns {HTMLElement|null} the focusable checkbox input + */ + get focusableElement() { + return this.mode === 'readonly' ? null : this.el.querySelector('input'); + } + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + get isSet() { + return true; + } + /** + * Toggle the checkbox if it is activated due to a click on itself. + * + * @override + * @param {Object} [options] + * @param {Event} [options.event] the event which fired this activation + * @returns {boolean} true if the component was activated, false if the + * focusable element was not found or invisible + */ + activate(options) { + const activated = super.activate(options); + // The event might have been fired on the non field version of + // this field, we can still test the presence of its custom class. + if (activated && options && options.event && options.event.target + .closest('.custom-control.custom-checkbox')) { + this._setValue(!this.value); // Toggle the checkbox + } + return activated; + } + /** + * Associates the 'for' attribute of the internal label. + * + * @override + */ + setIdForLabel(id) { + super.setIdForLabel(id); + this.el.querySelector('label').setAttribute('for', id); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Properly update the value when the checkbox is (un)ticked to trigger + * possible onchanges. + * + * @private + */ + _onChange(ev) { + this._setValue(ev.target.checked); + } + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @private + * @param {KeyEvent} ev + */ + _onKeydown(ev) { + switch (ev.which) { + case $.ui.keyCode.ENTER: + // prevent subsequent 'click' event (see _onKeydown of AbstractField) + ev.preventDefault(); + this._setValue(!this.value); + return; + case $.ui.keyCode.UP: + case $.ui.keyCode.RIGHT: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.LEFT: + ev.preventDefault(); + } + super._onKeydown(ev); + } + } + FieldBoolean.components = { CustomCheckbox }; + FieldBoolean.description = _lt("Checkbox"); + FieldBoolean.supportedFieldTypes = ['boolean']; + FieldBoolean.template = 'web.FieldBoolean'; + + + return { + FieldBadge, + FieldBoolean, + }; +}); diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js new file mode 100644 index 00000000..bb407cda --- /dev/null +++ b/addons/web/static/src/js/fields/field_registry.js @@ -0,0 +1,101 @@ +odoo.define('web.field_registry', function (require) { + "use strict"; + + const Registry = require('web.Registry'); + + return new Registry( + null, + (value) => !(value.prototype instanceof owl.Component) + ); +}); + +odoo.define('web._field_registry', function (require) { +"use strict"; + +var AbstractField = require('web.AbstractField'); +var basic_fields = require('web.basic_fields'); +var relational_fields = require('web.relational_fields'); +var registry = require('web.field_registry'); +var special_fields = require('web.special_fields'); + + +// Basic fields +registry + .add('abstract', AbstractField) + .add('input', basic_fields.InputField) + .add('integer', basic_fields.FieldInteger) + .add('boolean', basic_fields.FieldBoolean) + .add('date', basic_fields.FieldDate) + .add('datetime', basic_fields.FieldDateTime) + .add('daterange', basic_fields.FieldDateRange) + .add('remaining_days', basic_fields.RemainingDays) + .add('domain', basic_fields.FieldDomain) + .add('text', basic_fields.FieldText) + .add('list.text', basic_fields.ListFieldText) + .add('html', basic_fields.FieldText) + .add('float', basic_fields.FieldFloat) + .add('char', basic_fields.FieldChar) + .add('link_button', basic_fields.LinkButton) + .add('handle', basic_fields.HandleWidget) + .add('email', basic_fields.FieldEmail) + .add('phone', basic_fields.FieldPhone) + .add('url', basic_fields.UrlWidget) + .add('CopyClipboardText', basic_fields.TextCopyClipboard) + .add('CopyClipboardChar', basic_fields.CharCopyClipboard) + .add('image', basic_fields.FieldBinaryImage) + .add('image_url', basic_fields.CharImageUrl) + .add('kanban.image', basic_fields.KanbanFieldBinaryImage) + .add('kanban.image_url', basic_fields.KanbanCharImageUrl) + .add('binary', basic_fields.FieldBinaryFile) + .add('pdf_viewer', basic_fields.FieldPdfViewer) + .add('monetary', basic_fields.FieldMonetary) + .add('percentage', basic_fields.FieldPercentage) + .add('priority', basic_fields.PriorityWidget) + .add('attachment_image', basic_fields.AttachmentImage) + .add('label_selection', basic_fields.LabelSelection) + .add('kanban_label_selection', basic_fields.LabelSelection) // deprecated, use label_selection + .add('state_selection', basic_fields.StateSelectionWidget) + .add('kanban_state_selection', basic_fields.StateSelectionWidget) // deprecated, use state_selection + .add('boolean_favorite', basic_fields.FavoriteWidget) + .add('boolean_toggle', basic_fields.BooleanToggle) + .add('statinfo', basic_fields.StatInfo) + .add('percentpie', basic_fields.FieldPercentPie) + .add('float_time', basic_fields.FieldFloatTime) + .add('float_factor', basic_fields.FieldFloatFactor) + .add('float_toggle', basic_fields.FieldFloatToggle) + .add('progressbar', basic_fields.FieldProgressBar) + .add('toggle_button', basic_fields.FieldToggleBoolean) + .add('dashboard_graph', basic_fields.JournalDashboardGraph) + .add('ace', basic_fields.AceEditor) + .add('color', basic_fields.FieldColor) + .add('many2one_reference', basic_fields.FieldInteger) + .add('color_picker', basic_fields.FieldColorPicker); + +// Relational fields +registry + .add('selection', relational_fields.FieldSelection) + .add('radio', relational_fields.FieldRadio) + .add('selection_badge', relational_fields.FieldSelectionBadge) + .add('many2one', relational_fields.FieldMany2One) + .add('many2one_barcode', relational_fields.Many2oneBarcode) + .add('list.many2one', relational_fields.ListFieldMany2One) + .add('kanban.many2one', relational_fields.KanbanFieldMany2One) + .add('many2one_avatar', relational_fields.Many2OneAvatar) + .add('many2many', relational_fields.FieldMany2Many) + .add('many2many_binary', relational_fields.FieldMany2ManyBinaryMultiFiles) + .add('many2many_tags', relational_fields.FieldMany2ManyTags) + .add('many2many_tags_avatar', relational_fields.FieldMany2ManyTagsAvatar) + .add('form.many2many_tags', relational_fields.FormFieldMany2ManyTags) + .add('kanban.many2many_tags', relational_fields.KanbanFieldMany2ManyTags) + .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes) + .add('one2many', relational_fields.FieldOne2Many) + .add('statusbar', relational_fields.FieldStatus) + .add('reference', relational_fields.FieldReference) + .add('font', relational_fields.FieldSelectionFont); + +// Special fields +registry + .add('timezone_mismatch', special_fields.FieldTimezoneMismatch) + .add('report_layout', special_fields.FieldReportLayout) + .add('iframe_wrapper', special_fields.IframeWrapper) +}); diff --git a/addons/web/static/src/js/fields/field_registry_owl.js b/addons/web/static/src/js/fields/field_registry_owl.js new file mode 100644 index 00000000..635db621 --- /dev/null +++ b/addons/web/static/src/js/fields/field_registry_owl.js @@ -0,0 +1,26 @@ +odoo.define('web.field_registry_owl', function (require) { + "use strict"; + + const Registry = require('web.Registry'); + + return new Registry( + null, + (value) => value.prototype instanceof owl.Component + ); +}); + +odoo.define('web._field_registry_owl', function (require) { + "use strict"; + + /** + * This module registers field components (specifications of the AbstractField Component) + */ + + const basicFields = require('web.basic_fields_owl'); + const registry = require('web.field_registry_owl'); + + // Basic fields + registry + .add('badge', basicFields.FieldBadge) + .add('boolean', basicFields.FieldBoolean); +}); diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js new file mode 100644 index 00000000..beba1b07 --- /dev/null +++ b/addons/web/static/src/js/fields/field_utils.js @@ -0,0 +1,762 @@ +odoo.define('web.field_utils', function (require) { +"use strict"; + +/** + * Field Utils + * + * This file contains two types of functions: formatting functions and parsing + * functions. + * + * Each field type has to display in string form at some point, but it should be + * stored in memory with the actual value. For example, a float value of 0.5 is + * represented as the string "0.5" but is kept in memory as a float. A date + * (or datetime) value is always stored as a Moment.js object, but displayed as + * a string. This file contains all sort of functions necessary to perform the + * conversions. + */ + +var core = require('web.core'); +var dom = require('web.dom'); +var session = require('web.session'); +var time = require('web.time'); +var utils = require('web.utils'); + +var _t = core._t; + +//------------------------------------------------------------------------------ +// Formatting +//------------------------------------------------------------------------------ + +/** + * Convert binary to bin_size + * + * @param {string} [value] base64 representation of the binary (might be already a bin_size!) + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options (note: this parameter is ignored) + * + * @returns {string} bin_size (which is human-readable) + */ +function formatBinary(value, field, options) { + if (!value) { + return ''; + } + return utils.binaryToBinsize(value); +} + +/** + * @todo Really? it returns a jQuery element... We should try to avoid this and + * let DOM utility functions handle this directly. And replace this with a + * function that returns a string so we can get rid of the forceString. + * + * @param {boolean} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.forceString=false] if true, returns a string +* representation of the boolean rather than a jQueryElement + * @returns {jQuery|string} + */ +function formatBoolean(value, field, options) { + if (options && options.forceString) { + return value ? _t('True') : _t('False'); + } + return dom.renderCheckbox({ + prop: { + checked: value, + disabled: true, + }, + }); +} + +/** + * Returns a string representing a char. If the value is false, then we return + * an empty string. + * + * @param {string|false} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.escape=false] if true, escapes the formatted value + * @param {boolean} [options.isPassword=false] if true, returns '********' + * instead of the formatted value + * @returns {string} + */ +function formatChar(value, field, options) { + value = typeof value === 'string' ? value : ''; + if (options && options.isPassword) { + return _.str.repeat('*', value ? value.length : 0); + } + if (options && options.escape) { + value = _.escape(value); + } + return value; +} + +/** + * Returns a string representing a date. If the value is false, then we return + * an empty string. Note that this is dependant on the localization settings + * + * @param {Moment|false} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.timezone=true] use the user timezone when formating the + * date + * @returns {string} + */ +function formatDate(value, field, options) { + if (value === false || isNaN(value)) { + return ""; + } + if (field && field.type === 'datetime') { + if (!options || !('timezone' in options) || options.timezone) { + value = value.clone().add(session.getTZOffset(value), 'minutes'); + } + } + var date_format = time.getLangDateFormat(); + return value.format(date_format); +} + +/** + * Returns a string representing a datetime. If the value is false, then we + * return an empty string. Note that this is dependant on the localization + * settings + * + * @params {Moment|false} + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.timezone=true] use the user timezone when formating the + * date + * @returns {string} + */ +function formatDateTime(value, field, options) { + if (value === false) { + return ""; + } + if (!options || !('timezone' in options) || options.timezone) { + value = value.clone().add(session.getTZOffset(value), 'minutes'); + } + return value.format(time.getLangDatetimeFormat()); +} + +/** + * Returns a string representing a float. The result takes into account the + * user settings (to display the correct decimal separator). + * + * @param {float|false} value the value that should be formatted + * @param {Object} [field] a description of the field (returned by fields_get + * for example). It may contain a description of the number of digits that + * should be used. + * @param {Object} [options] additional options to override the values in the + * python description of the field. + * @param {integer[]} [options.digits] the number of digits that should be used, + * instead of the default digits precision in the field. + * @param {function} [options.humanReadable] if returns true, + * formatFloat acts like utils.human_number + * @returns {string} + */ +function formatFloat(value, field, options) { + options = options || {}; + if (value === false) { + return ""; + } + if (options.humanReadable && options.humanReadable(value)) { + return utils.human_number(value, options.decimals, options.minDigits, options.formatterCallback); + } + var l10n = core._t.database.parameters; + var precision; + if (options.digits) { + precision = options.digits[1]; + } else if (field && field.digits) { + precision = field.digits[1]; + } else { + precision = 2; + } + var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + return formatted.join(l10n.decimal_point); +} + + +/** + * Returns a string representing a float value, from a float converted with a + * factor. + * + * @param {number} value + * @param {number} [options.factor] + * Conversion factor, default value is 1.0 + * @returns {string} + */ +function formatFloatFactor(value, field, options) { + var factor = options.factor || 1; + return formatFloat(value * factor, field, options); +} + +/** + * Returns a string representing a time value, from a float. The idea is that + * we sometimes want to display something like 1:45 instead of 1.75, or 0:15 + * instead of 0.25. + * + * @param {float} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] + * @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30 + * otherwise, format like 01:30 + * @returns {string} + */ +function formatFloatTime(value, field, options) { + options = options || {}; + var pattern = options.noLeadingZeroHour ? '%1d:%02d' : '%02d:%02d'; + if (value < 0) { + value = Math.abs(value); + pattern = '-' + pattern; + } + var hour = Math.floor(value); + var min = Math.round((value % 1) * 60); + if (min === 60){ + min = 0; + hour = hour + 1; + } + return _.str.sprintf(pattern, hour, min); +} + +/** + * Returns a string representing an integer. If the value is false, then we + * return an empty string. + * + * @param {integer|false} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.isPassword=false] if true, returns '********' + * @param {function} [options.humanReadable] if returns true, + * formatFloat acts like utils.human_number + * @returns {string} + */ +function formatInteger(value, field, options) { + options = options || {}; + if (options.isPassword) { + return _.str.repeat('*', String(value).length); + } + if (!value && value !== 0) { + // previously, it returned 'false'. I don't know why. But for the Pivot + // view, I want to display the concept of 'no value' with an empty + // string. + return ""; + } + if (options.humanReadable && options.humanReadable(value)) { + return utils.human_number(value, options.decimals, options.minDigits, options.formatterCallback); + } + return utils.insert_thousand_seps(_.str.sprintf('%d', value)); +} + +/** + * Returns a string representing an many2one. If the value is false, then we + * return an empty string. Note that it accepts two types of input parameters: + * an array, in that case we assume that the many2one value is of the form + * [id, nameget], and we return the nameget, or it can be an object, and in that + * case, we assume that it is a record datapoint from a BasicModel. + * + * @param {Array|Object|false} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.escape=false] if true, escapes the formatted value + * @returns {string} + */ +function formatMany2one(value, field, options) { + if (!value) { + value = ''; + } else if (_.isArray(value)) { + // value is a pair [id, nameget] + value = value[1]; + } else { + // value is a datapoint, so we read its display_name field, which + // may in turn be a datapoint (if the name field is a many2one) + while (value.data) { + value = value.data.display_name || ''; + } + } + if (options && options.escape) { + value = _.escape(value); + } + return value; +} + +/** + * Returns a string indicating the number of records in the relation. + * + * @param {Object} value a valid element from a BasicModel, that represents a + * list of values + * @returns {string} + */ +function formatX2Many(value) { + if (value.data.length === 0) { + return _t('No records'); + } else if (value.data.length === 1) { + return _t('1 record'); + } else { + return value.data.length + _t(' records'); + } +} + +/** + * Returns a string representing a monetary value. The result takes into account + * the user settings (to display the correct decimal separator, currency, ...). + * + * @param {float|false} value the value that should be formatted + * @param {Object} [field] + * a description of the field (returned by fields_get for example). It + * may contain a description of the number of digits that should be used. + * @param {Object} [options] + * additional options to override the values in the python description of + * the field. + * @param {Object} [options.currency] the description of the currency to use + * @param {integer} [options.currency_id] + * the id of the 'res.currency' to use (ignored if options.currency) + * @param {string} [options.currency_field] + * the name of the field whose value is the currency id + * (ignore if options.currency or options.currency_id) + * Note: if not given it will default to the field currency_field value + * or to 'currency_id'. + * @param {Object} [options.data] + * a mapping of field name to field value, required with + * options.currency_field + * @param {integer[]} [options.digits] + * the number of digits that should be used, instead of the default + * digits precision in the field. Note: if the currency defines a + * precision, the currency's one is used. + * @param {boolean} [options.forceString=false] + * if false, returns a string encoding the html formatted value (with + * whitespace encoded as ' ') + * @returns {string} + */ +function formatMonetary(value, field, options) { + if (value === false) { + return ""; + } + options = Object.assign({ forceString: false }, options); + + var currency = options.currency; + if (!currency) { + var currency_id = options.currency_id; + if (!currency_id && options.data) { + var currency_field = options.currency_field || field.currency_field || 'currency_id'; + currency_id = options.data[currency_field] && options.data[currency_field].res_id; + } + currency = session.get_currency(currency_id); + } + + var digits = (currency && currency.digits) || options.digits; + if (options.field_digits === true) { + digits = field.digits || digits; + } + var formatted_value = formatFloat(value, field, + _.extend({}, options , {digits: digits}) + ); + + if (!currency || options.noSymbol) { + return formatted_value; + } + const ws = options.forceString ? ' ' : ' '; + if (currency.position === "after") { + return formatted_value + ws + currency.symbol; + } else { + return currency.symbol + ws + formatted_value; + } +} +/** + * Returns a string representing the given value (multiplied by 100) + * concatenated with '%'. + * + * @param {number | false} value + * @param {Object} [field] + * @param {Object} [options] + * @param {function} [options.humanReadable] if returns true, parsing is avoided + * @returns {string} + */ +function formatPercentage(value, field, options) { + options = options || {}; + let result = formatFloat(value * 100, field, options) || '0'; + if (!options.humanReadable || !options.humanReadable(value * 100)) { + result = parseFloat(result).toString().replace('.', _t.database.parameters.decimal_point); + } + return result + (options.noSymbol ? '' : '%'); +} +/** + * Returns a string representing the value of the selection. + * + * @param {string|false} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.escape=false] if true, escapes the formatted value + */ +function formatSelection(value, field, options) { + var val = _.find(field.selection, function (option) { + return option[0] === value; + }); + if (!val) { + return ''; + } + value = val[1]; + if (options && options.escape) { + value = _.escape(value); + } + return value; +} + +//////////////////////////////////////////////////////////////////////////////// +// Parse +//////////////////////////////////////////////////////////////////////////////// + +/** + * Smart date inputs are shortcuts to write dates quicker. + * These shortcuts should respect the format ^[+-]\d+[dmwy]?$ + * + * e.g. + * "+1d" or "+1" will return now + 1 day + * "-2w" will return now - 2 weeks + * "+3m" will return now + 3 months + * "-4y" will return now + 4 years + * + * @param {string} value + * @returns {Moment|false} Moment date object + */ +function parseSmartDateInput(value) { + const units = { + d: 'days', + m: 'months', + w: 'weeks', + y: 'years', + }; + const re = new RegExp(`^([+-])(\\d+)([${Object.keys(units).join('')}]?)$`); + const match = re.exec(value); + if (match) { + let date = moment(); + const offset = parseInt(match[2], 10); + const unit = units[match[3] || 'd']; + if (match[1] === '+') { + date.add(offset, unit); + } else { + date.subtract(offset, unit); + } + return date; + } + return false; +} + +/** + * Create an Date object + * The method toJSON return the formated value to send value server side + * + * @param {string} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.isUTC] the formatted date is utc + * @param {boolean} [options.timezone=false] format the date after apply the timezone + * offset + * @returns {Moment|false} Moment date object + */ +function parseDate(value, field, options) { + if (!value) { + return false; + } + var datePattern = time.getLangDateFormat(); + var datePatternWoZero = time.getLangDateFormatWoZero(); + var date; + const smartDate = parseSmartDateInput(value); + if (smartDate) { + date = smartDate; + } else { + if (options && options.isUTC) { + value = value.padStart(10, "0"); // server may send "932-10-10" for "0932-10-10" on some OS + date = moment.utc(value); + } else { + date = moment.utc(value, [datePattern, datePatternWoZero, moment.ISO_8601]); + } + } + if (date.isValid()) { + if (date.year() === 0) { + date.year(moment.utc().year()); + } + if (date.year() >= 1000){ + date.toJSON = function () { + return this.clone().locale('en').format('YYYY-MM-DD'); + }; + return date; + } + } + throw new Error(_.str.sprintf(core._t("'%s' is not a correct date"), value)); +} + +/** + * Create an Date object + * The method toJSON return the formated value to send value server side + * + * @param {string} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + * @param {Object} [options] additional options + * @param {boolean} [options.isUTC] the formatted date is utc + * @param {boolean} [options.timezone=false] format the date after apply the timezone + * offset + * @returns {Moment|false} Moment date object + */ +function parseDateTime(value, field, options) { + if (!value) { + return false; + } + const datePattern = time.getLangDateFormat(); + const timePattern = time.getLangTimeFormat(); + const datePatternWoZero = time.getLangDateFormatWoZero(); + const timePatternWoZero = time.getLangTimeFormatWoZero(); + var pattern1 = datePattern + ' ' + timePattern; + var pattern2 = datePatternWoZero + ' ' + timePatternWoZero; + var datetime; + const smartDate = parseSmartDateInput(value); + if (smartDate) { + datetime = smartDate; + } else { + if (options && options.isUTC) { + value = value.padStart(19, "0"); // server may send "932-10-10" for "0932-10-10" on some OS + // phatomjs crash if we don't use this format + datetime = moment.utc(value.replace(' ', 'T') + 'Z'); + } else { + datetime = moment.utc(value, [pattern1, pattern2, moment.ISO_8601]); + if (options && options.timezone) { + datetime.add(-session.getTZOffset(datetime), 'minutes'); + } + } + } + if (datetime.isValid()) { + if (datetime.year() === 0) { + datetime.year(moment.utc().year()); + } + if (datetime.year() >= 1000) { + datetime.toJSON = function () { + return this.clone().locale('en').format('YYYY-MM-DD HH:mm:ss'); + }; + return datetime; + } + } + throw new Error(_.str.sprintf(core._t("'%s' is not a correct datetime"), value)); +} + +/** + * Parse a String containing number in language formating + * + * @param {string} value + * The string to be parsed with the setting of thousands and + * decimal separator + * @returns {float|NaN} the number value contained in the string representation + */ +function parseNumber(value) { + if (core._t.database.parameters.thousands_sep) { + var escapedSep = _.str.escapeRegExp(core._t.database.parameters.thousands_sep); + value = value.replace(new RegExp(escapedSep, 'g'), ''); + } + if (core._t.database.parameters.decimal_point) { + value = value.replace(core._t.database.parameters.decimal_point, '.'); + } + return Number(value); +} + +/** + * Parse a String containing float in language formating + * + * @param {string} value + * The string to be parsed with the setting of thousands and + * decimal separator + * @returns {float} + * @throws {Error} if no float is found respecting the language configuration + */ +function parseFloat(value) { + var parsed = parseNumber(value); + if (isNaN(parsed)) { + throw new Error(_.str.sprintf(core._t("'%s' is not a correct float"), value)); + } + return parsed; +} + +/** + * Parse a String containing currency symbol and returns amount + * + * @param {string} value + * The string to be parsed + * We assume that a monetary is always a pair (symbol, amount) separated + * by a non breaking space. A simple float can also be accepted as value + * @param {Object} [field] + * a description of the field (returned by fields_get for example). + * @param {Object} [options] additional options. + * @param {Object} [options.currency] - the description of the currency to use + * @param {integer} [options.currency_id] + * the id of the 'res.currency' to use (ignored if options.currency) + * @param {string} [options.currency_field] + * the name of the field whose value is the currency id + * (ignore if options.currency or options.currency_id) + * Note: if not given it will default to the field currency_field value + * or to 'currency_id'. + * @param {Object} [options.data] + * a mapping of field name to field value, required with + * options.currency_field + * + * @returns {float} the float value contained in the string representation + * @throws {Error} if no float is found or if parameter does not respect monetary condition + */ +function parseMonetary(value, field, options) { + var values = value.split(' '); + if (values.length === 1) { + return parseFloat(value); + } + else if (values.length !== 2) { + throw new Error(_.str.sprintf(core._t("'%s' is not a correct monetary field"), value)); + } + options = options || {}; + var currency = options.currency; + if (!currency) { + var currency_id = options.currency_id; + if (!currency_id && options.data) { + var currency_field = options.currency_field || field.currency_field || 'currency_id'; + currency_id = options.data[currency_field] && options.data[currency_field].res_id; + } + currency = session.get_currency(currency_id); + } + return parseFloat(values[0] === currency.symbol ? values[1] : values[0]); +} + +/** + * Parse a String containing float and unconvert it with a conversion factor + * + * @param {number} [options.factor] + * Conversion factor, default value is 1.0 + */ +function parseFloatFactor(value, field, options) { + var parsed = parseFloat(value); + var factor = options.factor || 1.0; + return parsed / factor; +} + +function parseFloatTime(value) { + var factor = 1; + if (value[0] === '-') { + value = value.slice(1); + factor = -1; + } + var float_time_pair = value.split(":"); + if (float_time_pair.length !== 2) + return factor * parseFloat(value); + var hours = parseInteger(float_time_pair[0]); + var minutes = parseInteger(float_time_pair[1]); + return factor * (hours + (minutes / 60)); +} + +/** + * Parse a String containing float and unconvert it with a conversion factor + * of 100. The percentage can be a regular xx.xx float or a xx%. + * + * @param {string} value + * The string to be parsed + * @returns {float} + * @throws {Error} if the value couldn't be converted to float + */ +function parsePercentage(value) { + return parseFloat(value) / 100; +} + +/** + * Parse a String containing integer with language formating + * + * @param {string} value + * The string to be parsed with the setting of thousands and + * decimal separator + * @returns {integer} + * @throws {Error} if no integer is found respecting the language configuration + */ +function parseInteger(value) { + var parsed = parseNumber(value); + // do not accept not numbers or float values + if (isNaN(parsed) || parsed % 1 || parsed < -2147483648 || parsed > 2147483647) { + throw new Error(_.str.sprintf(core._t("'%s' is not a correct integer"), value)); + } + return parsed; +} + +/** + * Creates an object with id and display_name. + * + * @param {Array|number|string|Object} value + * The given value can be : + * - an array with id as first element and display_name as second element + * - a number or a string representing the id (the display_name will be + * returned as undefined) + * - an object, simply returned untouched + * @returns {Object} (contains the id and display_name) + * Note: if the given value is not an array, a string or a + * number, the value is returned untouched. + */ +function parseMany2one(value) { + if (_.isArray(value)) { + return { + id: value[0], + display_name: value[1], + }; + } + if (_.isNumber(value) || _.isString(value)) { + return { + id: parseInt(value, 10), + }; + } + return value; +} + +return { + format: { + binary: formatBinary, + boolean: formatBoolean, + char: formatChar, + date: formatDate, + datetime: formatDateTime, + float: formatFloat, + float_factor: formatFloatFactor, + float_time: formatFloatTime, + html: _.identity, // todo + integer: formatInteger, + many2many: formatX2Many, + many2one: formatMany2one, + many2one_reference: formatInteger, + monetary: formatMonetary, + one2many: formatX2Many, + percentage: formatPercentage, + reference: formatMany2one, + selection: formatSelection, + text: formatChar, + }, + parse: { + binary: _.identity, + boolean: _.identity, // todo + char: _.identity, // todo + date: parseDate, // todo + datetime: parseDateTime, // todo + float: parseFloat, + float_factor: parseFloatFactor, + float_time: parseFloatTime, + html: _.identity, // todo + integer: parseInteger, + many2many: _.identity, // todo + many2one: parseMany2one, + many2one_reference: parseInteger, + monetary: parseMonetary, + one2many: _.identity, + percentage: parsePercentage, + reference: parseMany2one, + selection: _.identity, // todo + text: _.identity, // todo + }, +}; + +}); diff --git a/addons/web/static/src/js/fields/field_wrapper.js b/addons/web/static/src/js/fields/field_wrapper.js new file mode 100644 index 00000000..ad32d046 --- /dev/null +++ b/addons/web/static/src/js/fields/field_wrapper.js @@ -0,0 +1,157 @@ +odoo.define('web.FieldWrapper', function (require) { + "use strict"; + + const { ComponentWrapper } = require('web.OwlCompatibility'); + const field_utils = require('web.field_utils'); + + /** + * This file defines the FieldWrapper component, an extension of ComponentWrapper, + * needed to instanciate Owl fields inside legacy widgets. This component + * will be no longer necessary as soon as all legacy widgets using fields will + * be rewritten in Owl. + */ + class FieldWrapper extends ComponentWrapper { + constructor() { + super(...arguments); + + this._data = {}; + + const options = this.props.options || {}; + const record = this.props.record; + this._data.name = this.props.fieldName; + this._data.record = record; + this._data.field = record.fields[this._data.name]; + this._data.viewType = options.viewType || 'default'; + const fieldsInfo = record.fieldsInfo[this._data.viewType]; + this._data.attrs = options.attrs || (fieldsInfo && fieldsInfo[this._data.name]) || {}; + this._data.additionalContext = options.additionalContext || {}; + this._data.value = record.data[this._data.name]; + this._data.recordData = record.data; + this._data.string = this._data.attrs.string || this._data.field.string || this._data.name; + this._data.nodeOptions = this._data.attrs.options || {}; + this._data.dataPointID = record.id; + this._data.res_id = record.res_id; + this._data.model = record.model; + this._data.mode = options.mode || "readonly"; + this._data.formatType = this._data.attrs.widget in field_utils.format ? + this._data.attrs.widget : + this._data.field.type; + this._data.formatOptions = {}; + this._data.parseOptions = {}; + if (this._data.attrs.decorations) { + this._data.resetOnAnyFieldChange = true; + } + + for (let key in this._data) { + Object.defineProperty(this, key, { + get: () => { + if (this.el) { + if (key === 'dataPointID') { + return this.componentRef.comp.dataPointId; + } else if (key === 'res_id') { + return this.componentRef.comp.resId; + } + } + return (this.el ? this.componentRef.comp : this._data)[key]; + }, + }); + } + } + + /** + * Renderers set the '__node' attribute on fields they instantiate. It + * is used for instance to evaluate modifiers on multi-edition. In this + * case, the controller reads this property on the target of the event. + * However, with Owl field Components, it is set on the FieldWrapper, + * not the real field Component, which triggers the 'field-changed' + * event. This function writes the attribute on that field Component. + */ + mounted() { + super.mounted(...arguments); + this.componentRef.comp.__node = this.__node; + } + + //---------------------------------------------------------------------- + // Getters + //---------------------------------------------------------------------- + + get $el() { + return $(this.el); + } + get fieldDependencies() { + return this.Component.fieldDependencies; + } + get specialData() { + return this.Component.specialData; + } + get supportedFieldTypes() { + return this.Component.supportedFieldTypes; + } + get description() { + return this.Component.description; + } + get noLabel() { + return this.Component.noLabel; + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + activate() { + return this.componentRef.comp.activate(...arguments); + } + commitChanges() { + return this.componentRef.comp.commitChanges(...arguments); + } + getFocusableElement() { + return $(this.componentRef.comp.focusableElement); + } + isEmpty() { + return this.componentRef.comp.isEmpty; + } + isFocusable() { + return this.componentRef.comp.isFocusable; + } + isSet() { + if (this.componentRef.comp) { + return this.componentRef.comp.isSet; + } + // because of the willStart, the real field component may not be + // instantiated yet when the renderer first asks if it is set + // (only the wrapper is instantiated), so we instantiate one + // with the same props, get its 'isSet' status, and destroy it. + const c = new this.Component(null, this.props); + const isSet = c.isSet; + c.destroy(); + return isSet; + } + isValid() { + return this.componentRef.comp.isValid; + } + removeInvalidClass() { + return this.componentRef.comp.removeInvalidClass(...arguments); + } + reset(record, event) { + return this.update({record, event}); + } + setIDForLabel() { + return this.componentRef.comp.setIdForLabel(...arguments); + } + setInvalidClass() { + return this.componentRef.comp.setInvalidClass(...arguments); + } + updateModifiersValue(modifiers) { + if (this.props.options.attrs) { + this.props.options.attrs.modifiersValue = modifiers || {}; + } else { + const viewType = this.props.options.viewType || 'default'; + const fieldsInfo = this.props.record.fieldsInfo[viewType]; + fieldsInfo[this.props.fieldName].modifiersValue = modifiers || {}; + } + this.componentRef.comp.props = this.props; + } + } + + return FieldWrapper; +}); diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js new file mode 100644 index 00000000..481ba16f --- /dev/null +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -0,0 +1,3460 @@ +odoo.define('web.relational_fields', function (require) { +"use strict"; + +/** + * Relational Fields + * + * In this file, we have a collection of various relational field widgets. + * Relational field widgets are more difficult to use/manipulate, because the + * relations add a level of complexity: a value is not a basic type, it can be + * a collection of other records. + * + * Also, the way relational fields are edited is more complex. We can change + * the corresponding record(s), or alter some of their fields. + */ + +var AbstractField = require('web.AbstractField'); +var basicFields = require('web.basic_fields'); +var concurrency = require('web.concurrency'); +const ControlPanelX2Many = require('web.ControlPanelX2Many'); +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); +var dom = require('web.dom'); +const Domain = require('web.Domain'); +var KanbanRecord = require('web.KanbanRecord'); +var KanbanRenderer = require('web.KanbanRenderer'); +var ListRenderer = require('web.ListRenderer'); +const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility'); +const { sprintf } = require("web.utils"); + +const { escape } = owl.utils; +var _t = core._t; +var _lt = core._lt; +var qweb = core.qweb; + +//------------------------------------------------------------------------------ +// Many2one widgets +//------------------------------------------------------------------------------ + +var M2ODialog = Dialog.extend({ + template: "M2ODialog", + init: function (parent, name, value) { + this.name = name; + this.value = value; + this._super(parent, { + title: _.str.sprintf(_t("New %s"), this.name), + size: 'medium', + buttons: [{ + text: _t('Create'), + classes: 'btn-primary', + close: true, + click: function () { + this.trigger_up('quick_create', { value: this.value }); + }, + }, { + text: _t('Create and edit'), + classes: 'btn-primary', + close: true, + click: function () { + this.trigger_up('search_create_popup', { + view_type: 'form', + value: this.value, + }); + }, + }, { + text: _t('Cancel'), + close: true, + }], + }); + }, + /** + * @override + * @param {boolean} isSet + */ + close: function (isSet) { + this.isSet = isSet; + this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + if (!this.isSet) { + this.trigger_up('closed_unset'); + } + this._super.apply(this, arguments); + }, +}); + +var FieldMany2One = AbstractField.extend({ + description: _lt("Many2one"), + supportedFieldTypes: ['many2one'], + template: 'FieldMany2One', + custom_events: _.extend({}, AbstractField.prototype.custom_events, { + 'closed_unset': '_onDialogClosedUnset', + 'field_changed': '_onFieldChanged', + 'quick_create': '_onQuickCreate', + 'search_create_popup': '_onSearchCreatePopup', + }), + events: _.extend({}, AbstractField.prototype.events, { + 'click input': '_onInputClick', + 'focusout input': '_onInputFocusout', + 'keyup input': '_onInputKeyup', + 'click .o_external_button': '_onExternalButtonClick', + 'click': '_onClick', + }), + AUTOCOMPLETE_DELAY: 200, + SEARCH_MORE_LIMIT: 320, + + /** + * @override + * @param {boolean} [options.noOpen=false] if true, there is no external + * button to open the related record in a dialog + * @param {boolean} [options.noCreate=false] if true, the many2one does not + * allow to create records + */ + init: function (parent, name, record, options) { + options = options || {}; + this._super.apply(this, arguments); + this.limit = 7; + this.orderer = new concurrency.DropMisordered(); + + // should normally be set, except in standalone M20 + const canCreate = 'can_create' in this.attrs ? JSON.parse(this.attrs.can_create) : true; + this.can_create = canCreate && !this.nodeOptions.no_create && !options.noCreate; + this.can_write = 'can_write' in this.attrs ? JSON.parse(this.attrs.can_write) : true; + + this.nodeOptions = _.defaults(this.nodeOptions, { + quick_create: true, + }); + this.noOpen = 'noOpen' in options ? options.noOpen : this.nodeOptions.no_open; + this.m2o_value = this._formatValue(this.value); + // 'recordParams' is a dict of params used when calling functions + // 'getDomain' and 'getContext' on this.record + this.recordParams = {fieldName: this.name, viewType: this.viewType}; + // We need to know if the widget is dirty (i.e. if the user has changed + // the value, and those changes haven't been acknowledged yet by the + // environment), to prevent erasing that new value on a reset (e.g. + // coming by an onchange on another field) + this.isDirty = false; + this.lastChangeEvent = undefined; + + // List of autocomplete sources + this._autocompleteSources = []; + // Add default search method for M20 (name_search) + this._addAutocompleteSource(this._search, {placeholder: _t('Loading...'), order: 1}); + + // use a DropPrevious to properly handle related record quick creations, + // and store a createDef to be able to notify the environment that there + // is pending quick create operation + this.dp = new concurrency.DropPrevious(); + this.createDef = undefined; + }, + start: function () { + // booleean indicating that the content of the input isn't synchronized + // with the current m2o value (for instance, the user is currently + // typing something in the input, and hasn't selected a value yet). + this.floating = false; + + this.$input = this.$('input'); + this.$external_button = this.$('.o_external_button'); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + if (this._onScroll) { + window.removeEventListener('scroll', this._onScroll, true); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Override to make the caller wait for potential ongoing record creation. + * This ensures that the correct many2one value is set when the main record + * is saved. + * + * @override + * @returns {Promise} resolved as soon as there is no longer record being + * (quick) created + */ + commitChanges: function () { + return Promise.resolve(this.createDef); + }, + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + return this.mode === 'edit' && this.$input || this.$el; + }, + /** + * TODO + */ + reinitialize: function (value) { + this.isDirty = false; + this.floating = false; + return this._setValue(value); + }, + /** + * Re-renders the widget if it isn't dirty. The widget is dirty if the user + * changed the value, and that change hasn't been acknowledged yet by the + * environment. For example, another field with an onchange has been updated + * and this field is updated before the onchange returns. Two '_setValue' + * are done (this is sequential), the first one returns and this widget is + * reset. However, it has pending changes, so we don't re-render. + * + * @override + */ + reset: function (record, event) { + this._reset(record, event); + if (!event || event === this.lastChangeEvent) { + this.isDirty = false; + } + if (this.isDirty) { + return Promise.resolve(); + } else { + return this._render(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a source to the autocomplete results + * + * @param {function} method : A function that returns a list of results. If async source, the function should return a promise + * @param {Object} params : Parameters containing placeholder/validation/order + * @private + */ + _addAutocompleteSource: function (method, params) { + this._autocompleteSources.push({ + method: method, + placeholder: (params.placeholder ? _t(params.placeholder) : _t('Loading...')) + '<i class="fa fa-spinner fa-spin pull-right"></i>' , + validation: params.validation, + loading: false, + order: params.order || 999 + }); + + this._autocompleteSources = _.sortBy(this._autocompleteSources, 'order'); + }, + /** + * @private + */ + _bindAutoComplete: function () { + var self = this; + // avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439 + if ($.browser.chrome && this.$input.attr('placeholder')) { + this.$input.attr('placeholder', function (index, val) { + return val.split('').join('\ufeff'); + }); + } + this.$input.autocomplete({ + source: function (req, resp) { + _.each(self._autocompleteSources, function (source) { + // Resets the results for this source + source.results = []; + + // Check if this source should be used for the searched term + const search = req.term.trim(); + if (!source.validation || source.validation.call(self, search)) { + source.loading = true; + + // Wrap the returned value of the source.method with a promise + // So event if the returned value is not async, it will work + Promise.resolve(source.method.call(self, search)).then(function (results) { + source.results = results; + source.loading = false; + resp(self._concatenateAutocompleteResults()); + }); + } + }); + }, + select: function (event, ui) { + // we do not want the select event to trigger any additional + // effect, such as navigating to another field. + event.stopImmediatePropagation(); + event.preventDefault(); + + var item = ui.item; + self.floating = false; + if (item.id) { + self.reinitialize({id: item.id, display_name: item.name}); + } else if (item.action) { + item.action(); + } + return false; + }, + focus: function (event) { + event.preventDefault(); // don't automatically select values on focus + }, + open: function (event) { + self._onScroll = function (ev) { + if (ev.target !== self.$input.get(0) && self.$input.hasClass('ui-autocomplete-input')) { + self.$input.autocomplete('close'); + } + }; + window.addEventListener('scroll', self._onScroll, true); + }, + close: function (event) { + // it is necessary to prevent ESC key from propagating to field + // root, to prevent unwanted discard operations. + if (event.which === $.ui.keyCode.ESCAPE) { + event.stopPropagation(); + } + if (self._onScroll) { + window.removeEventListener('scroll', self._onScroll, true); + } + }, + autoFocus: true, + html: true, + minLength: 0, + delay: this.AUTOCOMPLETE_DELAY, + }); + this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" }); + this.autocomplete_bound = true; + }, + /** + * Concatenate async results for autocomplete. + * + * @returns {Array} + * @private + */ + _concatenateAutocompleteResults: function () { + var results = []; + _.each(this._autocompleteSources, function (source) { + if (source.results && source.results.length) { + results = results.concat(source.results); + } else if (source.loading) { + results.push({ + label: source.placeholder + }); + } + }); + return results; + }, + /** + * @private + * @param {string} [name] + * @returns {Object} + */ + _createContext: function (name) { + var tmp = {}; + var field = this.nodeOptions.create_name_field; + if (field === undefined) { + field = "name"; + } + if (field !== false && name && this.nodeOptions.quick_create !== false) { + tmp["default_" + field] = name; + } + return tmp; + }, + /** + * @private + * @returns {Array} + */ + _getSearchBlacklist: function () { + return []; + }, + /** + * Returns the display_name from a string which contains it but was altered + * as a result of the show_address option using a horrible hack. + * + * @private + * @param {string} value + * @returns {string} display_name without show_address mess + */ + _getDisplayName: function (value) { + return value.split('\n')[0]; + }, + /** + * Prepares and returns options for SelectCreateDialog + * + * @private + */ + _getSearchCreatePopupOptions: function(view, ids, context, dynamicFilters) { + var self = this; + return { + res_model: this.field.relation, + domain: this.record.getDomain({fieldName: this.name}), + context: _.extend({}, this.record.getContext(this.recordParams), context || {}), + _createContext: this._createContext.bind(this), + dynamicFilters: dynamicFilters || [], + title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string, + initial_ids: ids, + initial_view: view, + disable_multiple_selection: true, + no_create: !self.can_create, + kanban_view_ref: this.attrs.kanban_view_ref, + on_selected: function (records) { + self.reinitialize(records[0]); + }, + on_closed: function () { + self.activate(); + }, + }; + }, + /** + * @private + * @param {Object} values + * @param {string} search_val + * @param {Object} domain + * @param {Object} context + * @returns {Object} + */ + _manageSearchMore: function (values, search_val, domain, context) { + var self = this; + values = values.slice(0, this.limit); + values.push({ + label: _t("Search More..."), + action: function () { + var prom; + if (search_val !== '') { + prom = self._rpc({ + model: self.field.relation, + method: 'name_search', + kwargs: { + name: search_val, + args: domain, + operator: "ilike", + limit: self.SEARCH_MORE_LIMIT, + context: context, + }, + }); + } + Promise.resolve(prom).then(function (results) { + var dynamicFilters; + if (results) { + var ids = _.map(results, function (x) { + return x[0]; + }); + dynamicFilters = [{ + description: _.str.sprintf(_t('Quick search: %s'), search_val), + domain: [['id', 'in', ids]], + }]; + } + self._searchCreatePopup("search", false, {}, dynamicFilters); + }); + }, + classname: 'o_m2o_dropdown_option', + }); + return values; + }, + /** + * Listens to events 'field_changed' to keep track of the last event that + * has been trigerred. This allows to detect that all changes have been + * acknowledged by the environment. + * + * @param {OdooEvent} event 'field_changed' event + */ + _onFieldChanged: function (event) { + this.lastChangeEvent = event; + }, + /** + * @private + * @param {string} name + * @returns {Promise} resolved after the name_create or when the slowcreate + * modal is closed. + */ + _quickCreate: function (name) { + var self = this; + var createDone; + + var def = new Promise(function (resolve, reject) { + self.createDef = new Promise(function (innerResolve) { + // called when the record has been quick created, or when the dialog has + // been closed (in the case of a 'slow' create), meaning that the job is + // done + createDone = function () { + innerResolve(); + resolve(); + self.createDef = undefined; + }; + }); + + // called if the quick create is disabled on this many2one, or if the + // quick creation failed (probably because there are mandatory fields on + // the model) + var slowCreate = function () { + var dialog = self._searchCreatePopup("form", false, self._createContext(name)); + dialog.on('closed', self, createDone); + }; + if (self.nodeOptions.quick_create) { + const prom = self.reinitialize({id: false, display_name: name}); + prom.guardedCatch(reason => { + reason.event.preventDefault(); + slowCreate(); + }); + self.dp.add(prom).then(createDone).guardedCatch(reject); + } else { + slowCreate(); + } + }); + + return def; + }, + /** + * @private + */ + _renderEdit: function () { + var value = this.m2o_value; + + // this is a stupid hack necessary to support the always_reload flag. + // the field value has been reread by the basic model. We use it to + // display the full address of a partner, separated by \n. This is + // really a bad way to do it. Now, we need to remove the extra lines + // and hope for the best that no one tries to uses this mechanism to do + // something else. + if (this.nodeOptions.always_reload) { + value = this._getDisplayName(value); + } + this.$input.val(value); + if (!this.autocomplete_bound) { + this._bindAutoComplete(); + } + this._updateExternalButton(); + }, + /** + * @private + */ + _renderReadonly: function () { + var escapedValue = _.escape((this.m2o_value || "").trim()); + var value = escapedValue.split('\n').map(function (line) { + return '<span>' + line + '</span>'; + }).join('<br/>'); + this.$el.html(value); + if (!this.noOpen && this.value) { + this.$el.attr('href', _.str.sprintf('#id=%s&model=%s', this.value.res_id, this.field.relation)); + this.$el.addClass('o_form_uri'); + } + }, + /** + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this.floating = false; + this.m2o_value = this._formatValue(this.value); + }, + /** + * Executes a 'name_search' and returns a list of formatted objects meant to + * be displayed in the autocomplete widget dropdown. These items are either: + * - a formatted version of a 'name_search' result + * - an option meant to display additional information or perform an action + * + * @private + * @param {string} [searchValue=""] + * @returns {Promise<{ + * label: string, + * id?: number, + * name?: string, + * value?: string, + * classname?: string, + * action?: () => Promise<any>, + * }[]>} + */ + _search: async function (searchValue = "") { + const value = searchValue.trim(); + const domain = this.record.getDomain(this.recordParams); + const context = Object.assign( + this.record.getContext(this.recordParams), + this.additionalContext + ); + + // Exclude black-listed ids from the domain + const blackListedIds = this._getSearchBlacklist(); + if (blackListedIds.length) { + domain.push(['id', 'not in', blackListedIds]); + } + + const nameSearch = this._rpc({ + model: this.field.relation, + method: "name_search", + kwargs: { + name: value, + args: domain, + operator: "ilike", + limit: this.limit + 1, + context, + } + }); + const results = await this.orderer.add(nameSearch); + + // Format results to fit the options dropdown + let values = results.map((result) => { + const [id, fullName] = result; + const displayName = this._getDisplayName(fullName).trim(); + result[1] = displayName; + return { + id, + label: escape(displayName) || data.noDisplayContent, + value: displayName, + name: displayName, + }; + }); + + // Add "Search more..." option if results count is higher than the limit + if (this.limit < values.length) { + values = this._manageSearchMore(values, value, domain, context); + } + if (!this.can_create) { + return values; + } + + // Additional options... + const canQuickCreate = !this.nodeOptions.no_quick_create; + const canCreateEdit = !this.nodeOptions.no_create_edit; + if (value.length) { + // "Quick create" option + const nameExists = results.some((result) => result[1] === value); + if (canQuickCreate && !nameExists) { + values.push({ + label: sprintf( + _t(`Create "<strong>%s</strong>"`), + escape(value) + ), + action: () => this._quickCreate(value), + classname: 'o_m2o_dropdown_option' + }); + } + // "Create and Edit" option + if (canCreateEdit) { + const valueContext = this._createContext(value); + values.push({ + label: _t("Create and Edit..."), + action: () => { + // Input value is cleared and the form popup opens + this.el.querySelector(':scope input').value = ""; + return this._searchCreatePopup('form', false, valueContext); + }, + classname: 'o_m2o_dropdown_option', + }); + } + // "No results" option + if (!values.length) { + values.push({ + label: _t("No results to show..."), + }); + } + } else if (!this.value && (canQuickCreate || canCreateEdit)) { + // "Start typing" option + values.push({ + label: _t("Start typing..."), + classname: 'o_m2o_start_typing', + }); + } + + return values; + }, + /** + * all search/create popup handling + * + * TODO: ids argument is no longer used, remove it in master (as well as + * initial_ids param of the dialog) + * + * @private + * @param {any} view + * @param {any} ids + * @param {any} context + * @param {Object[]} [dynamicFilters=[]] filters to add to the search view + * in the dialog (each filter has keys 'description' and 'domain') + */ + _searchCreatePopup: function (view, ids, context, dynamicFilters) { + var options = this._getSearchCreatePopupOptions(view, ids, context, dynamicFilters); + return new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, options)).open(); + }, + /** + * @private + */ + _updateExternalButton: function () { + var has_external_button = !this.noOpen && !this.floating && this.isSet(); + this.$external_button.toggle(has_external_button); + this.$el.toggleClass('o_with_button', has_external_button); // Should not be required anymore but kept for compatibility + }, + + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onClick: function (event) { + var self = this; + if (this.mode === 'readonly' && !this.noOpen) { + event.preventDefault(); + event.stopPropagation(); + this._rpc({ + model: this.field.relation, + method: 'get_formview_action', + args: [[this.value.res_id]], + context: this.record.getContext(this.recordParams), + }) + .then(function (action) { + self.trigger_up('do_action', {action: action}); + }); + } + }, + + /** + * Reset the input as dialog has been closed without m2o creation. + * + * @private + */ + _onDialogClosedUnset: function () { + this.isDirty = false; + this.floating = false; + this._render(); + }, + /** + * @private + */ + _onExternalButtonClick: function () { + if (!this.value) { + this.activate(); + return; + } + var self = this; + var context = this.record.getContext(this.recordParams); + this._rpc({ + model: this.field.relation, + method: 'get_formview_id', + args: [[this.value.res_id]], + context: context, + }) + .then(function (view_id) { + new dialogs.FormViewDialog(self, { + res_model: self.field.relation, + res_id: self.value.res_id, + context: context, + title: _t("Open: ") + self.string, + view_id: view_id, + readonly: !self.can_write, + on_saved: function (record, changed) { + if (changed) { + const _setValue = self._setValue.bind(self, self.value.data, { + forceChange: true, + }); + self.trigger_up('reload', { + db_id: self.value.id, + onSuccess: _setValue, + onFailure: _setValue, + }); + } + }, + }).open(); + }); + }, + /** + * @private + */ + _onInputClick: function () { + if (this.$input.autocomplete("widget").is(":visible")) { + this.$input.autocomplete("close"); + } else if (this.floating) { + this.$input.autocomplete("search"); // search with the input's content + } else { + this.$input.autocomplete("search", ''); // search with the empty string + } + }, + /** + * @private + */ + _onInputFocusout: function () { + if (this.can_create && this.floating) { + new M2ODialog(this, this.string, this.$input.val()).open(); + } + }, + /** + * @private + * + * @param {OdooEvent} ev + */ + _onInputKeyup: function (ev) { + if (ev.which === $.ui.keyCode.ENTER || ev.which === $.ui.keyCode.TAB) { + // If we pressed enter or tab, we want to prevent _onInputFocusout from + // executing since it would open a M2O dialog to request + // confirmation that the many2one is not properly set. + // It's a case that is already handled by the autocomplete lib. + return; + } + this.isDirty = true; + if (this.$input.val() === "") { + this.reinitialize(false); + } else if (this._getDisplayName(this.m2o_value) !== this.$input.val()) { + this.floating = true; + this._updateExternalButton(); + } + }, + /** + * @override + * @private + */ + _onKeydown: function () { + this.floating = false; + this._super.apply(this, arguments); + }, + /** + * Stops the left/right navigation move event if the cursor is not at the + * start/end of the input element. Stops any navigation move event if the + * user is selecting text. + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + // TODO Maybe this should be done in a mixin or, better, the m2o field + // should be an InputField (but this requires some refactoring). + basicFields.InputField.prototype._onNavigationMove.apply(this, arguments); + if (this.mode === 'edit' && $(this.$input.autocomplete('widget')).is(':visible')) { + ev.stopPropagation(); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onQuickCreate: function (event) { + this._quickCreate(event.data.value); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onSearchCreatePopup: function (event) { + var data = event.data; + this._searchCreatePopup(data.view_type, false, this._createContext(data.value)); + }, +}); + +var Many2oneBarcode = FieldMany2One.extend({ + // We don't require this widget to be displayed in studio sidebar in + // non-debug mode hence just extended it from its original widget, so that + // description comes from parent and hasOwnProperty based condition fails +}); + +var ListFieldMany2One = FieldMany2One.extend({ + events: _.extend({}, FieldMany2One.prototype.events, { + 'focusin input': '_onInputFocusin', + }), + + /** + * Should never be allowed to be opened while in readonly mode in a list + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + // when we empty the input, we delay the setValue to prevent from + // triggering the 'fieldChanged' event twice when the user wants set + // another m2o value ; the following attribute is used to determine when + // we skipped the setValue, s.t. we can perform it later on if the user + // didn't select another value + this.mustSetValue = false; + this.m2oDialogFocused = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * If in readonly, will never be considered as an active widget. + * + * @override + */ + activate: function () { + if (this.mode === 'readonly') { + return false; + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + reinitialize: function () { + this.mustSetValue = false; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _renderReadonly: function () { + this.$el.text(this.m2o_value); + }, + /** + * @override + * @private + */ + _searchCreatePopup: function () { + this.m2oDialogFocused = true; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputFocusin: function () { + this.m2oDialogFocused = false; + }, + /** + * In case the focus is lost from a mousedown, we want to prevent the click occuring on the + * following mouseup since it might trigger some unwanted list functions. + * If it's not the case, we want to remove the added handler on the next mousedown. + * @see list_editable_renderer._onWindowClicked() + * + * Also, in list views, we don't want to try to trigger a fieldChange when the field + * is being emptied. Instead, it will be triggered as the user leaves the field + * while it is empty. + * + * @override + * @private + */ + _onInputFocusout: function () { + if (this.can_create && this.floating) { + // In case the focus out is due to a mousedown, we want to prevent the next click + var attachedEvents = ['click', 'mousedown']; + var stopNextClick = (function (ev) { + ev.stopPropagation(); + attachedEvents.forEach(function (eventName) { + window.removeEventListener(eventName, stopNextClick, true); + }); + }).bind(this); + attachedEvents.forEach(function (eventName) { + window.addEventListener(eventName, stopNextClick, true); + }); + } + this._super.apply(this, arguments); + if (!this.m2oDialogFocused && this.$input.val() === "" && this.mustSetValue) { + this.reinitialize(false); + } + }, + /** + * Prevents the triggering of an immediate _onFieldChanged when emptying the field. + * + * @override + * @private + */ + _onInputKeyup: function () { + if (this.$input.val() !== "") { + this._super.apply(this, arguments); + } else { + this.mustSetValue = true; + } + }, +}); + +var KanbanFieldMany2One = AbstractField.extend({ + tagName: 'span', + init: function () { + this._super.apply(this, arguments); + this.m2o_value = this._formatValue(this.value); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _render: function () { + this.$el.text(this.m2o_value); + }, +}); + +/** + * Widget Many2OneAvatar is only supported on many2one fields pointing to a + * model which inherits from 'image.mixin'. In readonly, it displays the + * record's image next to the display_name. In edit, it behaves exactly like a + * regular many2one widget. + */ +const Many2OneAvatar = FieldMany2One.extend({ + _template: 'web.Many2OneAvatar', + + init() { + this._super.apply(this, arguments); + if (this.mode === 'readonly') { + this.template = null; + this.tagName = 'div'; + this.className = 'o_field_many2one_avatar'; + // disable the redirection to the related record on click, in readonly + this.noOpen = true; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _renderReadonly() { + this.$el.empty(); + if (this.value) { + this.$el.html(qweb.render(this._template, { + url: `/web/image/${this.field.relation}/${this.value.res_id}/image_128`, + value: this.m2o_value, + })); + } + }, +}); + +//------------------------------------------------------------------------------ +// X2Many widgets +//------------------------------------------------------------------------------ + +var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, { + tagName: 'div', + custom_events: _.extend({}, AbstractField.prototype.custom_events, { + add_record: '_onAddRecord', + discard_changes: '_onDiscardChanges', + edit_line: '_onEditLine', + field_changed: '_onFieldChanged', + open_record: '_onOpenRecord', + kanban_record_delete: '_onRemoveRecord', + list_record_remove: '_onRemoveRecord', + resequence_records: '_onResequenceRecords', + save_line: '_onSaveLine', + toggle_column_order: '_onToggleColumnOrder', + activate_next_widget: '_onActiveNextWidget', + navigation_move: '_onNavigationMove', + save_optional_fields: '_onSaveOrLoadOptionalFields', + load_optional_fields: '_onSaveOrLoadOptionalFields', + pager_changed: '_onPagerChanged', + }), + + // We need to trigger the reset on every changes to be aware of the parent changes + // and then evaluate the 'column_invisible' modifier in case a evaluated value + // changed. + resetOnAnyFieldChange: true, + + /** + * useSubview is used in form view to load view of the related model of the x2many field + */ + useSubview: true, + + /** + * @override + */ + init: function (parent, name, record, options) { + this._super.apply(this, arguments); + this.nodeOptions = _.defaults(this.nodeOptions, { + create_text: _t('Add'), + }); + this.operations = []; + this.isReadonly = this.mode === 'readonly'; + this.view = this.attrs.views[this.attrs.mode]; + this.isMany2Many = this.field.type === 'many2many' || this.attrs.widget === 'many2many'; + this.activeActions = {}; + this.recordParams = {fieldName: this.name, viewType: this.viewType}; + // The limit is fixed so it cannot be changed by adding/removing lines in + // the widget. It will only change through a hard reload or when manually + // changing the pager (see _onPagerChanged). + this.pagingState = { + currentMinimum: this.value.offset + 1, + limit: this.value.limit, + size: this.value.count, + validate: () => { + // TODO: we should have some common method in the basic renderer... + return this.view.arch.tag === 'tree' ? + this.renderer.unselectRow() : + Promise.resolve(); + }, + withAccessKey: false, + }; + var arch = this.view && this.view.arch; + if (arch) { + this.activeActions.create = arch.attrs.create ? + !!JSON.parse(arch.attrs.create) : + true; + this.activeActions.delete = arch.attrs.delete ? + !!JSON.parse(arch.attrs.delete) : + true; + this.editable = arch.attrs.editable; + } + this._computeAvailableActions(record); + if (this.attrs.columnInvisibleFields) { + this._processColumnInvisibleFields(); + } + }, + /** + * @override + */ + start: async function () { + const _super = this._super.bind(this); + if (this.view) { + this._renderButtons(); + this._controlPanelWrapper = new ComponentWrapper(this, ControlPanelX2Many, { + cp_content: { $buttons: this.$buttons }, + pager: this.pagingState, + }); + await this._controlPanelWrapper.mount(this.el, { position: 'first-child' }); + } + return _super(...arguments); + }, + destroy: function () { + WidgetAdapterMixin.destroy.call(this); + this._super(); + }, + /** + * For the list renderer to properly work, it must know if it is in the DOM, + * and be notified when it is attached to the DOM. + */ + on_attach_callback: function () { + this.isInDOM = true; + WidgetAdapterMixin.on_attach_callback.call(this); + if (this.renderer) { + this.renderer.on_attach_callback(); + } + }, + /** + * For the list renderer to properly work, it must know if it is in the DOM. + */ + on_detach_callback: function () { + this.isInDOM = false; + WidgetAdapterMixin.on_detach_callback.call(this); + if (this.renderer) { + this.renderer.on_detach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * A x2m field can only be saved if it finished the edition of all its rows. + * On parent view saving, we have to ask the x2m fields to commit their + * changes, that is confirming the save of the in-edition row or asking the + * user if he wants to discard it if necessary. + * + * @override + * @returns {Promise} + */ + commitChanges: function () { + var self = this; + var inEditionRecordID = + this.renderer && + this.renderer.viewType === "list" && + this.renderer.getEditableRecordID(); + if (inEditionRecordID) { + return this.renderer.commitChanges(inEditionRecordID).then(function () { + return self._saveLine(inEditionRecordID); + }); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + isSet: function () { + return true; + }, + /** + * @override + * @param {Object} record + * @param {OdooEvent} [ev] an event that triggered the reset action + * @param {Boolean} [fieldChanged] if true, the widget field has changed + * @returns {Promise} + */ + reset: function (record, ev, fieldChanged) { + // re-evaluate available actions + const oldCanCreate = this.canCreate; + const oldCanDelete = this.canDelete; + const oldCanLink = this.canLink; + const oldCanUnlink = this.canUnlink; + this._computeAvailableActions(record); + const actionsChanged = + this.canCreate !== oldCanCreate || + this.canDelete !== oldCanDelete || + this.canLink !== oldCanLink || + this.canUnlink !== oldCanUnlink; + + // If 'fieldChanged' is false, it means that the reset was triggered by + // the 'resetOnAnyFieldChange' mechanism. If it is the case, if neither + // the modifiers (so the visible columns) nor the available actions + // changed, the reset is skipped. + if (!fieldChanged && !actionsChanged) { + var newEval = this._evalColumnInvisibleFields(); + if (_.isEqual(this.currentColInvisibleFields, newEval)) { + this._reset(record, ev); // update the internal state, but do not re-render + return Promise.resolve(); + } + } else if (ev && ev.target === this && ev.data.changes && this.view.arch.tag === 'tree') { + var command = ev.data.changes[this.name]; + // Here, we only consider 'UPDATE' commands with data, which occur + // with editable list view. In order to keep the current line in + // edition, we call confirmUpdate which will try to reset the widgets + // of the line being edited, and rerender the rest of the list. + // 'UPDATE' commands with no data can be ignored: they occur in + // one2manys when the record is updated from a dialog and in this + // case, we can re-render the whole subview. + if (command && command.operation === 'UPDATE' && command.data) { + var state = record.data[this.name]; + var fieldNames = state.getFieldNames({ viewType: 'list' }); + this._reset(record, ev); + return this.renderer.confirmUpdate(state, command.id, fieldNames, ev.initialEvent); + } + } + return this._super.apply(this, arguments); + }, + + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + return (this.mode === 'edit' && this.$input) || this.$el; + }, + + /** + * @override + * @param {Object|undefined} [options={}] + * @param {boolean} [options.noAutomaticCreate=false] + */ + activate: function (options) { + if (!this.activeActions.create || this.isReadonly || !this.$el.is(":visible")) { + return false; + } + if (this.view.type === 'kanban') { + this.$buttons.find(".o-kanban-button-new").focus(); + } + if (this.view.arch.tag === 'tree') { + if (options && options.noAutomaticCreate) { + this.renderer.$('.o_field_x2many_list_row_add a:first').focus(); + } else { + this.renderer.$('.o_field_x2many_list_row_add a:first').click(); + } + } + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} record + */ + _computeAvailableActions: function (record) { + const evalContext = record.evalContext; + this.canCreate = 'create' in this.nodeOptions ? + new Domain(this.nodeOptions.create, evalContext).compute(evalContext) : + true; + this.canDelete = 'delete' in this.nodeOptions ? + new Domain(this.nodeOptions.delete, evalContext).compute(evalContext) : + true; + this.canLink = 'link' in this.nodeOptions ? + new Domain(this.nodeOptions.link, evalContext).compute(evalContext) : + true; + this.canUnlink = 'unlink' in this.nodeOptions ? + new Domain(this.nodeOptions.unlink, evalContext).compute(evalContext) : + true; + }, + /** + * Evaluates the 'column_invisible' modifier for the parent record. + * + * @return {Object} Object containing fieldName as key and the evaluated + * column_invisible modifier + */ + _evalColumnInvisibleFields: function () { + var self = this; + return _.mapObject(this.columnInvisibleFields, function (domains) { + return self.record.evalModifiers({ + column_invisible: domains, + }).column_invisible; + }); + }, + /** + * Returns qweb context to render buttons. + * + * @private + * @returns {Object} + */ + _getButtonsRenderingContext() { + return { + btnClass: 'btn-secondary', + create_text: this.nodeOptions.create_text, + }; + }, + /** + * Computes the default renderer to use depending on the view type. + * We create this as a method so we can override it if we want to use + * another renderer instead (eg. section_and_note_one2many). + * + * @private + * @returns {Object} The renderer to use + */ + _getRenderer: function () { + if (this.view.arch.tag === 'tree') { + return ListRenderer; + } + if (this.view.arch.tag === 'kanban') { + return KanbanRenderer; + } + }, + /** + * @private + * @returns {boolean} true iff the list should contain a 'create' line. + */ + _hasCreateLine: function () { + return !this.isReadonly && ( + (!this.isMany2Many && this.activeActions.create && this.canCreate) || + (this.isMany2Many && this.canLink) + ); + }, + /** + * @private + * @returns {boolean} true iff the list should add a trash icon on each row. + */ + _hasTrashIcon: function () { + return !this.isReadonly && ( + (!this.isMany2Many && this.activeActions.delete && this.canDelete) || + (this.isMany2Many && this.canUnlink) + ); + }, + /** + * Instanciates or updates the adequate renderer. + * + * @override + * @private + * @returns {Promise|undefined} + */ + _render: function () { + var self = this; + if (!this.view) { + return this._super(); + } + + if (this.renderer) { + this.currentColInvisibleFields = this._evalColumnInvisibleFields(); + return this.renderer.updateState(this.value, { + addCreateLine: this._hasCreateLine(), + addTrashIcon: this._hasTrashIcon(), + columnInvisibleFields: this.currentColInvisibleFields, + keepWidths: true, + }).then(() => { + return this._updateControlPanel({ size: this.value.count }); + }); + } + var arch = this.view.arch; + var viewType; + var rendererParams = { + arch: arch, + }; + + if (arch.tag === 'tree') { + viewType = 'list'; + this.currentColInvisibleFields = this._evalColumnInvisibleFields(); + _.extend(rendererParams, { + editable: this.mode === 'edit' && arch.attrs.editable, + addCreateLine: this._hasCreateLine(), + addTrashIcon: this._hasTrashIcon(), + isMany2Many: this.isMany2Many, + columnInvisibleFields: this.currentColInvisibleFields, + }); + } + + if (arch.tag === 'kanban') { + viewType = 'kanban'; + var record_options = { + editable: false, + deletable: false, + read_only_mode: this.isReadonly, + }; + _.extend(rendererParams, { + record_options: record_options, + readOnlyMode: this.isReadonly, + }); + } + + _.extend(rendererParams, { + viewType: viewType, + }); + var Renderer = this._getRenderer(); + this.renderer = new Renderer(this, this.value, rendererParams); + + this.$el.addClass('o_field_x2many o_field_x2many_' + viewType); + if (this.renderer) { + return this.renderer.appendTo(document.createDocumentFragment()).then(function () { + dom.append(self.$el, self.renderer.$el, { + in_DOM: self.isInDOM, + callbacks: [{widget: self.renderer}], + }); + }); + } else { + return this._super(); + } + }, + /** + * Renders the buttons and sets this.$buttons. + * + * @private + */ + _renderButtons: function () { + if (!this.isReadonly && this.view.arch.tag === 'kanban') { + const renderingContext = this._getButtonsRenderingContext(); + this.$buttons = $(qweb.render('KanbanView.buttons', renderingContext)); + this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this)); + } + }, + /** + * Saves the line associated to the given recordID. If the line is valid, + * it only has to be switched to readonly mode as all the line changes have + * already been notified to the model so that they can be saved in db if the + * parent view is actually saved. If the line is not valid, the line is to + * be discarded if the user agrees (this behavior is not a list editable + * one but a x2m one as it is made to replace the "discard" button which + * exists for list editable views). + * + * @private + * @param {string} recordID + * @returns {Promise} resolved if the line was properly saved or discarded. + * rejected if the line could not be saved and the user + * did not agree to discard. + */ + _saveLine: function (recordID) { + var self = this; + return new Promise(function (resolve, reject) { + var fieldNames = self.renderer.canBeSaved(recordID); + if (fieldNames.length) { + self.trigger_up('discard_changes', { + recordID: recordID, + onSuccess: resolve, + onFailure: reject, + }); + } else { + self.renderer.setRowMode(recordID, 'readonly').then(resolve); + } + }).then(async function () { + self._updateControlPanel({ size: self.value.count }); + var newEval = self._evalColumnInvisibleFields(); + if (!_.isEqual(self.currentColInvisibleFields, newEval)) { + self.currentColInvisibleFields = newEval; + self.renderer.updateState(self.value, { + columnInvisibleFields: self.currentColInvisibleFields, + }); + } + }); + }, + /** + * Re-renders buttons and updates the control panel. This method is called + * when the widget is reset, as the available buttons might have changed. + * The only mutable element in X2Many fields will be the pager. + * + * @private + */ + _updateControlPanel: function (pagingState) { + if (this._controlPanelWrapper) { + this._renderButtons(); + const pagerProps = Object.assign(this.pagingState, pagingState, { + // sometimes, we temporarily want to increase the pager limit + // (for instance, when we add a new record on a page that already + // contains the maximum number of records) + limit: Math.max(this.value.limit, this.value.data.length), + }); + const newProps = { + cp_content: { $buttons: this.$buttons }, + pager: pagerProps, + }; + return this._controlPanelWrapper.update(newProps); + } + }, + /** + * Parses the 'columnInvisibleFields' attribute to search for the domains + * containing the key 'parent'. If there are such domains, the string + * 'parent.field' is replaced with 'field' in order to be evaluated + * with the right field name in the parent context. + * + * @private + */ + _processColumnInvisibleFields: function () { + var columnInvisibleFields = {}; + _.each(this.attrs.columnInvisibleFields, function (domains, fieldName) { + if (_.isArray(domains)) { + columnInvisibleFields[fieldName] = _.map(domains, function (domain) { + // We check if the domain is an array to avoid processing + // the '|' and '&' cases + if (_.isArray(domain)) { + return [domain[0].split('.')[1]].concat(domain.slice(1)); + } + return domain; + }); + } + }); + this.columnInvisibleFields = columnInvisibleFields; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user clicks on the 'Add a line' link (list case) or the + * 'Add' button (kanban case). + * + * @abstract + * @private + */ + _onAddRecord: function () { + // to implement + }, + /** + * Removes the given record from the relation. + * Stops the propagation of the event to prevent it from being handled again + * by the parent controller. + * + * @private + * @param {OdooEvent} ev + */ + _onRemoveRecord: function (ev) { + ev.stopPropagation(); + var operation = this.isMany2Many ? 'FORGET' : 'DELETE'; + this._setValue({ + operation: operation, + ids: [ev.data.id], + }); + }, + /** + * When the discard_change event go through this field, we can just decorate + * the data with the name of the field. The origin field ignore this + * information (it is a subfield in a o2m), and the controller will need to + * know which field needs to be handled. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardChanges: function (ev) { + if (ev.target !== this) { + ev.stopPropagation(); + this.trigger_up('discard_changes', _.extend({}, ev.data, {fieldName: this.name})); + } + }, + /** + * Called when the renderer asks to edit a line, in that case simply tells + * him back to toggle the mode of this row. + * + * @private + * @param {OdooEvent} ev + */ + _onEditLine: function (ev) { + ev.stopPropagation(); + this.trigger_up('edited_list', { id: this.value.id }); + this.renderer.setRowMode(ev.data.recordId, 'edit') + .then(ev.data.onSuccess); + }, + /** + * Updates the given record with the changes. + * + * @private + * @param {OdooEvent} ev + */ + _onFieldChanged: function (ev) { + if (ev.target === this) { + ev.initialEvent = this.lastInitialEvent; + return; + } + ev.stopPropagation(); + // changes occured in an editable list + var changes = ev.data.changes; + // save the initial event triggering the field_changed, as it will be + // necessary when the field triggering this event will be reset (to + // prevent it from re-rendering itself, formatting its value, loosing + // the focus... while still being edited) + this.lastInitialEvent = undefined; + if (Object.keys(changes).length) { + this.lastInitialEvent = ev; + this._setValue({ + operation: 'UPDATE', + id: ev.data.dataPointID, + data: changes, + }).then(function () { + if (ev.data.onSuccess) { + ev.data.onSuccess(); + } + }).guardedCatch(function (reason) { + if (ev.data.onFailure) { + ev.data.onFailure(reason); + } + }); + } + }, + /** + * Override to handle the navigation inside editable list controls + * + * @override + * @private + */ + _onNavigationMove: function (ev) { + if (this.view.arch.tag === 'tree') { + var $curControl = this.renderer.$('.o_field_x2many_list_row_add a:focus'); + if ($curControl.length) { + var $nextControl; + if (ev.data.direction === 'right') { + $nextControl = $curControl.next('a'); + } else if (ev.data.direction === 'left') { + $nextControl = $curControl.prev('a'); + } + if ($nextControl && $nextControl.length) { + ev.stopPropagation(); + $nextControl.focus(); + return; + } + } + } + this._super.apply(this, arguments); + }, + /** + * Called when the user clicks on a relational record. + * + * @abstract + * @private + */ + _onOpenRecord: function () { + // to implement + }, + /** + * We re-render the pager immediately with the new event values to allow + * it to request another pager change while another one is still ongoing. + * @see field_manager_mixin for concurrency handling. + * + * @private + * @param {OdooEvent} ev + */ + _onPagerChanged: function (ev) { + ev.stopPropagation(); + const { currentMinimum, limit } = ev.data; + this._updateControlPanel({ currentMinimum, limit }); + this.trigger_up('load', { + id: this.value.id, + limit, + offset: currentMinimum - 1, + on_success: value => { + this.value = value; + this.pagingState.limit = value.limit; + this.pagingState.size = value.count; + this._render(); + }, + }); + }, + /** + * Called when the renderer ask to save a line (the user tries to leave it) + * -> Nothing is to "save" here, the model was already notified of the line + * changes; if the row could be saved, we make the row readonly. Otherwise, + * we trigger a new event for the view to tell it to discard the changes + * made to that row. + * Note that we do that in the controller mutex to ensure that the check on + * the row (whether or not it can be saved) is done once all potential + * onchange RPCs are done (those RPCs being executed in the same mutex). + * This particular handling is done in this handler, instead of in the + * _saveLine function directly, because _saveLine is also called from + * the controller (via commitChanges), and in this case, it is already + * executed in the mutex. + * + * @private + * @param {OdooEvent} ev + * @param {string} ev.recordID + * @param {function} ev.onSuccess success callback (see '_saveLine') + * @param {function} ev.onFailure fail callback (see '_saveLine') + */ + _onSaveLine: function (ev) { + var self = this; + ev.stopPropagation(); + this.renderer.commitChanges(ev.data.recordID).then(function () { + self.trigger_up('mutexify', { + action: function () { + return self._saveLine(ev.data.recordID) + .then(ev.data.onSuccess) + .guardedCatch(ev.data.onFailure); + }, + }); + }); + }, + /** + * Add necessary key parts for the basic controller to compute the local + * storage key. The event will be properly handled by the basic controller. + * + * @param {OdooEvent} ev + * @private + */ + _onSaveOrLoadOptionalFields: function (ev) { + ev.data.keyParts.relationalField = this.name; + ev.data.keyParts.subViewId = this.view.view_id; + ev.data.keyParts.subViewType = this.view.type; + }, + /** + * Forces a resequencing of the records. + * + * @private + * @param {OdooEvent} ev + * @param {string[]} ev.data.recordIds + * @param {integer} ev.data.offset + * @param {string} ev.data.handleField + */ + _onResequenceRecords: function (ev) { + ev.stopPropagation(); + var self = this; + if (this.view.arch.tag === 'tree') { + this.trigger_up('edited_list', { id: this.value.id }); + } + var handleField = ev.data.handleField; + var offset = ev.data.offset; + var recordIds = ev.data.recordIds.slice(); + // trigger an update of all records but the last one with option + // 'notifyChanges' set to false, and once all those changes have been + // validated by the model, trigger the change on the last record + // (without the option, s.t. the potential onchange on parent record + // is triggered) + var recordId = recordIds.pop(); + var proms = recordIds.map(function (recordId, index) { + var data = {}; + data[handleField] = offset + index; + return self._setValue({ + operation: 'UPDATE', + id: recordId, + data: data, + }, { + notifyChange: false, + }); + }); + Promise.all(proms).then(function () { + function always() { + if (self.view.arch.tag === 'tree') { + self.trigger_up('toggle_column_order', { + id: self.value.id, + name: handleField, + }); + } + } + var data = {}; + data[handleField] = offset + recordIds.length; + self._setValue({ + operation: 'UPDATE', + id: recordId, + data: data, + }).then(always).guardedCatch(always); + }); + }, + /** + * Adds field name information to the event, so that the view upstream is + * aware of which widgets it has to redraw. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleColumnOrder: function (ev) { + ev.data.field = this.name; + }, + /* + * Move to next widget. + * + * @private + */ + _onActiveNextWidget: function (e) { + e.stopPropagation(); + this.renderer.unselectRow(); + this.trigger_up('navigation_move', { + direction: e.data.direction || 'next', + }); + }, +}); + +var One2ManyKanbanRecord = KanbanRecord.extend({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply same logic as in the ListRenderer: buttons with type="object" + * are disabled for no saved yet records, as calling the python method + * with no id would make no sense. + * + * To avoid to expose this logic inside all Kanban views, we define + * a specific KanbanRecord Class for the One2many case. + * + * This could be refactored to prevent from duplicating this logic in + * list and kanban views. + * + * @private + */ + _postProcessObjectButtons: function () { + var self = this; + // if the res_id is defined, it's already correctly handled by the Kanban record global event click + if (!this.state.res_id) { + this.$('.oe_kanban_action[data-type=object]').each(function (index, button) { + var $button = $(button); + if ($button.attr('warn')) { + $button.on('click', function (e) { + e.stopPropagation(); + self.do_warn(false, _t('Please click on the "save" button first')); + }); + } else { + $button.attr('disabled', 'disabled'); + } + }); + } + }, + /** + * @override + * @private + */ + _render: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._postProcessObjectButtons(); + }); + }, +}); + +var One2ManyKanbanRenderer = KanbanRenderer.extend({ + config: _.extend({}, KanbanRenderer.prototype.config, { + KanbanRecord: One2ManyKanbanRecord, + }), +}); + +var FieldOne2Many = FieldX2Many.extend({ + description: _lt("One2many"), + className: 'o_field_one2many', + supportedFieldTypes: ['one2many'], + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + // boolean used to prevent concurrent record creation + this.creatingRecord = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * @override + * @param {Object} record + * @param {OdooEvent} [ev] an event that triggered the reset action + * @returns {Promise} + */ + reset: function (record, ev) { + var self = this; + return this._super.apply(this, arguments).then(() => { + if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') { + if (ev.data.changes[self.name] && ev.data.changes[self.name].operation === 'CREATE') { + var index = 0; + if (self.editable !== 'top') { + index = self.value.data.length - 1; + } + var newID = self.value.data[index].id; + self.renderer.editRecord(newID); + } + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _getButtonsRenderingContext() { + const renderingContext = this._super(...arguments); + renderingContext.noCreate = !this.canCreate; + return renderingContext; + }, + /** + * @override + * @private + */ + _getRenderer: function () { + if (this.view.arch.tag === 'kanban') { + return One2ManyKanbanRenderer; + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to only render the buttons if the 'create' action is available. + * + * @override + * @private + */ + _renderButtons: function () { + if (this.activeActions.create) { + return this._super(...arguments); + } + }, + /** + * @private + * @param {Object} params + * @param {Object} [params.context] We allow additional context, this is + * used for example to define default values when adding new lines to + * a one2many with control/create tags. + */ + _openFormDialog: function (params) { + var context = this.record.getContext(_.extend({}, + this.recordParams, + { additionalContext: params.context } + )); + this.trigger_up('open_one2many_record', _.extend(params, { + domain: this.record.getDomain(this.recordParams), + context: context, + field: this.field, + fields_view: this.attrs.views && this.attrs.views.form, + parentID: this.value.id, + viewInfo: this.view, + deletable: this.activeActions.delete && params.deletable && this.canDelete, + })); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens a FormViewDialog to allow creating a new record for a one2many. + * + * @override + * @private + * @param {OdooEvent|MouseEvent} ev this event comes either from the 'Add + * record' link in the list editable renderer, or from the 'Create' button + * in the kanban view + * @param {Array} ev.data.context additional context for the added records, + * if several contexts are provided, multiple records will be added + * (form dialog will only use the context at index 0 if provided) + * @param {boolean} ev.data.forceEditable this is used to bypass the dialog opening + * in case you want to add record(s) to a list + * @param {function} ev.data.onSuccess called when the records are correctly created + * (not supported by form dialog) + * @param {boolean} ev.data.allowWarning defines if the records can be added + * to the list even if warnings are triggered (e.g: stock warning for product availability) + */ + _onAddRecord: function (ev) { + var self = this; + var data = ev.data || {}; + + // we don't want interference with the components upstream. + ev.stopPropagation(); + + if (this.editable || data.forceEditable) { + if (!this.activeActions.create) { + if (data.onFail) { + data.onFail(); + } + } else if (!this.creatingRecord) { + this.creatingRecord = true; + this.trigger_up('edited_list', { id: this.value.id }); + this._setValue({ + operation: 'CREATE', + position: this.editable || data.forceEditable, + context: data.context, + }, { + allowWarning: data.allowWarning + }).then(function () { + self.creatingRecord = false; + }).then(function (){ + if (data.onSuccess){ + data.onSuccess(); + } + }).guardedCatch(function() { + self.creatingRecord = false; + }) + ; + } + } else { + this._openFormDialog({ + context: data.context && data.context[0], + on_saved: function (record) { + self._setValue({ operation: 'ADD', id: record.id }); + }, + }); + } + }, + /** + * Overrides the handler to set a specific 'on_save' callback as the o2m + * sub-records aren't saved directly when the user clicks on 'Save' in the + * dialog. Instead, the relational record is changed in the local data, and + * this change is saved in DB when the user clicks on 'Save' in the main + * form view. + * + * @private + * @param {OdooEvent} ev + */ + _onOpenRecord: function (ev) { + // we don't want interference with the components upstream. + var self = this; + ev.stopPropagation(); + + var id = ev.data.id; + var onSaved = function (record) { + if (_.some(self.value.data, {id: record.id})) { + // the record already exists in the relation, so trigger an + // empty 'UPDATE' operation when the user clicks on 'Save' in + // the dialog, to notify the main record that a subrecord of + // this relational field has changed (those changes will be + // already stored on that subrecord, thanks to the 'Save'). + self._setValue({ operation: 'UPDATE', id: record.id }); + } else { + // the record isn't in the relation yet, so add it ; this can + // happen if the user clicks on 'Save & New' in the dialog (the + // opened record will be updated, and other records will be + // created) + self._setValue({ operation: 'ADD', id: record.id }); + } + }; + this._openFormDialog({ + id: id, + on_saved: onSaved, + on_remove: function () { + self._setValue({operation: 'DELETE', ids: [id]}); + }, + deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete, + readonly: this.mode === 'readonly', + }); + }, +}); + +var FieldMany2Many = FieldX2Many.extend({ + description: _lt("Many2many"), + className: 'o_field_many2many', + supportedFieldTypes: ['many2many'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Opens a SelectCreateDialog + */ + onAddRecordOpenDialog: function () { + var self = this; + var domain = this.record.getDomain({fieldName: this.name}); + + new dialogs.SelectCreateDialog(this, { + res_model: this.field.relation, + domain: domain.concat(["!", ["id", "in", this.value.res_ids]]), + context: this.record.getContext(this.recordParams), + title: _t("Add: ") + this.string, + no_create: this.nodeOptions.no_create || !this.activeActions.create || !this.canCreate, + fields_view: this.attrs.views.form, + kanban_view_ref: this.attrs.kanban_view_ref, + on_selected: function (records) { + var resIDs = _.pluck(records, 'id'); + var newIDs = _.difference(resIDs, self.value.res_ids); + if (newIDs.length) { + var values = _.map(newIDs, function (id) { + return {id: id}; + }); + self._setValue({ + operation: 'ADD_M2M', + ids: values, + }); + } + } + }).open(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _getButtonsRenderingContext() { + const renderingContext = this._super(...arguments); + renderingContext.noCreate = !this.canLink; + return renderingContext; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens a SelectCreateDialog. + * + * @override + * @private + * @param {OdooEvent|MouseEvent} ev this event comes either from the 'Add + * record' link in the list editable renderer, or from the 'Create' button + * in the kanban view + */ + _onAddRecord: function (ev) { + ev.stopPropagation(); + this.onAddRecordOpenDialog(); + }, + + /** + * Intercepts the 'open_record' event to edit its data and lets it bubble up + * to the form view. + * + * @private + * @param {OdooEvent} ev + */ + _onOpenRecord: function (ev) { + var self = this; + _.extend(ev.data, { + context: this.record.getContext(this.recordParams), + domain: this.record.getDomain(this.recordParams), + fields_view: this.attrs.views && this.attrs.views.form, + on_saved: function () { + self._setValue({operation: 'TRIGGER_ONCHANGE'}, {forceChange: true}) + .then(function () { + self.trigger_up('reload', {db_id: ev.data.id}); + }); + }, + on_remove: function () { + self._setValue({operation: 'FORGET', ids: [ev.data.id]}); + }, + readonly: this.mode === 'readonly', + deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete, + string: this.string, + }); + }, +}); + +/** + * Widget to upload or delete one or more files at the same time. + */ +var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ + template: "FieldBinaryFileUploader", + template_files: "FieldBinaryFileUploader.files", + supportedFieldTypes: ['many2many'], + fieldsToFetch: { + name: {type: 'char'}, + mimetype: {type: 'char'}, + }, + events: { + 'click .o_attach': '_onAttach', + 'click .o_attachment_delete': '_onDelete', + 'change .o_input_file': '_onFileChanged', + }, + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + if (this.field.type !== 'many2many' || this.field.relation !== 'ir.attachment') { + var msg = _t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model."); + throw _.str.sprintf(msg, this.field.string); + } + + this.uploadedFiles = {}; + this.uploadingFiles = []; + this.fileupload_id = _.uniqueId('oe_fileupload_temp'); + this.accepted_file_extensions = (this.nodeOptions && this.nodeOptions.accepted_file_extensions) || this.accepted_file_extensions || '*'; + $(window).on(this.fileupload_id, this._onFileLoaded.bind(this)); + + this.metadata = {}; + }, + + destroy: function () { + this._super(); + $(window).off(this.fileupload_id); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Compute the URL of an attachment. + * + * @private + * @param {Object} attachment + * @returns {string} URL of the attachment + */ + _getFileUrl: function (attachment) { + return '/web/content/' + attachment.id + '?download=true'; + }, + /** + * Process the field data to add some information (url, etc.). + * + * @private + */ + _generatedMetadata: function () { + var self = this; + _.each(this.value.data, function (record) { + // tagging `allowUnlink` ascertains if the attachment was user + // uploaded or was an existing or system generated attachment + self.metadata[record.id] = { + allowUnlink: self.uploadedFiles[record.data.id] || false, + url: self._getFileUrl(record.data), + }; + }); + }, + /** + * @private + * @override + */ + _render: function () { + // render the attachments ; as the attachments will changes after each + // _setValue, we put the rendering here to ensure they will be updated + this._generatedMetadata(); + this.$('.oe_placeholder_files, .o_attachments') + .replaceWith($(qweb.render(this.template_files, { + widget: this, + }))); + this.$('.oe_fileupload').show(); + + // display image thumbnail + this.$('.o_image[data-mimetype^="image"]').each(function () { + var $img = $(this); + if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) { + $img.css('background-image', "url('" + $img.data('src') + "')"); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAttach: function () { + // This widget uses a hidden form to upload files. Clicking on 'Attach' + // will simulate a click on the related input. + this.$('.o_input_file').click(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onDelete: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + var fileID = $(ev.currentTarget).data('id'); + var record = _.findWhere(this.value.data, {res_id: fileID}); + if (record) { + this._setValue({ + operation: 'FORGET', + ids: [record.id], + }); + var metadata = this.metadata[record.id]; + if (!metadata || metadata.allowUnlink) { + this._rpc({ + model: 'ir.attachment', + method: 'unlink', + args: [record.res_id], + }); + } + } + }, + /** + * @private + * @param {Event} ev + */ + _onFileChanged: function (ev) { + var self = this; + ev.stopPropagation(); + + var files = ev.target.files; + var attachment_ids = this.value.res_ids; + + // Don't create an attachment if the upload window is cancelled. + if(files.length === 0) + return; + + _.each(files, function (file) { + var record = _.find(self.value.data, function (attachment) { + return attachment.data.name === file.name; + }); + if (record) { + var metadata = self.metadata[record.id]; + if (!metadata || metadata.allowUnlink) { + // there is a existing attachment with the same name so we + // replace it + attachment_ids = _.without(attachment_ids, record.res_id); + self._rpc({ + model: 'ir.attachment', + method: 'unlink', + args: [record.res_id], + }); + } + } + self.uploadingFiles.push(file); + }); + + this._setValue({ + operation: 'REPLACE_WITH', + ids: attachment_ids, + }); + + this.$('form.o_form_binary_form').submit(); + this.$('.oe_fileupload').hide(); + ev.target.value = ""; + }, + /** + * @private + */ + _onFileLoaded: function () { + var self = this; + // the first argument isn't a file but the jQuery.Event + var files = Array.prototype.slice.call(arguments, 1); + // files has been uploaded, clear uploading + this.uploadingFiles = []; + + var attachment_ids = this.value.res_ids; + _.each(files, function (file) { + if (file.error) { + self.do_warn(_t('Uploading Error'), file.error); + } else { + attachment_ids.push(file.id); + self.uploadedFiles[file.id] = true; + } + }); + + this._setValue({ + operation: 'REPLACE_WITH', + ids: attachment_ids, + }); + }, +}); + +var FieldMany2ManyTags = AbstractField.extend({ + description: _lt("Tags"), + tag_template: "FieldMany2ManyTag", + className: "o_field_many2manytags", + supportedFieldTypes: ['many2many'], + custom_events: _.extend({}, AbstractField.prototype.custom_events, { + field_changed: '_onFieldChanged', + }), + events: _.extend({}, AbstractField.prototype.events, { + 'click .o_delete': '_onDeleteTag', + }), + fieldsToFetch: { + display_name: {type: 'char'}, + }, + limit: 1000, + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + if (this.mode === 'edit') { + this.className += ' o_input'; + } + + this.colorField = this.nodeOptions.color_field; + this.hasDropdown = false; + + this._computeAvailableActions(this.record); + // have listen to react to other fields changes to re-evaluate 'create' option + this.resetOnAnyFieldChange = this.resetOnAnyFieldChange || 'create' in this.nodeOptions; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + activate: function () { + return this.many2one ? this.many2one.activate() : false; + }, + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + return this.many2one ? this.many2one.getFocusableElement() : $(); + }, + /** + * @override + * @returns {boolean} + */ + isSet: function () { + return !!this.value && this.value.count; + }, + /** + * Reset the focus on this field if it was the origin of the onchange call. + * + * @override + */ + reset: function (record, event) { + var self = this; + this._computeAvailableActions(record); + return this._super.apply(this, arguments).then(function () { + if (event && event.target === self) { + self.activate(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {any} data + * @returns {Promise} + */ + _addTag: function (data) { + if (!_.contains(this.value.res_ids, data.id)) { + return this._setValue({ + operation: 'ADD_M2M', + ids: data + }); + } + return Promise.resolve(); + }, + /** + * @private + * @param {Object} record + */ + _computeAvailableActions: function (record) { + const evalContext = record.evalContext; + this.canCreate = 'create' in this.nodeOptions ? + new Domain(this.nodeOptions.create, evalContext).compute(evalContext) : + true; + }, + /** + * Get the QWeb rendering context used by the tag template; this computation + * is placed in a separate function for other tags to override it. + * + * @private + * @returns {Object} + */ + _getRenderTagsContext: function () { + var elements = this.value ? _.pluck(this.value.data, 'data') : []; + return { + colorField: this.colorField, + elements: elements, + hasDropdown: this.hasDropdown, + readonly: this.mode === "readonly", + }; + }, + /** + * @private + * @param {any} id + */ + _removeTag: function (id) { + var record = _.findWhere(this.value.data, {res_id: id}); + this._setValue({ + operation: 'FORGET', + ids: [record.id], + }); + }, + /** + * @private + */ + _renderEdit: function () { + var self = this; + this._renderTags(); + if (this.many2one) { + this.many2one.destroy(); + } + this.many2one = new FieldMany2One(this, this.name, this.record, { + mode: 'edit', + noOpen: true, + noCreate: !this.canCreate, + viewType: this.viewType, + attrs: this.attrs, + }); + // to prevent the M2O to take the value of the M2M + this.many2one.value = false; + // to prevent the M2O to take the relational values of the M2M + this.many2one.m2o_value = ''; + + this.many2one._getSearchBlacklist = function () { + return self.value.res_ids; + }; + var _getSearchCreatePopupOptions = this.many2one._getSearchCreatePopupOptions; + this.many2one._getSearchCreatePopupOptions = function (view, ids, context, dynamicFilters) { + var options = _getSearchCreatePopupOptions.apply(this, arguments); + var domain = this.record.getDomain({fieldName: this.name}); + var m2mRecords = []; + return _.extend({}, options, { + domain: domain.concat(["!", ["id", "in", self.value.res_ids]]), + disable_multiple_selection: false, + on_selected: function (records) { + m2mRecords.push(...records); + }, + on_closed: function () { + self.many2one.reinitialize(m2mRecords); + }, + }); + }; + return this.many2one.appendTo(this.$el); + }, + /** + * @private + */ + _renderReadonly: function () { + this._renderTags(); + }, + /** + * @private + */ + _renderTags: function () { + this.$el.html(qweb.render(this.tag_template, this._getRenderTagsContext())); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onDeleteTag: function (event) { + event.preventDefault(); + event.stopPropagation(); + this._removeTag($(event.target).parent().data('id')); + }, + /** + * Controls the changes made in the internal m2o field. + * + * @private + * @param {OdooEvent} ev + */ + _onFieldChanged: function (ev) { + if (ev.target !== this.many2one) { + return; + } + ev.stopPropagation(); + var newValue = ev.data.changes[this.name]; + if (newValue) { + this._addTag(newValue) + .then(ev.data.onSuccess || function () {}) + .guardedCatch(ev.data.onFailure || function () {}); + this.many2one.reinitialize(false); + } + }, + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.BACKSPACE && this.$('input').val() === "") { + var $badges = this.$('.badge'); + if ($badges.length) { + this._removeTag($badges.last().data('id')); + return; + } + } + this._super.apply(this, arguments); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onQuickCreate: function (event) { + this._quickCreate(event.data.value); + }, +}); + +var FieldMany2ManyTagsAvatar = FieldMany2ManyTags.extend({ + tag_template: 'FieldMany2ManyTagAvatar', + className: 'o_field_many2manytags avatar', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _getRenderTagsContext: function () { + var result = this._super.apply(this, arguments); + result.avatarModel = this.nodeOptions.avatarModel || this.field.relation; + result.avatarField = this.nodeOptions.avatarField || 'image_128'; + return result; + }, +}); + +var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({ + events: _.extend({}, FieldMany2ManyTags.prototype.events, { + 'click .dropdown-toggle': '_onOpenColorPicker', + 'mousedown .o_colorpicker a': '_onUpdateColor', + 'mousedown .o_colorpicker .o_hide_in_kanban': '_onUpdateColor', + }), + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + this.hasDropdown = !!this.colorField; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onOpenColorPicker: function (ev) { + ev.preventDefault(); + if (this.nodeOptions.no_edit_color) { + ev.stopPropagation(); + return; + } + var tagID = $(ev.currentTarget).parent().data('id'); + var tagColor = $(ev.currentTarget).parent().data('color'); + var tag = _.findWhere(this.value.data, { res_id: tagID }); + if (tag && this.colorField in tag.data) { // if there is a color field on the related model + this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', { + 'widget': this, + 'tag_id': tagID, + })); + + $(ev.currentTarget).after(this.$color_picker); + this.$color_picker.dropdown(); + this.$color_picker.attr("tabindex", 1).focus(); + if (!tagColor) { + this.$('.custom-checkbox input').prop('checked', true); + } + } + }, + /** + * Update color based on target of ev + * either by clicking on a color item or + * by toggling the 'Hide in Kanban' checkbox. + * + * @private + * @param {MouseEvent} ev + */ + _onUpdateColor: function (ev) { + ev.preventDefault(); + var $target = $(ev.currentTarget); + var color = $target.data('color'); + var id = $target.data('id'); + var $tag = this.$(".badge[data-id='" + id + "']"); + var currentColor = $tag.data('color'); + var changes = {}; + + if ($target.is('.o_hide_in_kanban')) { + var $checkbox = $('.o_hide_in_kanban .custom-checkbox input'); + $checkbox.prop('checked', !$checkbox.prop('checked')); // toggle checkbox + this.prevColors = this.prevColors ? this.prevColors : {}; + if ($checkbox.is(':checked')) { + this.prevColors[id] = currentColor; + } else { + color = this.prevColors[id] ? this.prevColors[id] : 1; + } + } else if ($target.is('[class^="o_tag_color"]')) { // $target.is('o_tag_color_') + if (color === currentColor) { return; } + } + + changes[this.colorField] = color; + + this.trigger_up('field_changed', { + dataPointID: _.findWhere(this.value.data, {res_id: id}).id, + changes: changes, + force_save: true, + }); + }, +}); + +var KanbanFieldMany2ManyTags = FieldMany2ManyTags.extend({ + // Remove event handlers on this widget to ensure that the kanban 'global + // click' opens the clicked record, even if the click is done on a tag + // This is necessary because of the weird 'global click' logic in + // KanbanRecord, which should definitely be cleaned. + // Anyway, those handlers are only necessary in Form and List views, so we + // can removed them here. + events: AbstractField.prototype.events, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + var self = this; + + if (this.$el) { + this.$el.empty().addClass('o_field_many2manytags o_kanban_tags'); + } + + _.each(this.value.data, function (m2m) { + if (self.colorField in m2m.data && !m2m.data[self.colorField]) { + // When a color field is specified and that color is the default + // one, the kanban tag is not rendered. + return; + } + + $('<span>', { + class: 'o_tag o_tag_color_' + (m2m.data[self.colorField] || 0), + text: m2m.data.display_name, + }) + .prepend('<span>') + .appendTo(self.$el); + }); + }, +}); + +var FieldMany2ManyCheckBoxes = AbstractField.extend({ + description: _lt("Checkboxes"), + template: 'FieldMany2ManyCheckBoxes', + events: _.extend({}, AbstractField.prototype.events, { + change: '_onChange', + }), + specialData: "_fetchSpecialRelation", + supportedFieldTypes: ['many2many'], + // set an arbitrary high limit to ensure that all data returned by the server + // are processed by the BasicModel (otherwise it would be 40) + limit: 100000, + init: function () { + this._super.apply(this, arguments); + this.m2mValues = this.record.specialData[this.name]; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _renderCheckboxes: function () { + var self = this; + this.m2mValues = this.record.specialData[this.name]; + this.$el.html(qweb.render(this.template, {widget: this})); + _.each(this.value.res_ids, function (id) { + self.$('input[data-record-id="' + id + '"]').prop('checked', true); + }); + }, + /** + * @override + * @private + */ + _renderEdit: function () { + this._renderCheckboxes(); + }, + /** + * @override + * @private + */ + _renderReadonly: function () { + this._renderCheckboxes(); + this.$("input").prop("disabled", true); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChange: function () { + // Get the list of selected ids + var ids = _.map(this.$('input:checked'), function (input) { + return $(input).data("record-id"); + }); + // The number of displayed checkboxes is limited to 100 (name_search + // limit, server-side), to prevent extreme cases where thousands of + // records are fetched/displayed. If not all values are displayed, it may + // happen that some values that are in the relation aren't available in the + // widget. In this case, when the user (un)selects a value, we don't + // want to remove those non displayed values from the relation. For that + // reason, we manually add those values to the list of ids. + const displayedIds = this.m2mValues.map(v => v[0]); + const idsInRelation = this.value.res_ids; + ids = ids.concat(idsInRelation.filter(a => !displayedIds.includes(a))); + this._setValue({ + operation: 'REPLACE_WITH', + ids: ids, + }); + }, +}); + +//------------------------------------------------------------------------------ +// Widgets handling both basic and relational fields (selection and Many2one) +//------------------------------------------------------------------------------ + +var FieldStatus = AbstractField.extend({ + className: 'o_statusbar_status', + events: { + 'click button:not(.dropdown-toggle)': '_onClickStage', + }, + specialData: "_fetchSpecialStatus", + supportedFieldTypes: ['selection', 'many2one'], + /** + * @override init from AbstractField + */ + init: function () { + this._super.apply(this, arguments); + this._setState(); + this._onClickStage = _.debounce(this._onClickStage, 300, true); // TODO maybe not useful anymore ? + + // Retro-compatibility: clickable used to be defined in the field attrs + // instead of options. + // If not set, the statusbar is not clickable. + try { + this.isClickable = !!JSON.parse(this.attrs.clickable); + } catch (_) { + this.isClickable = !!this.nodeOptions.clickable; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns false to force the statusbar to be always visible (even the field + * it not set). + * + * @override + * @returns {boolean} always false + */ + isEmpty: function () { + return false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override _reset from AbstractField + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this._setState(); + }, + /** + * Prepares the rendering data from the field and record data. + * @private + */ + _setState: function () { + var self = this; + if (this.field.type === 'many2one') { + this.status_information = _.map(this.record.specialData[this.name], function (info) { + return _.extend({ + selected: info.id === self.value.res_id, + }, info); + }); + } else { + var selection = this.field.selection; + if (this.attrs.statusbar_visible) { + var restriction = this.attrs.statusbar_visible.split(","); + selection = _.filter(selection, function (val) { + return _.contains(restriction, val[0]) || val[0] === self.value; + }); + } + this.status_information = _.map(selection, function (val) { + return { id: val[0], display_name: val[1], selected: val[0] === self.value, fold: false }; + }); + } + }, + /** + * @override _render from AbstractField + * @private + */ + _render: function () { + var selections = _.partition(this.status_information, function (info) { + return (info.selected || !info.fold); + }); + this.$el.html(qweb.render("FieldStatus.content", { + selection_unfolded: selections[0], + selection_folded: selections[1], + clickable: this.isClickable, + })); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when on status stage is clicked -> sets the field value. + * + * @private + * @param {MouseEvent} e + */ + _onClickStage: function (e) { + this._setValue($(e.currentTarget).data("value")); + }, +}); + +/** + * The FieldSelection widget is a simple select tag with a dropdown menu to + * allow the selection of a range of values. It is designed to work with fields + * of type 'selection' and 'many2one'. + */ +var FieldSelection = AbstractField.extend({ + description: _lt("Selection"), + template: 'FieldSelection', + specialData: "_fetchSpecialRelation", + supportedFieldTypes: ['selection'], + events: _.extend({}, AbstractField.prototype.events, { + 'change': '_onChange', + }), + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this._setValues(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + return this.$el && this.$el.is('select') ? this.$el : $(); + }, + /** + * @override + */ + isSet: function () { + return this.value !== false; + }, + /** + * Listen to modifiers updates to hide/show the falsy value in the dropdown + * according to the required modifier. + * + * @override + */ + updateModifiersValue: function () { + this._super.apply(this, arguments); + if (!this.attrs.modifiersValue.invisible && this.mode !== 'readonly') { + this._setValues(); + this._renderEdit(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _renderEdit: function () { + this.$el.empty(); + var required = this.attrs.modifiersValue && this.attrs.modifiersValue.required; + for (var i = 0 ; i < this.values.length ; i++) { + var disabled = required && this.values[i][0] === false; + + this.$el.append($('<option/>', { + value: JSON.stringify(this.values[i][0]), + text: this.values[i][1], + style: disabled ? "display: none" : "", + })); + } + this.$el.val(JSON.stringify(this._getRawValue())); + }, + /** + * @override + * @private + */ + _renderReadonly: function () { + this.$el.empty().text(this._formatValue(this.value)); + this.$el.attr('raw-value', this._getRawValue()); + }, + _getRawValue: function() { + var raw_value = this.value; + if (this.field.type === 'many2one' && raw_value) { + raw_value = raw_value.data.id; + } + return raw_value; + }, + /** + * @override + */ + _reset: function () { + this._super.apply(this, arguments); + this._setValues(); + }, + /** + * Sets the possible field values. If the field is a many2one, those values + * may change during the lifecycle of the widget if the domain change (an + * onchange may change the domain). + * + * @private + */ + _setValues: function () { + if (this.field.type === 'many2one') { + this.values = this.record.specialData[this.name]; + this.formatType = 'many2one'; + } else { + this.values = _.reject(this.field.selection, function (v) { + return v[0] === false && v[1] === ''; + }); + } + this.values = [[false, this.attrs.placeholder || '']].concat(this.values); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * The small slight difficulty is that we have to set the value differently + * depending on the field type. + * + * @private + */ + _onChange: function () { + var res_id = JSON.parse(this.$el.val()); + if (this.field.type === 'many2one') { + var value = _.find(this.values, function (val) { + return val[0] === res_id; + }); + this._setValue({id: res_id, display_name: value[1]}); + } else { + this._setValue(res_id); + } + }, +}); + +var FieldRadio = FieldSelection.extend({ + description: _lt("Radio"), + template: null, + className: 'o_field_radio', + tagName: 'span', + specialData: "_fetchSpecialMany2ones", + supportedFieldTypes: ['selection', 'many2one'], + events: _.extend({}, AbstractField.prototype.events, { + 'click input': '_onInputClick', + }), + /** + * @constructs FieldRadio + */ + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit') { + this.tagName = 'div'; + this.className += this.nodeOptions.horizontal ? ' o_horizontal' : ' o_vertical'; + } + this.unique_id = _.uniqueId("radio"); + this._setValues(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the currently-checked radio button, or the first one if no radio + * button is checked. + * + * @override + */ + getFocusableElement: function () { + var checked = this.$("[checked='true']"); + return checked.length ? checked : this.$("[data-index='0']"); + }, + + /** + * @override + * @returns {boolean} always true + */ + isSet: function () { + return true; + }, + + /** + * Associates the 'for' attribute to the radiogroup, instead of the selected + * radio button. + * + * @param {string} id + */ + setIDForLabel: function (id) { + this.$el.attr('id', id); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @override + */ + _renderEdit: function () { + var self = this; + var currentValue; + if (this.field.type === 'many2one') { + currentValue = this.value && this.value.data.id; + } else { + currentValue = this.value; + } + this.$el.empty(); + this.$el.attr('role', 'radiogroup') + .attr('aria-label', this.string); + _.each(this.values, function (value, index) { + self.$el.append(qweb.render('FieldRadio.button', { + checked: value[0] === currentValue, + id: self.unique_id + '_' + value[0], + index: index, + name: self.unique_id, + value: value, + })); + }); + }, + /** + * @override + */ + _reset: function () { + this._super.apply(this, arguments); + this._setValues(); + }, + /** + * Sets the possible field values. If the field is a many2one, those values + * may change during the lifecycle of the widget if the domain change (an + * onchange may change the domain). + * + * @private + */ + _setValues: function () { + if (this.field.type === 'selection') { + this.values = this.field.selection || []; + } else if (this.field.type === 'many2one') { + this.values = _.map(this.record.specialData[this.name], function (val) { + return [val.id, val.display_name]; + }); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onInputClick: function (event) { + var index = $(event.target).data('index'); + var value = this.values[index]; + if (this.field.type === 'many2one') { + this._setValue({id: value[0], display_name: value[1]}); + } else { + this._setValue(value[0]); + } + }, +}); + + +var FieldSelectionBadge = FieldSelection.extend({ + description: _lt("Badges"), + template: null, + className: 'o_field_selection_badge', + tagName: 'span', + specialData: "_fetchSpecialMany2ones", + events: _.extend({}, AbstractField.prototype.events, { + 'click span.o_selection_badge': '_onBadgeClicked', + }), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @override + */ + _renderEdit: function () { + this.currentValue = this.value; + + if (this.field.type === 'many2one') { + this.currentValue = this.value && this.value.data.id; + } + this.$el.empty(); + this.$el.html(qweb.render('FieldSelectionBadge', {'values': this.values, 'current_value': this.currentValue})); + }, + /** + * Sets the possible field values. If the field is a many2one, those values + * may change during the life cycle of the widget if the domain change (an + * onchange may change the domain). + * + * @private + * @override + */ + _setValues: function () { + // Note: We can make abstract widget for common code in radio and selection badge + if (this.field.type === 'selection') { + this.values = this.field.selection || []; + } else if (this.field.type === 'many2one') { + this.values = _.map(this.record.specialData[this.name], function (val) { + return [val.id, val.display_name]; + }); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onBadgeClicked: function (event) { + var index = $(event.target).data('index'); + var value = this.values[index]; + if (value[0] !== this.currentValue) { + if (this.field.type === 'many2one') { + this._setValue({id: value[0], display_name: value[1]}); + } else { + this._setValue(value[0]); + } + } else { + this._setValue(false); + } + }, +}); + +var FieldSelectionFont = FieldSelection.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Changes CSS for all options according to their value. + * Also removes empty labels. + * + * @private + * @override + */ + _renderEdit: function () { + this._super.apply(this, arguments); + + this.$('option').each(function (i, option) { + if (! option.label) { + $(option).remove(); + } + $(option).css('font-family', option.value); + }); + this.$el.css('font-family', this.value); + }, +}); + +/** + * The FieldReference is a combination of a select (for the model) and + * a FieldMany2one for its value. + * Its intern representation is similar to the many2one (a datapoint with a + * `name_get` as data). + * Note that there is some logic to support char field because of one use in our + * codebase, but this use should be removed along with this note. + */ +var FieldReference = FieldMany2One.extend({ + specialData: "_fetchSpecialReference", + supportedFieldTypes: ['reference'], + template: 'FieldReference', + events: _.extend({}, FieldMany2One.prototype.events, { + 'change select': '_onSelectionChange', + }), + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + // needs to be copied as it is an unmutable object + this.field = _.extend({}, this.field); + + this._setState(); + }, + /** + * @override + */ + start: function () { + this.$('select').val(this.field.relation); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + if (this.mode === 'edit' && !this.field.relation) { + return this.$('select'); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get the encompassing record's display_name + * + * @override + */ + _formatValue: function () { + var value; + if (this.field.type === 'char') { + value = this.record.specialData[this.name]; + } else { + value = this.value; + } + return value && value.data && value.data.display_name || ''; + }, + + /** + * Add a select in edit mode (for the model). + * + * @override + */ + _renderEdit: function () { + this._super.apply(this, arguments); + + if (this.$('select').val()) { + this.$('.o_input_dropdown').show(); + this.$el.addClass('o_row'); // this class is used to display the two + // components (select & input) on the same line + } else { + // hide the many2one if the selection is empty + this.$('.o_input_dropdown').hide(); + } + + }, + /** + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + var value = this.$('select').val(); + this._setState(); + this.$('select').val(this.value && this.value.model || value); + }, + /** + * Set `relation` key in field properties. + * + * @private + * @param {string} model + */ + _setRelation: function (model) { + // used to generate the search in many2one + this.field.relation = model; + }, + /** + * @private + */ + _setState: function () { + if (this.field.type === 'char') { + // in this case, the value is stored in specialData instead + this.value = this.record.specialData[this.name]; + } + + if (this.value) { + this._setRelation(this.value.model); + } + }, + /** + * @override + * @private + */ + _setValue: function (value, options) { + value = value || {}; + // we need to specify the model for the change in basic_model + // the value is then now a dict with id, display_name and model + value.model = this.$('select').val(); + return this._super(value, options); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When the selection (model) changes, the many2one is reset. + * + * @private + */ + _onSelectionChange: function () { + var value = this.$('select').val(); + this.reinitialize(false); + this._setRelation(value); + }, +}); + +return { + FieldMany2One: FieldMany2One, + Many2oneBarcode: Many2oneBarcode, + KanbanFieldMany2One: KanbanFieldMany2One, + ListFieldMany2One: ListFieldMany2One, + Many2OneAvatar: Many2OneAvatar, + + FieldX2Many: FieldX2Many, + FieldOne2Many: FieldOne2Many, + + FieldMany2Many: FieldMany2Many, + FieldMany2ManyBinaryMultiFiles: FieldMany2ManyBinaryMultiFiles, + FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes, + FieldMany2ManyTags: FieldMany2ManyTags, + FieldMany2ManyTagsAvatar: FieldMany2ManyTagsAvatar, + FormFieldMany2ManyTags: FormFieldMany2ManyTags, + KanbanFieldMany2ManyTags: KanbanFieldMany2ManyTags, + + FieldRadio: FieldRadio, + FieldSelectionBadge: FieldSelectionBadge, + FieldSelection: FieldSelection, + FieldStatus: FieldStatus, + FieldSelectionFont: FieldSelectionFont, + + FieldReference: FieldReference, +}; + +}); diff --git a/addons/web/static/src/js/fields/signature.js b/addons/web/static/src/js/fields/signature.js new file mode 100644 index 00000000..de70f72c --- /dev/null +++ b/addons/web/static/src/js/fields/signature.js @@ -0,0 +1,173 @@ +odoo.define('web.Signature', function (require) { + "use strict"; + + var AbstractFieldBinary = require('web.basic_fields').AbstractFieldBinary; + var core = require('web.core'); + var field_utils = require('web.field_utils'); + var registry = require('web.field_registry'); + var session = require('web.session'); + const SignatureDialog = require('web.signature_dialog'); + var utils = require('web.utils'); + + + var qweb = core.qweb; + var _t = core._t; + var _lt = core._lt; + +var FieldBinarySignature = AbstractFieldBinary.extend({ + description: _lt("Signature"), + fieldDependencies: _.extend({}, AbstractFieldBinary.prototype.fieldDependencies, { + __last_update: {type: 'datetime'}, + }), + resetOnAnyFieldChange: true, + custom_events: _.extend({}, AbstractFieldBinary.prototype.custom_events, { + upload_signature: '_onUploadSignature', + }), + events: _.extend({}, AbstractFieldBinary.prototype.events, { + 'click .o_signature': '_onClickSignature', + }), + template: null, + supportedFieldTypes: ['binary'], + file_type_magic_word: { + '/': 'jpg', + 'R': 'gif', + 'i': 'png', + 'P': 'svg+xml', + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This widget must always have render even if there are no signature. + * In edit mode, the real value is return to manage required fields. + * + * @override + */ + isSet: function () { + if (this.mode === 'edit') { + return this.value; + } + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders an empty signature or the saved signature. Both must have the same size. + * + * @override + * @private + */ + + _render: function () { + var self = this; + var displaySignatureRatio = 3; + var url; + var $img; + var width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width; + var height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height; + if (this.value) { + if (!utils.is_bin_size(this.value)) { + // Use magic-word technique for detecting image type + url = 'data:image/' + (this.file_type_magic_word[this.value[0]] || 'png') + ';base64,' + this.value; + } else { + url = session.url('/web/image', { + model: this.model, + id: JSON.stringify(this.res_id), + field: this.nodeOptions.preview_image || this.name, + // unique forces a reload of the image when the record has been updated + unique: field_utils.format.datetime(this.recordData.__last_update).replace(/[^0-9]/g, ''), + }); + } + $img = $(qweb.render("FieldBinarySignature-img", {widget: this, url: url})); + } else { + $img = $('<div class="o_signature o_signature_empty"><svg></svg><p>' + _t('SIGNATURE') + '</p></div>'); + if (width && height) { + width = Math.min(width, displaySignatureRatio * height); + height = width / displaySignatureRatio; + } else if (width) { + height = width / displaySignatureRatio; + } else if (height) { + width = height * displaySignatureRatio; + } + } + if (width) { + $img.attr('width', width); + $img.css('max-width', width + 'px'); + } + if (height) { + $img.attr('height', height); + $img.css('max-height', height + 'px'); + } + this.$('> div').remove(); + this.$('> img').remove(); + + this.$el.prepend($img); + + $img.on('error', function () { + self._clearFile(); + $img.attr('src', self.placeholder); + self.do_warn(false, _t("Could not display the selected image")); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * If the view is in edit mode, open dialog to sign. + * + * @private + */ + _onClickSignature: function () { + var self = this; + if (this.mode === 'edit') { + + var nameAndSignatureOptions = { + mode: 'draw', + displaySignatureRatio: 3, + signatureType: 'signature', + noInputName: true, + }; + + if (this.nodeOptions.full_name) { + var signName; + if (this.fields[this.nodeOptions.full_name].type === 'many2one') { + // If m2o is empty, it will have falsy value in recordData + signName = this.recordData[this.nodeOptions.full_name] && this.recordData[this.nodeOptions.full_name].data.display_name; + } else { + signName = this.recordData[this.nodeOptions.full_name]; + } + nameAndSignatureOptions.defaultName = (signName === '') ? undefined : signName; + } + + nameAndSignatureOptions.defaultFont = this.nodeOptions.default_font || ''; + this.signDialog = new SignatureDialog(self, {nameAndSignatureOptions: nameAndSignatureOptions}); + + this.signDialog.open(); + } + }, + + /** + * Upload the signature image if valid and close the dialog. + * + * @private + */ + _onUploadSignature: function (ev) { + var signatureImage = ev.data.signatureImage; + if (signatureImage !== this.signDialog.emptySignature) { + var data = signatureImage[1]; + var type = signatureImage[0].split('/')[1]; + this.on_file_uploaded(data.length, ev.data.name, type, data); + } + this.signDialog.close(); + } +}); + +registry.add('signature', FieldBinarySignature); + +}); diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js new file mode 100644 index 00000000..52ef6d51 --- /dev/null +++ b/addons/web/static/src/js/fields/special_fields.js @@ -0,0 +1,262 @@ +odoo.define('web.special_fields', function (require) { +"use strict"; + +var core = require('web.core'); +var field_utils = require('web.field_utils'); +var relational_fields = require('web.relational_fields'); +var AbstractField = require('web.AbstractField'); + +var FieldSelection = relational_fields.FieldSelection; +var _t = core._t; +var _lt = core._lt; + + +/** + * This widget is intended to display a warning near a label of a 'timezone' field + * indicating if the browser timezone is identical (or not) to the selected timezone. + * This widget depends on a field given with the param 'tz_offset_field', which contains + * the time difference between UTC time and local time, in minutes. + */ +var FieldTimezoneMismatch = FieldSelection.extend({ + /** + * @override + */ + start: function () { + var interval = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? 60000 : 1000; + this._datetime = setInterval(this._renderDateTimeTimezone.bind(this), interval); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + clearInterval(this._datetime); + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + this._renderTimezoneMismatch(); + }, + /** + * Display the time in the user timezone (reload each second) + * + * @private + */ + _renderDateTimeTimezone: function () { + if (!this.mismatch || !this.$option.html()) { + return; + } + var offset = this.recordData.tz_offset.match(/([+-])([0-9]{2})([0-9]{2})/); + offset = (offset[1] === '-' ? -1 : 1) * (parseInt(offset[2])*60 + parseInt(offset[3])); + var datetime = field_utils.format.datetime(moment.utc().add(offset, 'minutes'), this.field, {timezone: false}); + var content = this.$option.html().split(' ')[0]; + content += ' ('+ datetime + ')'; + this.$option.html(content); + }, + /** + * Display the timezone alert + * + * Note: timezone alert is a span that is added after $el, and $el is now a + * set of two elements + * + * @private + */ + _renderTimezoneMismatch: function () { + // we need to clean the warning to have maximum one alert + this.$el.last().filter('.o_tz_warning').remove(); + this.$el = this.$el.first(); + var value = this.$el.val(); + var $span = $('<span class="fa fa-exclamation-triangle o_tz_warning"/>'); + + if (this.$option && this.$option.html()) { + this.$option.html(this.$option.html().split(' ')[0]); + } + + var userOffset = this.recordData.tz_offset; + this.mismatch = false; + if (userOffset && value !== "" && value !== "false") { + var offset = -(new Date().getTimezoneOffset()); + var browserOffset = (offset < 0) ? "-" : "+"; + browserOffset += _.str.sprintf("%02d", Math.abs(offset / 60)); + browserOffset += _.str.sprintf("%02d", Math.abs(offset % 60)); + this.mismatch = (browserOffset !== userOffset); + } + + if (this.mismatch){ + $span.insertAfter(this.$el); + $span.attr('title', _t("Timezone Mismatch : This timezone is different from that of your browser.\nPlease, set the same timezone as your browser's to avoid time discrepancies in your system.")); + this.$el = this.$el.add($span); + + this.$option = this.$('option').filter(function () { + return $(this).attr('value') === value; + }); + this._renderDateTimeTimezone(); + } else if (value == "false") { + $span.insertAfter(this.$el); + $span.attr('title', _t("Set a timezone on your user")); + this.$el = this.$el.add($span); + } + }, + /** + * @override + * @private + * this.$el can have other elements than select + * that should not be touched + */ + _renderEdit: function () { + // FIXME: hack to handle multiple root elements + // in this.$el , which is a bad idea + // In master we should make this.$el a wrapper + // around multiple subelements + var $otherEl = this.$el.not('select'); + this.$el = this.$el.first(); + + this._super.apply(this, arguments); + + $otherEl.insertAfter(this.$el); + this.$el = this.$el.add($otherEl); + }, +}); + +var FieldReportLayout = relational_fields.FieldMany2One.extend({ + // this widget is not generic, so we disable its studio use + // supportedFieldTypes: ['many2one', 'selection'], + events: _.extend({}, relational_fields.FieldMany2One.prototype.events, { + 'click img': '_onImgClicked', + }), + + willStart: function () { + var self = this; + this.previews = {}; + return this._super() + .then(function () { + return self._rpc({ + model: 'report.layout', + method: "search_read" + }).then(function (values) { + self.previews = values; + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + var self = this; + this.$el.empty(); + var value = _.isObject(this.value) ? this.value.data.id : this.value; + _.each(this.previews, function (val) { + var $container = $('<div>').addClass('col-3 text-center'); + var $img = $('<img>') + .addClass('img img-fluid img-thumbnail ml16') + .toggleClass('btn-info', val.view_id[0] === value) + .attr('src', val.image) + .data('key', val.view_id[0]); + $container.append($img); + if (val.pdf) { + var $previewLink = $('<a>') + .text('Example') + .attr('href', val.pdf) + .attr('target', '_blank'); + $container.append($previewLink); + } + self.$el.append($container); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @param {MouseEvent} event + */ + _onImgClicked: function (event) { + this._setValue($(event.currentTarget).data('key')); + }, +}); + + +const IframeWrapper = AbstractField.extend({ + description: _lt("Wrap raw html within an iframe"), + + // If HTML, don't forget to adjust the sanitize options to avoid stripping most of the metadata + supportedFieldTypes: ['text', 'html'], + + template: "web.IframeWrapper", + + _render() { + + const spinner = this.el.querySelector('.o_iframe_wrapper_spinner'); + const iframe = this.el.querySelector('.o_preview_iframe'); + + iframe.style.display = 'none'; + spinner.style.display = 'block'; + + // Promise for tests + let resolver; + $(iframe).data('ready', new Promise((resolve) => { + resolver = resolve; + })); + + /** + * Certain browser don't trigger onload events of iframe for particular cases. + * In our case, chrome and safari could be problematic depending on version and environment. + * This rather unorthodox solution replace the onload event handler. (jquery on('load') doesn't fix it) + */ + const onloadReplacement = setInterval(() => { + const iframeDoc = iframe.contentDocument; + if (iframeDoc && (iframeDoc.readyState === 'complete' || iframeDoc.readyState === 'interactive')) { + + /** + * The document.write is not recommended. It is better to manipulate the DOM through $.appendChild and + * others. In our case though, we deal with an iframe without src attribute and with metadata to put in + * head tag. If we use the usual dom methods, the iframe is automatically created with its document + * component containing html > head & body. Therefore, if we want to make it work that way, we would + * need to receive each piece at a time to append it to this document (with this.record.data and extra + * model fields or with an rpc). It also cause other difficulties getting attribute on the most parent + * nodes, parsing to HTML complex elements, etc. + * Therefore, document.write makes it much more trivial in our situation. + */ + iframeDoc.open(); + iframeDoc.write(this.value); + iframeDoc.close(); + + iframe.style.display = 'block'; + spinner.style.display = 'none'; + + resolver(); + + clearInterval(onloadReplacement); + } + }, 100); + + } + +}); + + +return { + FieldTimezoneMismatch: FieldTimezoneMismatch, + FieldReportLayout: FieldReportLayout, + IframeWrapper, +}; + +}); diff --git a/addons/web/static/src/js/fields/upgrade_fields.js b/addons/web/static/src/js/fields/upgrade_fields.js new file mode 100644 index 00000000..36af3956 --- /dev/null +++ b/addons/web/static/src/js/fields/upgrade_fields.js @@ -0,0 +1,199 @@ +odoo.define('web.upgrade_widgets', function (require) { +"use strict"; + +/** + * The upgrade widgets are intended to be used in config settings. + * When checked, an upgrade popup is showed to the user. + */ + +var AbstractField = require('web.AbstractField'); +var basic_fields = require('web.basic_fields'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var field_registry = require('web.field_registry'); +var framework = require('web.framework'); +var relational_fields = require('web.relational_fields'); + +var _t = core._t; +var QWeb = core.qweb; + +var FieldBoolean = basic_fields.FieldBoolean; +var FieldRadio = relational_fields.FieldRadio; + + +/** + * Mixin that defines the common functions shared between Boolean and Radio + * upgrade widgets + */ +var AbstractFieldUpgrade = { + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Redirects the user to the odoo-enterprise/uprade page + * + * @private + * @returns {Promise} + */ + _confirmUpgrade: function () { + return this._rpc({ + model: 'res.users', + method: 'search_count', + args: [[["share", "=", false]]], + }) + .then(function (data) { + framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data); + }); + }, + /** + * This function is meant to be overridden to insert the 'Enterprise' label + * JQuery node at the right place. + * + * @abstract + * @private + * @param {jQuery} $enterpriseLabel the 'Enterprise' label to insert + */ + _insertEnterpriseLabel: function ($enterpriseLabel) {}, + /** + * Opens the Upgrade dialog. + * + * @private + * @returns {Dialog} the instance of the opened Dialog + */ + _openDialog: function () { + var message = $(QWeb.render('EnterpriseUpgrade')); + + var buttons = [ + { + text: _t("Upgrade now"), + classes: 'btn-primary', + close: true, + click: this._confirmUpgrade.bind(this), + }, + { + text: _t("Cancel"), + close: true, + }, + ]; + + return new Dialog(this, { + size: 'medium', + buttons: buttons, + $content: $('<div>', { + html: message, + }), + title: _t("Odoo Enterprise"), + }).open(); + }, + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + this._insertEnterpriseLabel($("<span>", { + text: "Enterprise", + 'class': "badge badge-primary oe_inline o_enterprise_label" + })); + }, + /** + * This function is meant to be overridden to reset the $el to its initial + * state. + * + * @abstract + * @private + */ + _resetValue: function () {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onInputClicked: function (event) { + if ($(event.currentTarget).prop("checked")) { + this._openDialog().on('closed', this, this._resetValue.bind(this)); + } + }, + +}; + +var UpgradeBoolean = FieldBoolean.extend(AbstractFieldUpgrade, { + supportedFieldTypes: [], + events: _.extend({}, AbstractField.prototype.events, { + 'click input': '_onInputClicked', + }), + /** + * Re-renders the widget with the label + * + * @param {jQuery} $label + */ + renderWithLabel: function ($label) { + this.$label = $label; + this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _insertEnterpriseLabel: function ($enterpriseLabel) { + var $el = this.$label || this.$el; + $el.append(' ').append($enterpriseLabel); + }, + /** + * @override + * @private + */ + _resetValue: function () { + this.$input.prop("checked", false).change(); + }, +}); + +var UpgradeRadio = FieldRadio.extend(AbstractFieldUpgrade, { + supportedFieldTypes: [], + events: _.extend({}, FieldRadio.prototype.events, { + 'click input:last': '_onInputClicked', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _insertEnterpriseLabel: function ($enterpriseLabel) { + this.$('label').last().append(' ').append($enterpriseLabel); + }, + /** + * @override + * @private + */ + _resetValue: function () { + this.$('input').first().prop("checked", true).click(); + }, +}); + +field_registry + .add('upgrade_boolean', UpgradeBoolean) + .add('upgrade_radio', UpgradeRadio); + +}); diff --git a/addons/web/static/src/js/libs/autocomplete.js b/addons/web/static/src/js/libs/autocomplete.js new file mode 100644 index 00000000..72f3ee89 --- /dev/null +++ b/addons/web/static/src/js/libs/autocomplete.js @@ -0,0 +1,38 @@ +odoo.define('web.autocomplete.extensions', function () { +'use strict'; + +/** + * The jquery autocomplete library extensions and fixes should be done here to + * avoid patching in place. + */ + +// jquery autocomplete tweak to allow html and classnames +var proto = $.ui.autocomplete.prototype; +var initSource = proto._initSource; + +function filter( array, term ) { + var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term), "i" ); + return $.grep( array, function (value_) { + return matcher.test( $( "<div>" ).html( value_.label || value_.value || value_ ).text() ); + }); +} + +$.extend(proto, { + _initSource: function () { + if ( this.options.html && $.isArray(this.options.source) ) { + this.source = function (request, response) { + response( filter( this.options.source, request.term ) ); + }; + } else { + initSource.call( this ); + } + }, + _renderItem: function (ul, item) { + return $( "<li></li>" ) + .data( "item.autocomplete", item ) + .append( $( "<a></a>" )[ this.options.html ? "html" : "text" ]( item.label ) ) + .appendTo( ul ) + .addClass(item.classname); + }, +}); +}); diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js new file mode 100644 index 00000000..9854b47a --- /dev/null +++ b/addons/web/static/src/js/libs/bootstrap.js @@ -0,0 +1,131 @@ +odoo.define('web.bootstrap.extensions', function () { +'use strict'; + +/** + * The bootstrap library extensions and fixes should be done here to avoid + * patching in place. + */ + +/** + * Review Bootstrap Sanitization: leave it enabled by default but extend it to + * accept more common tag names like tables and buttons, and common attributes + * such as style or data-. If a specific tooltip or popover must accept custom + * tags or attributes, they must be supplied through the whitelist BS + * parameter explicitely. + * + * We cannot disable sanitization because bootstrap uses tooltip/popover + * DOM attributes in an "unsafe" way. + */ +var bsSanitizeWhiteList = $.fn.tooltip.Constructor.Default.whiteList; + +bsSanitizeWhiteList['*'].push('title', 'style', /^data-[\w-]+/); + +bsSanitizeWhiteList.header = []; +bsSanitizeWhiteList.main = []; +bsSanitizeWhiteList.footer = []; + +bsSanitizeWhiteList.caption = []; +bsSanitizeWhiteList.col = ['span']; +bsSanitizeWhiteList.colgroup = ['span']; +bsSanitizeWhiteList.table = []; +bsSanitizeWhiteList.thead = []; +bsSanitizeWhiteList.tbody = []; +bsSanitizeWhiteList.tfooter = []; +bsSanitizeWhiteList.tr = []; +bsSanitizeWhiteList.th = ['colspan', 'rowspan']; +bsSanitizeWhiteList.td = ['colspan', 'rowspan']; + +bsSanitizeWhiteList.address = []; +bsSanitizeWhiteList.article = []; +bsSanitizeWhiteList.aside = []; +bsSanitizeWhiteList.blockquote = []; +bsSanitizeWhiteList.section = []; + +bsSanitizeWhiteList.button = ['type']; +bsSanitizeWhiteList.del = []; + +/** + * Returns an extended version of bootstrap default whitelist for sanitization, + * i.e. a version where, for each key, the original value is concatened with the + * received version's value and where the received version's extra key/values + * are added. + * + * Note: the returned version + * + * @param {Object} extensions + * @returns {Object} /!\ the returned whitelist is made from a *shallow* copy of + * the default whitelist, extended with given whitelist. + */ +function makeExtendedSanitizeWhiteList(extensions) { + var whiteList = _.clone($.fn.tooltip.Constructor.Default.whiteList); + Object.keys(extensions).forEach(key => { + whiteList[key] = (whiteList[key] || []).concat(extensions[key]); + }); + return whiteList; +} + +/* Bootstrap tooltip defaults overwrite */ +$.fn.tooltip.Constructor.Default.placement = 'auto'; +$.fn.tooltip.Constructor.Default.fallbackPlacement = ['bottom', 'right', 'left', 'top']; +$.fn.tooltip.Constructor.Default.html = true; +$.fn.tooltip.Constructor.Default.trigger = 'hover'; +$.fn.tooltip.Constructor.Default.container = 'body'; +$.fn.tooltip.Constructor.Default.boundary = 'window'; +$.fn.tooltip.Constructor.Default.delay = { show: 1000, hide: 0 }; + +var bootstrapShowFunction = $.fn.tooltip.Constructor.prototype.show; +$.fn.tooltip.Constructor.prototype.show = function () { + // Overwrite bootstrap tooltip method to prevent showing 2 tooltip at the + // same time + $('.tooltip').remove(); + + return bootstrapShowFunction.call(this); +}; + +/* Bootstrap scrollspy fix for non-body to spy */ + +const bootstrapSpyRefreshFunction = $.fn.scrollspy.Constructor.prototype.refresh; +$.fn.scrollspy.Constructor.prototype.refresh = function () { + bootstrapSpyRefreshFunction.apply(this, arguments); + if (this._scrollElement === window || this._config.method !== 'offset') { + return; + } + const baseScrollTop = this._getScrollTop(); + for (let i = 0; i < this._offsets.length; i++) { + this._offsets[i] += baseScrollTop; + } +}; + +/** + * In some cases, we need to keep the first element of navbars selected. + */ +const bootstrapSpyProcessFunction = $.fn.scrollspy.Constructor.prototype._process; +$.fn.scrollspy.Constructor.prototype._process = function () { + bootstrapSpyProcessFunction.apply(this, arguments); + if (this._activeTarget === null && this._config.alwaysKeepFirstActive) { + this._activate(this._targets[0]); + } +}; + +/* Bootstrap modal scrollbar compensation on non-body */ +const bsSetScrollbarFunction = $.fn.modal.Constructor.prototype._setScrollbar; +$.fn.modal.Constructor.prototype._setScrollbar = function () { + const $scrollable = $().getScrollingElement(); + if (document.body.contains($scrollable[0])) { + $scrollable.compensateScrollbar(true); + } + return bsSetScrollbarFunction.apply(this, arguments); +}; +const bsResetScrollbarFunction = $.fn.modal.Constructor.prototype._resetScrollbar; +$.fn.modal.Constructor.prototype._resetScrollbar = function () { + const $scrollable = $().getScrollingElement(); + if (document.body.contains($scrollable[0])) { + $scrollable.compensateScrollbar(false); + } + return bsResetScrollbarFunction.apply(this, arguments); +}; + +return { + makeExtendedSanitizeWhiteList: makeExtendedSanitizeWhiteList, +}; +}); diff --git a/addons/web/static/src/js/libs/content-disposition.js b/addons/web/static/src/js/libs/content-disposition.js new file mode 100644 index 00000000..d229bd4c --- /dev/null +++ b/addons/web/static/src/js/libs/content-disposition.js @@ -0,0 +1,249 @@ +/* +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Stripped down to only parsing/decoding. + */ +odoo.define('web.contentdisposition', function () { +'use strict'; + +/** + * RegExp to match percent encoding escape. + * @private + */ +var HEX_ESCAPE_REPLACE_REGEXP = /%([0-9A-Fa-f]{2})/g; + +/** + * RegExp to match non-latin1 characters. + * @private + */ +var NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g; + +/** + * RegExp to match quoted-pair in RFC 2616 + * + * quoted-pair = "\" CHAR + * CHAR = <any US-ASCII character (octets 0 - 127)> + * @private + */ +var QESC_REGEXP = /\\([\u0000-\u007f])/g; + +/** + * RegExp for various RFC 2616 grammar + * + * parameter = token "=" ( token | quoted-string ) + * token = 1*<any CHAR except CTLs or separators> + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + * qdtext = <any TEXT except <">> + * quoted-pair = "\" CHAR + * CHAR = <any US-ASCII character (octets 0 - 127)> + * TEXT = <any OCTET except CTLs, but including LWS> + * LWS = [CRLF] 1*( SP | HT ) + * CRLF = CR LF + * CR = <US-ASCII CR, carriage return (13)> + * LF = <US-ASCII LF, linefeed (10)> + * SP = <US-ASCII SP, space (32)> + * HT = <US-ASCII HT, horizontal-tab (9)> + * CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)> + * OCTET = <any 8-bit sequence of data> + * @private + */ +var PARAM_REGEXP = /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g; + +/** + * RegExp for various RFC 5987 grammar + * + * ext-value = charset "'" [ language ] "'" value-chars + * charset = "UTF-8" / "ISO-8859-1" / mime-charset + * mime-charset = 1*mime-charsetc + * mime-charsetc = ALPHA / DIGIT + * / "!" / "#" / "$" / "%" / "&" + * / "+" / "-" / "^" / "_" / "`" + * / "{" / "}" / "~" + * language = ( 2*3ALPHA [ extlang ] ) + * / 4ALPHA + * / 5*8ALPHA + * extlang = *3( "-" 3ALPHA ) + * value-chars = *( pct-encoded / attr-char ) + * pct-encoded = "%" HEXDIG HEXDIG + * attr-char = ALPHA / DIGIT + * / "!" / "#" / "$" / "&" / "+" / "-" / "." + * / "^" / "_" / "`" / "|" / "~" + * @private + */ +var EXT_VALUE_REGEXP = /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/; + +/** + * RegExp for various RFC 6266 grammar + * + * disposition-type = "inline" | "attachment" | disp-ext-type + * disp-ext-type = token + * disposition-parm = filename-parm | disp-ext-parm + * filename-parm = "filename" "=" value + * | "filename*" "=" ext-value + * disp-ext-parm = token "=" value + * | ext-token "=" ext-value + * ext-token = <the characters in token, followed by "*"> + * @private + */ +var DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/; + +/** + * Decode a RFC 6987 field value (gracefully). + * + * @param {string} str + * @return {string} + * @private + */ +function decodefield(str) { + var match = EXT_VALUE_REGEXP.exec(str); + + if (!match) { + throw new TypeError('invalid extended field value') + } + + var charset = match[1].toLowerCase(); + var encoded = match[2]; + + switch (charset) { + case 'iso-8859-1': + return encoded.replace(HEX_ESCAPE_REPLACE_REGEXP, pdecode).replace(NON_LATIN1_REGEXP, '?'); + case 'utf-8': + return decodeURIComponent(encoded); + default: + throw new TypeError('unsupported charset in extended field') + } +} + +/** + * Parse Content-Disposition header string. + * + * @param {string} string + * @return {ContentDisposition} + * @public + */ +function parse(string) { + if (!string || typeof string !== 'string') { + throw new TypeError('argument string is required') + } + + var match = DISPOSITION_TYPE_REGEXP.exec(string); + + if (!match) { + throw new TypeError('invalid type format') + } + + // normalize type + var index = match[0].length; + var type = match[1].toLowerCase(); + + var key; + var names = []; + var params = {}; + var value; + + // calculate index to start at + index = PARAM_REGEXP.lastIndex = match[0].substr(-1) === ';' ? index - 1 : index; + + // match parameters + while ((match = PARAM_REGEXP.exec(string))) { + if (match.index !== index) { + throw new TypeError('invalid parameter format') + } + + index += match[0].length; + key = match[1].toLowerCase(); + value = match[2]; + + if (names.indexOf(key) !== -1) { + throw new TypeError('invalid duplicate parameter') + } + + names.push(key); + + if (key.indexOf('*') + 1 === key.length) { + // decode extended value + key = key.slice(0, -1); + value = decodefield(value); + + // overwrite existing value + params[key] = value; + continue + } + + if (typeof params[key] === 'string') { + continue + } + + if (value[0] === '"') { + // remove quotes and escapes + value = value + .substr(1, value.length - 2) + .replace(QESC_REGEXP, '$1') + } + + params[key] = value + } + + if (index !== -1 && index !== string.length) { + throw new TypeError('invalid parameter format') + } + + return new ContentDisposition(type, params) +} + +/** + * Percent decode a single character. + * + * @param {string} str + * @param {string} hex + * @return {string} + * @private + */ +function pdecode(str, hex) { + return String.fromCharCode(parseInt(hex, 16)) +} + +/** + * Class for parsed Content-Disposition header for v8 optimization + * + * @public + * @param {string} type + * @param {object} parameters + * @constructor + */ +function ContentDisposition(type, parameters) { + this.type = type; + this.parameters = parameters +} + +return { + parse: parse, +}; +}); diff --git a/addons/web/static/src/js/libs/daterangepicker.js b/addons/web/static/src/js/libs/daterangepicker.js new file mode 100644 index 00000000..dd731eb4 --- /dev/null +++ b/addons/web/static/src/js/libs/daterangepicker.js @@ -0,0 +1,24 @@ +odoo.define('web.daterangepicker.extensions', function () { +'use strict'; + +/** + * Don't allow user to select off days(Dates which are out of current calendar). + */ +var clickDateFunction = daterangepicker.prototype.clickDate; +daterangepicker.prototype.clickDate = function (ev) { + if (!$(ev.target).hasClass('off')) { + clickDateFunction.apply(this, arguments); + } +}; + +/** + * Override to open up or down based on top/bottom space in window. + */ +const moveFunction = daterangepicker.prototype.move; +daterangepicker.prototype.move = function () { + const offset = this.element.offset(); + this.drops = this.container.height() < offset.top ? 'up' : 'down'; + moveFunction.apply(this, arguments); +}; + +}); diff --git a/addons/web/static/src/js/libs/download.js b/addons/web/static/src/js/libs/download.js new file mode 100644 index 00000000..97a015f0 --- /dev/null +++ b/addons/web/static/src/js/libs/download.js @@ -0,0 +1,153 @@ +/* +MIT License + +Copyright (c) 2016 dandavis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +/** + * download.js v4.2, by dandavis; 2008-2018. [MIT] see http://danml.com/download.html for tests/usage + * + * @param {Blob | File | String} data + * @param {String} [filename] + * @param {String} [mimetype] + */ +odoo.define('web.download', function () { +return function download(data, filename, mimetype) { + var self = window, // this script is only for browsers anyway... + defaultMime = "application/octet-stream", // this default mime also triggers iframe downloads + mimeType = mimetype || defaultMime, payload = data, + url = !filename && !mimetype && payload, + anchor = document.createElement("a"), + toString = function (a) {return String(a);}, + myBlob = (self.Blob || self.MozBlob || self.WebKitBlob || toString), + fileName = filename || "download", blob, reader; + myBlob = myBlob.call ? myBlob.bind(self) : Blob; + + if (String(this) === "true") { //reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback + payload = [payload, mimeType]; + mimeType = payload[0]; + payload = payload[1]; + } + + if (url && url.length < 2048) { // if no filename and no mime, assume a url was passed as the only argument + fileName = url.split("/").pop().split("?")[0]; + anchor.href = url; // assign href prop to temp anchor + if (anchor.href.indexOf(url) !== -1) { // if the browser determines that it's a potentially valid url path: + var ajax = new XMLHttpRequest(); + ajax.open("GET", url, true); + ajax.responseType = 'blob'; + ajax.onload = function (e) { + download(e.target.response, fileName, defaultMime); + }; + setTimeout(function () { ajax.send();}, 0); // allows setting custom ajax headers using the return: + return ajax; + } + } + + //go ahead and download dataURLs right away + if (/^data:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)) { + + if (payload.length > (1024 * 1024 * 1.999) && myBlob !== toString) { + payload = dataUrlToBlob(payload); + mimeType = payload.type || defaultMime; + } else { + return navigator.msSaveBlob ? // IE10 can't do a[download], only Blobs: + navigator.msSaveBlob(dataUrlToBlob(payload), fileName) : saver(payload); // everyone else can save dataURLs un-processed + } + + } + + blob = payload instanceof myBlob ? payload : new myBlob([payload], {type: mimeType}); + + + function dataUrlToBlob(strUrl) { + var parts = strUrl.split(/[:;,]/), type = parts[1], + decoder = parts[2] === "base64" ? atob : decodeURIComponent, + binData = decoder(parts.pop()), mx = binData.length, + i = 0, uiArr = new Uint8Array(mx); + + for (i; i < mx; ++i) uiArr[i] = binData.charCodeAt(i); + + return new myBlob([uiArr], {type: type}); + } + + function saver(url, winMode) { + if ('download' in anchor) { //html5 A[download] + anchor.href = url; + anchor.setAttribute("download", fileName); + anchor.className = "download-js-link"; + anchor.innerHTML = "downloading..."; + anchor.style.display = "none"; + document.body.appendChild(anchor); + setTimeout(function () { + anchor.click(); + document.body.removeChild(anchor); + if (winMode === true) {setTimeout(function () { self.URL.revokeObjectURL(anchor.href);}, 250);} + }, 66); + return true; + } + + // handle non-a[download] safari as best we can: + if (/(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent)) { + url = url.replace(/^data:([\w\/\-+]+)/, defaultMime); + if (!window.open(url)) { // popup blocked, offer direct download: + if (confirm("Displaying New Document\n\nUse Save As... to download, then click back to return to this page.")) { location.href = url; } + } + return true; + } + + //do iframe dataURL download (old ch+FF): + var f = document.createElement("iframe"); + document.body.appendChild(f); + + if (!winMode) { // force a mime that will download: + url = "data:" + url.replace(/^data:([\w\/\-+]+)/, defaultMime); + } + f.src = url; + setTimeout(function () { document.body.removeChild(f); }, 333); + } + + if (navigator.msSaveBlob) { // IE10+ : (has Blob, but not a[download] or URL) + return navigator.msSaveBlob(blob, fileName); + } + + if (self.URL) { // simple fast and modern way using Blob and URL: + saver(self.URL.createObjectURL(blob), true); + } else { + // handle non-Blob()+non-URL browsers: + if (typeof blob === "string" || blob.constructor === toString) { + try { + return saver("data:" + mimeType + ";base64," + self.btoa(blob)); + } catch (y) { + return saver("data:" + mimeType + "," + encodeURIComponent(blob)); + } + } + + // Blob but not URL support: + reader = new FileReader(); + reader.onload = function () { + saver(this.result); + }; + reader.readAsDataURL(blob); + } + return true; +}; +}); diff --git a/addons/web/static/src/js/libs/fullcalendar.js b/addons/web/static/src/js/libs/fullcalendar.js new file mode 100644 index 00000000..7f9714e8 --- /dev/null +++ b/addons/web/static/src/js/libs/fullcalendar.js @@ -0,0 +1,252 @@ +odoo.define('/web/static/src/js/libs/fullcalendar.js', function () { + "use strict"; + + function createYearCalendarView(FullCalendar) { + const { + Calendar, + createElement, + EventApi, + memoizeRendering, + View, + } = FullCalendar; + + class YearView extends View { + constructor() { + super(...arguments); + this.months = null; + this.renderSubCalendarsMem = memoizeRendering( + this.renderSubCalendars, this.unrenderSubCalendars); + this.events = []; + } + + //---------------------------------------------------------------------- + // Getters + //---------------------------------------------------------------------- + + get currentDate() { + return this.context.calendar.state.currentDate; + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + */ + destroy() { + this.renderSubCalendarsMem.unrender(); + super.destroy(); + } + /** + * Removes the selection on sub calendar. + * Selections on sub calendars are not propagated to this view so + * this view cannot manage them. + */ + unselect() { + for (const { calendar } of this.months) { + calendar.unselect(); + } + } + /** + * @override + */ + render() { + this.renderSubCalendarsMem(this.context); + super.render(...arguments); + } + /** + * Renders the main layout (the 4x3 month grid) + */ + renderSubCalendars() { + this.el.classList.add('fc-scroller'); + if (!this.context.options.selectable) { + this.el.classList.add('fc-readonly-year-view'); + } + this.months = []; + for (let monthNumber = 0; monthNumber < 12; monthNumber++) { + const monthDate = new Date(this.currentDate.getFullYear(), monthNumber); + const monthShortName = moment(monthDate).format('MMM').toLowerCase(); + const container = createElement('div', { class: 'fc-month-container' }); + this.el.appendChild(container); + const el = createElement('div', { + class: `fc-month fc-month-${monthShortName}`, + }); + container.appendChild(el); + const calendar = this._createMonthCalendar(el, monthDate); + this.months.push({ el, calendar }); + calendar.render(); + } + } + /** + * Removes the main layout (the 4x3 month grid). + * Called when view is switched/destroyed. + */ + unrenderSubCalendars() { + for (const { el, calendar } of this.months) { + calendar.destroy(); + el.remove(); + } + } + /** + * Renders events in sub calendars. + * Called every time event source changed (when changing the date, + * when changing filters, adding/removing filters). + */ + renderEvents() { + // `renderDates` also renders events so if it's called just before + // then do not execute this as it will do a re-render. + if (this.datesRendered) { + this.datesRendered = false; + return; + } + this.events = this._computeEvents(); + for (const { calendar } of this.months) { + calendar.refetchEvents(); + } + this._setCursorOnEventDates(); + } + /** + * Renders dates and events in sub calendars. + * Called when the year of the date changed to render a new + * 4*3 grid of month calendar based on the new year. + */ + renderDates() { + this.events = this._computeEvents(); + for (const [monthNumber, { calendar }] of Object.entries(this.months)) { + const monthDate = new Date(this.currentDate.getFullYear(), monthNumber); + calendar.gotoDate(monthDate); + } + this._setCursorOnEventDates(); + this.datesRendered = true; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + */ + _computeEvents() { + const calendar = this.context.calendar; + return calendar.getEvents().map(event => { + const endUTC = calendar.dateEnv.toDate(event._instance.range.end); + const end = new Date(event._instance.range.end); + if (endUTC.getHours() > 0 || endUTC.getMinutes() > 0 || + endUTC.getSeconds() > 0 || endUTC.getMilliseconds() > 0) { + end.setDate(end.getDate() + 1); + } + // clone event data to not trigger rerendering and issues + const instance = Object.assign({}, event._instance, { + range: { start: new Date(event._instance.range.start), end }, + }); + const def = Object.assign({}, event._def, { + rendering: 'background', + allDay: true, + }); + return new EventApi(this.context.calendar, def, instance); + }); + } + /** + * Create a month calendar for the date `monthDate` and mount it on container. + * + * @private + * @param {HTMLElement} container + * @param {Date} monthDate + */ + _createMonthCalendar(container, monthDate) { + return new Calendar(container, Object.assign({}, this.context.options, { + defaultDate: monthDate, + defaultView: 'dayGridMonth', + header: { left: false, center: 'title', right: false }, + titleFormat: { month: 'short', year: 'numeric' }, + height: 0, + contentHeight: 0, + weekNumbers: false, + showNonCurrentDates: false, + views: { + dayGridMonth: { + columnHeaderText: (date) => moment(date).format("ddd")[0], + }, + }, + selectMinDistance: 5, // needed to not trigger select when click + dateClick: this._onYearDateClick.bind(this), + datesRender: undefined, + events: (info, successCB) => { + successCB(this.events); + }, + windowResize: undefined, + })); + } + /** + * Sets fc-has-event class on every dates that have at least one event. + * + * @private + */ + _setCursorOnEventDates() { + for (const el of this.el.querySelectorAll('.fc-has-event')) { + el.classList.remove('fc-has-event'); + } + for (const event of Object.values(this.events)) { + let currentDate = moment(event._instance.range.start); + while (currentDate.isBefore(event._instance.range.end, 'day')) { + const formattedDate = currentDate.format('YYYY-MM-DD'); + const el = this.el.querySelector(`.fc-day-top[data-date="${formattedDate}"]`); + if (el) { + el.classList.add('fc-has-event'); + } + currentDate.add(1, 'days'); + } + } + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + * @param {*} info + */ + _onYearDateClick(info) { + const calendar = this.context.calendar; + const events = Object.values(this.events) + .filter(event => { + const startUTC = calendar.dateEnv.toDate(event._instance.range.start); + const endUTC = calendar.dateEnv.toDate(event._instance.range.end); + const start = moment(startUTC); + const end = moment(endUTC); + const inclusivity = start.isSame(end, 'day') ? '[]' : '[)'; + return moment(info.date).isBetween(start, end, 'day', inclusivity); + }) + .map(event => { + return Object.assign({}, event._def, event._instance.range); + }); + const yearDateInfo = Object.assign({}, info, { + view: this, + monthView: info.view, + events, + selectable: this.context.options.selectable, + }); + calendar.publiclyTrigger('yearDateClick', [yearDateInfo]); + } + } + + return FullCalendar.createPlugin({ + views: { + dayGridYear: { + class: YearView, + duration: { years: 1 }, + defaults: { + fixedWeekCount: true, + }, + }, + }, + }); + } + + return { + createYearCalendarView, + }; +}); diff --git a/addons/web/static/src/js/libs/jquery.js b/addons/web/static/src/js/libs/jquery.js new file mode 100644 index 00000000..749bcd80 --- /dev/null +++ b/addons/web/static/src/js/libs/jquery.js @@ -0,0 +1,235 @@ +odoo.define('web.jquery.extensions', function () { +'use strict'; + +/** + * The jquery library extensions and fixes should be done here to avoid patching + * in place. + */ + +// jQuery selectors extensions +$.extend($.expr[':'], { + containsLike: function (element, index, matches){ + return element.innerHTML.toUpperCase().indexOf(matches[3].toUpperCase()) >= 0; + }, + containsTextLike: function (element, index, matches){ + return element.innerText.toUpperCase().indexOf(matches[3].toUpperCase()) >= 0; + }, + containsExact: function (element, index, matches){ + return $.trim(element.innerHTML) === matches[3]; + }, + containsExactText: function (element, index, matches) { + return element.innerText.trim() === matches[3].trim(); + }, + /** + * Note all escaped characters need to be double escaped inside of the + * expression, so "\(" needs to be "\\(" + */ + containsRegex: function (element, index, matches){ + var regreg = /^\/((?:\\\/|[^\/])+)\/([mig]{0,3})$/, + reg = regreg.exec(matches[3]); + return reg ? new RegExp(reg[1], reg[2]).test($.trim(element.innerHTML)) : false; + }, + propChecked: function (element, index, matches) { + return $(element).prop("checked") === true; + }, + propSelected: function (element, index, matches) { + return $(element).prop("selected") === true; + }, + propValue: function (element, index, matches) { + return $(element).prop("value") === matches[3]; + }, + propValueContains: function (element, index, matches) { + return $(element).prop("value") && $(element).prop("value").indexOf(matches[3]) !== -1; + }, + hasData: function (element) { + return !!_.toArray(element.dataset).length; + }, + data: function (element, index, matches) { + return $(element).data(matches[3]); + }, + hasVisibility: function (element, index, matches) { + var $element = $(element); + if ($(element).css('visibility') === 'hidden') { + return false; + } + var $parent = $element.parent(); + if (!$parent.length || $element.is('html')) { + return true; + } + return $parent.is(':hasVisibility'); + }, + hasOpacity: function (element, index, matches) { + var $element = $(element); + if (parseFloat($(element).css('opacity')) <= 0.01) { + return false; + } + var $parent = $element.parent(); + if (!$parent.length || $element.is('html')) { + return true; + } + return $parent.is(':hasOpacity'); + }, +}); + +// jQuery functions extensions +$.fn.extend({ + /** + * Returns all the attributes of a DOM element (first one in the jQuery + * set). + * + * @returns {Object} attribute name -> attribute value + */ + getAttributes: function () { + var o = {}; + if (this.length) { + var attrs = this[0].attributes; + for (var i = 0, l = attrs.length ; i < l ; i++) { + var attr = attrs.item(i); + o[attr.name] = attr.value; + } + } + return o; + }, + /** + * Makes DOM elements bounce the way Odoo decided it. + * + * @param {string} [extraClass] + */ + odooBounce: function (extraClass) { + for (const el of this) { + el.classList.add('o_catch_attention', extraClass); + setTimeout(() => el.classList.remove('o_catch_attention', extraClass), 400); + } + return this; + }, + /** + * Allows to bind events to a handler just as the standard `$.on` function + * but binds the handler so that it is executed before any already-attached + * handler for the same events. + * + * @see jQuery.on + */ + prependEvent: function (events, selector, data, handler) { + this.on.apply(this, arguments); + + events = events.split(' '); + return this.each(function () { + var el = this; + _.each(events, function (evNameNamespaced) { + var evName = evNameNamespaced.split('.')[0]; + var handler = $._data(el, 'events')[evName].pop(); + $._data(el, 'events')[evName].unshift(handler); + }); + }); + }, + /** + * @return {jQuery} + */ + closestScrollable() { + let $el = this; + while ($el[0] !== document.scrollingElement) { + if ($el.isScrollable()) { + return $el; + } + $el = $el.parent(); + } + return $el; + }, + /** + * Adapt the given css property by adding the size of a scrollbar if any. + * Limitation: only works if the given css property is not already used as + * inline style for another reason. + * + * @param {boolean} [add=true] + * @param {boolean} [isScrollElement=true] + * @param {string} [cssProperty='padding-right'] + */ + compensateScrollbar(add = true, isScrollElement = true, cssProperty = 'padding-right') { + for (const el of this) { + // Compensate scrollbar + el.style.removeProperty(cssProperty); + if (!add) { + return; + } + const scrollableEl = isScrollElement ? el : $(el).parent().closestScrollable()[0]; + const style = window.getComputedStyle(el); + const newValue = parseInt(style[cssProperty]) + scrollableEl.offsetWidth - scrollableEl.clientWidth; + el.style.setProperty(cssProperty, `${newValue}px`, 'important'); + } + }, + /** + * @returns {jQuery} + */ + getScrollingElement() { + const $baseScrollingElement = $(document.scrollingElement); + if ($baseScrollingElement.isScrollable() + && $baseScrollingElement.hasScrollableContent()) { + return $baseScrollingElement; + } + const bodyHeight = $(document.body).height(); + for (const el of document.body.children) { + // Search for a body child which is at least as tall as the body + // and which has the ability to scroll if enough content in it. If + // found, suppose this is the top scrolling element. + if (bodyHeight - el.scrollHeight > 1.5) { + continue; + } + const $el = $(el); + if ($el.isScrollable()) { + return $el; + } + } + return $baseScrollingElement; + }, + /** + * @return {boolean} + */ + hasScrollableContent() { + return this[0].scrollHeight > this[0].clientHeight; + }, + /** + * @returns {boolean} + */ + isScrollable() { + const overflow = this.css('overflow-y'); + return overflow === 'auto' || overflow === 'scroll' + || (overflow === 'visible' && this === document.scrollingElement); + }, +}); + +// jQuery functions monkey-patching + +// Some magic to ensure scrolltop and animate on html/body animate the top level +// scrollable element even if not html or body. +const originalScrollTop = $.fn.scrollTop; +$.fn.scrollTop = function (value) { + if (value !== undefined && this.filter('html, body').length) { + // The caller wants to scroll a set of elements including html and/or + // body to a specific point -> do that but make sure to add the real + // top level element to that set of elements if any different is found. + originalScrollTop.apply(this.not('html, body').add($().getScrollingElement()), arguments); + return this; + } else if (value === undefined && this.eq(0).is('html, body')) { + // The caller wants to get the scroll point of a set of elements, jQuery + // will return the scroll point of the first one, if it is html or body + // return the scroll point of the real top level element. + return originalScrollTop.apply($().getScrollingElement(), arguments); + } + return originalScrollTop.apply(this, arguments); +}; +const originalAnimate = $.fn.animate; +$.fn.animate = function (properties, ...rest) { + const props = Object.assign({}, properties); + if ('scrollTop' in props && this.filter('html, body').length) { + // The caller wants to scroll a set of elements including html and/or + // body to a specific point -> do that but make sure to add the real + // top level element to that set of elements if any different is found. + originalAnimate.call(this.not('html, body').add($().getScrollingElement()), {'scrollTop': props['scrollTop']}, ...rest); + delete props['scrollTop']; + } + if (!Object.keys(props).length) { + return this; + } + return originalAnimate.call(this, props, ...rest); +}; +}); diff --git a/addons/web/static/src/js/libs/pdfjs.js b/addons/web/static/src/js/libs/pdfjs.js new file mode 100644 index 00000000..92c1e560 --- /dev/null +++ b/addons/web/static/src/js/libs/pdfjs.js @@ -0,0 +1,20 @@ +/* +* There is no changes to pdf.js in this file, but only a note about a change that has been done in it. +* +* In the module account_invoice_extract, the the code need to react to the 'pagerendered' event triggered by +* pdf.js. However in recent version of pdf.js, event are not visible outside of the library, except if the +* 'eventBusDispatchToDOM' has been set to true. +* +* We tried to set this option from outside of the library but without success, as our pdf viewer is in an iframe. +* There is no state of the iframe in which we can add an event listener to set the option. +* pdf.js has an event used to signal when we can set settings, called 'webviewerloaded'. +* This event is triggered in an EventListener attached to the 'DOMContentLoaded' event. +* So, to list options we had, we could: +* a) add an eventListener to the iframe document or window to react to 'webviewerloaded'. This doesn't work as +* document and windows are not the definitive ones and won't catche the event later. +* b) add an eventListener to the iframe to react to 'DOMContentLoaded', which doens't work too as our listener will be called +* after the pdf.js one. +* +* Finally the option was choosed to modify the default value of this option directly in pdf.js as no hook worked in the +* 'account_invoice_extract' module. +*/ diff --git a/addons/web/static/src/js/libs/popper.js b/addons/web/static/src/js/libs/popper.js new file mode 100644 index 00000000..e94cf40c --- /dev/null +++ b/addons/web/static/src/js/libs/popper.js @@ -0,0 +1,2 @@ +/** @odoo-module **/ +Popper.Defaults.modifiers.preventOverflow.priority = ['right', 'left', 'bottom', 'top']; diff --git a/addons/web/static/src/js/libs/underscore.js b/addons/web/static/src/js/libs/underscore.js new file mode 100644 index 00000000..083ee22f --- /dev/null +++ b/addons/web/static/src/js/libs/underscore.js @@ -0,0 +1,52 @@ +/** + * The _throttle in underscore has the feature to cancel the throttled function + * only starting version 1.9.0 + * @todo remove this in master and update underscorejs to 1.9.1 + */ + +// Returns a function, that, when invoked, will only be triggered at most once +// during a given window of time. Normally, the throttled function will run +// as much as it can, without ever going more than once per `wait` duration; +// but if you'd like to disable the execution on the leading edge, pass +// `{leading: false}`. To disable execution on the trailing edge, ditto. + +_.cancellableThrottleRemoveMeSoon = function (func, wait, options) { + var timeout, context, args, result; + var previous = 0; + if (!options) options = {}; + + var later = function () { + previous = options.leading === false ? 0 : _.now(); + timeout = null; + result = func.apply(context, args); + if (!timeout) context = args = null; + }; + + var throttled = function () { + var now = _.now(); + if (!previous && options.leading === false) previous = now; + var remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + result = func.apply(context, args); + if (!timeout) context = args = null; + } else if (!timeout && options.trailing !== false) { + timeout = setTimeout(later, remaining); + } + return result; + }; + + throttled.cancel = function () { + clearTimeout(timeout); + previous = 0; + timeout = context = args = null; + }; + + return throttled; +}; diff --git a/addons/web/static/src/js/libs/zoomodoo.js b/addons/web/static/src/js/libs/zoomodoo.js new file mode 100644 index 00000000..07da384c --- /dev/null +++ b/addons/web/static/src/js/libs/zoomodoo.js @@ -0,0 +1,353 @@ +odoo.define('web.zoomodoo', function (require) { +'use strict'; + +/** + This code has been more that widely inspired by easyZoom library. + + Copyright 2013 Matt Hinchliffe + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. +**/ + +var dw, dh, rw, rh, lx, ly; + +var defaults = { + + // Attribute to retrieve the zoom image URL from. + linkTag: 'a', + linkAttribute: 'data-zoom-image', + + // event to trigger zoom + event: 'click', //or mouseenter + + // Timer before trigger zoom + timer: 0, + + // Prevent clicks on the zoom image link. + preventClicks: true, + + // disable on mobile + disabledOnMobile: true, + + // Callback function to execute before the flyout is displayed. + beforeShow: $.noop, + + // Callback function to execute before the flyout is removed. + beforeHide: $.noop, + + // Callback function to execute when the flyout is displayed. + onShow: $.noop, + + // Callback function to execute when the flyout is removed. + onHide: $.noop, + + // Callback function to execute when the cursor is moved while over the image. + onMove: $.noop, + + // Callback function to execute when the flyout is attached to the target. + beforeAttach: $.noop + +}; + +/** + * ZoomOdoo + * @constructor + * @param {Object} target + * @param {Object} options (Optional) + */ +function ZoomOdoo(target, options) { + this.$target = $(target); + this.opts = $.extend({}, defaults, options, this.$target.data()); + + if (this.isOpen === undefined) { + this._init(); + } +} + +/** + * Init + * @private + */ +ZoomOdoo.prototype._init = function () { + if (window.outerWidth > 467 || !this.opts.disabledOnMobile) { + this.$link = this.$target.find(this.opts.linkTag).length && this.$target.find(this.opts.linkTag) || this.$target; + this.$image = this.$target.find('img').length && this.$target.find('img') || this.$target; + this.$flyout = $('<div class="zoomodoo-flyout" />'); + + var $attach = this.$target; + if (this.opts.attach !== undefined && this.$target.closest(this.opts.attach).length) { + $attach = this.$target.closest(this.opts.attach); + } + $attach.parent().on('mousemove.zoomodoo touchmove.zoomodoo', $.proxy(this._onMove, this)); + $attach.parent().on('mouseleave.zoomodoo touchend.zoomodoo', $.proxy(this._onLeave, this)); + this.$target.on(this.opts.event + '.zoomodoo touchstart.zoomodoo', $.proxy(this._onEnter, this)); + + if (this.opts.preventClicks) { + this.$target.on('click.zoomodoo', function (e) { e.preventDefault(); }); + } else { + var self = this; + this.$target.on('click.zoomodoo', function () { self.hide(); self.$target.unbind(); }); + } + } +}; + +/** + * Show + * @param {MouseEvent|TouchEvent} e + * @param {Boolean} testMouseOver (Optional) + */ +ZoomOdoo.prototype.show = function (e, testMouseOver) { + var w1, h1, w2, h2; + var self = this; + + if (this.opts.beforeShow.call(this) === false) return; + + if (!this.isReady) { + return this._loadImage(this.$link.attr(this.opts.linkAttribute), function () { + if (self.isMouseOver || !testMouseOver) { + self.show(e); + } + }); + } + + var $attach = this.$target; + if (this.opts.attach !== undefined && this.$target.closest(this.opts.attach).length) { + $attach = this.$target.closest(this.opts.attach); + } + + // Prevents having multiple zoom flyouts + $attach.parent().find('.zoomodoo-flyout').remove(); + this.$flyout.removeAttr('style'); + $attach.parent().append(this.$flyout); + + if (this.opts.attachToTarget) { + this.opts.beforeAttach.call(this); + + // Be sure that the flyout is at top 0, left 0 to ensure correct computation + // e.g. employees kanban on dashboard + this.$flyout.css('position', 'fixed'); + var flyoutOffset = this.$flyout.offset(); + if (flyoutOffset.left > 0) { + var flyoutLeft = parseFloat(this.$flyout.css('left').replace('px','')); + this.$flyout.css('left', flyoutLeft - flyoutOffset.left + 'px'); + } + if (flyoutOffset.top > 0) { + var flyoutTop = parseFloat(this.$flyout.css('top').replace('px','')); + this.$flyout.css('top', flyoutTop - flyoutOffset.top + 'px'); + } + + if(this.$zoom.height() < this.$flyout.height()) { + this.$flyout.css('height', this.$zoom.height() + 'px'); + } + if(this.$zoom.width() < this.$flyout.width()) { + this.$flyout.css('width', this.$zoom.width() + 'px'); + } + + var offset = this.$target.offset(); + var left = offset.left - this.$flyout.width(); + var top = offset.top; + + // Position the zoom on the right side of the target + // if there's not enough room on the left + if(left < 0) { + if(offset.left < ($(document).width() / 2)) { + left = offset.left + this.$target.width(); + } else { + left = 0; + } + } + + // Prevents the flyout to overflow + if(left + this.$flyout.width() > $(document).width()) { + this.$flyout.css('width', $(document).width() - left + 'px'); + } else if(left === 0) { // Limit the max width if displayed on the left + this.$flyout.css('width', offset.left + 'px'); + } + + // Prevents the zoom to be displayed outside the current viewport + if((top + this.$flyout.height()) > $(document).height()) { + top = $(document).height() - this.$flyout.height(); + } + + this.$flyout.css('transform', 'translate3d(' + left + 'px, ' + top + 'px, 0px)'); + } else { + // Computing flyout max-width depending to the available space on the right to avoid overflow-x issues + // e.g. width too high so a right zoomed element is not visible (need to scroll on x axis) + var rightAvailableSpace = document.body.clientWidth - this.$flyout[0].getBoundingClientRect().left; + this.$flyout.css('max-width', rightAvailableSpace); + } + + w1 = this.$target[0].offsetWidth; + h1 = this.$target[0].offsetHeight; + + w2 = this.$flyout.width(); + h2 = this.$flyout.height(); + + dw = this.$zoom.width() - w2; + dh = this.$zoom.height() - h2; + + // For the case where the zoom image is actually smaller than + // the flyout. + if (dw < 0) dw = 0; + if (dh < 0) dh = 0; + + rw = dw / w1; + rh = dh / h1; + + this.isOpen = true; + + this.opts.onShow.call(this); + + if (e) { + this._move(e); + } +}; + +/** + * On enter + * @private + * @param {Event} e + */ +ZoomOdoo.prototype._onEnter = function (e) { + var self = this; + var touches = e.originalEvent.touches; + e.preventDefault(); + this.isMouseOver = true; + + setTimeout(function () { + if (self.isMouseOver && (!touches || touches.length === 1)) { + self.show(e, true); + } + }, this.opts.timer); + +}; + +/** + * On move + * @private + * @param {Event} e + */ +ZoomOdoo.prototype._onMove = function (e) { + if (!this.isOpen) return; + + e.preventDefault(); + this._move(e); +}; + +/** + * On leave + * @private + */ +ZoomOdoo.prototype._onLeave = function () { + this.isMouseOver = false; + if (this.isOpen) { + this.hide(); + } +}; + +/** + * On load + * @private + * @param {Event} e + */ +ZoomOdoo.prototype._onLoad = function (e) { + // IE may fire a load event even on error so test the image dimensions + if (!e.currentTarget.width) return; + + this.isReady = true; + + this.$flyout.html(this.$zoom); + + if (e.data.call) { + e.data(); + } +}; + +/** + * Load image + * @private + * @param {String} href + * @param {Function} callback + */ +ZoomOdoo.prototype._loadImage = function (href, callback) { + var zoom = new Image(); + + this.$zoom = $(zoom).on('load', callback, $.proxy(this._onLoad, this)); + + zoom.style.position = 'absolute'; + zoom.src = href; +}; + +/** + * Move + * @private + * @param {Event} e + */ +ZoomOdoo.prototype._move = function (e) { + if (e.type.indexOf('touch') === 0) { + var touchlist = e.touches || e.originalEvent.touches; + lx = touchlist[0].pageX; + ly = touchlist[0].pageY; + } else { + lx = e.pageX || lx; + ly = e.pageY || ly; + } + + var offset = this.$target.offset(); + var pt = ly - offset.top; + var pl = lx - offset.left; + var xt = Math.ceil(pt * rh); + var xl = Math.ceil(pl * rw); + + // Close if outside + if (!this.opts.attachToTarget && (xl < 0 || xt < 0 || xl > dw || xt > dh || lx > (offset.left + this.$target.outerWidth()))) { + this.hide(); + } else { + var top = xt * -1; + var left = xl * -1; + + this.$zoom.css({ + top: top, + left: left + }); + + this.opts.onMove.call(this, top, left); + } + +}; + +/** + * Hide + */ +ZoomOdoo.prototype.hide = function () { + if (!this.isOpen) return; + if (this.opts.beforeHide.call(this) === false) return; + + this.$flyout.detach(); + this.isOpen = false; + + this.opts.onHide.call(this); +}; + +// jQuery plugin wrapper +$.fn.zoomOdoo = function (options) { + return this.each(function () { + var api = $.data(this, 'zoomOdoo'); + + if (!api) { + $.data(this, 'zoomOdoo', new ZoomOdoo(this, options)); + } else if (api.isOpen === undefined) { + api._init(); + } + }); +}; +}); diff --git a/addons/web/static/src/js/main.js b/addons/web/static/src/js/main.js new file mode 100644 index 00000000..465ea0d6 --- /dev/null +++ b/addons/web/static/src/js/main.js @@ -0,0 +1,29 @@ +odoo.define('web.web_client', function (require) { + "use strict"; + + const AbstractService = require('web.AbstractService'); + const env = require('web.env'); + const session = require("web.session"); + const WebClient = require('web.WebClient'); + + // configure the env and set it on Owl Component + owl.config.mode = env.isDebug() ? "dev" : "prod"; + owl.Component.env = env; + + // deploy services in the env + AbstractService.prototype.deployServices(env); + + // add the owl templates to the environment and start the web client + const webClient = new WebClient(); + async function startWebClient() { + await session.is_bound; + env.qweb.addTemplates(session.owlTemplates); + + await owl.utils.whenReady(); + webClient.setElement($(document.body)); + webClient.start(); + } + startWebClient(); + + return webClient; +}); diff --git a/addons/web/static/src/js/model.js b/addons/web/static/src/js/model.js new file mode 100644 index 00000000..51959450 --- /dev/null +++ b/addons/web/static/src/js/model.js @@ -0,0 +1,505 @@ +odoo.define("web/static/src/js/model.js", function (require) { + "use strict"; + + const { groupBy, partitionBy } = require("web.utils"); + const Registry = require("web.Registry"); + + const { Component, core } = owl; + const { EventBus, Observer } = core; + const isNotNull = (val) => val !== null && val !== undefined; + + /** + * Feature extension of the class Model. + * @see {Model} + */ + class ModelExtension { + /** + * @param {Object} config + * @param {Object} config.env + */ + constructor(config) { + this.config = config; + this.env = this.config.env; + this.shouldLoad = true; + this.state = {}; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Used by the parent model to initiate a load action. The actual + * loading of the extension is determined by the "shouldLoad" property. + * @param {Object} params + */ + async callLoad(params) { + if (this.shouldLoad) { + this.shouldLoad = false; + await this.load(params); + } + } + + /** + * @param {string} method + * @param {...any} args + */ + dispatch(method, ...args) { + if (method in this) { + this[method](...args); + } + } + + /** + * Exports the current state of the extension. + * @returns {Object} + */ + exportState() { + return this.state; + } + + /** + * Meant to return the result of the appropriate getter or do nothing + * if not concerned by the given property. + * @abstract + * @param {string} property + * @param {...any} args + * @returns {null} + */ + get() { + return null; + } + + /** + * Imports the given state after parsing it. If no state is given the + * extension will prepare a new state and will need to be loaded. + * @param {Object} [state] + */ + importState(state) { + this.shouldLoad = !state; + if (this.shouldLoad) { + this.prepareState(); + } else { + Object.assign(this.state, state); + } + } + + /** + * Called and awaited on initial model load. + * @abstract + * @param {Object} params + * @returns {Promise} + */ + async load(/* params */) { + /* ... */ + } + + /** + * Called on initialization if no imported state for the extension is + * found. + * @abstract + */ + prepareState() { + /* ... */ + } + } + /** + * The layer of an extension indicates with which other extensions this one + * will be loaded. This property must be overridden in case the model + * depends on other extensions to be loaded first. + */ + ModelExtension.layer = 0; + + /** + * Model + * + * The purpose of the class Model and the associated hook useModel + * is to offer something similar to an owl store but with no automatic + * notification (and rendering) of components when the 'state' used in the + * model would change. Instead, one should call the "__notifyComponents" + * function whenever it is useful to alert registered component. + * Nevertheless, when calling a method through the 'dispatch' method, a + * notification does take place automatically, and registered components + * (via useModel) are rendered. + * + * It is highly expected that this class will change in a near future. We + * don't have the necessary hindsight to be sure its actual form is good. + * + * The following snippets show a typical use case of the model system: a + * search model with a control panel extension feature. + * + *------------------------------------------------------------------------- + * MODEL AND EXTENSIONS DEFINITION + *------------------------------------------------------------------------- + * + * 1. Definition of the main model + * @see Model + * ``` + * class ActionModel extends Model { + * // ... + * } + * ``` + * + * 2. Definition of the model extension + * @see ModelExtension + * ``` + * class ControlPanelModelExtension extends ActionModel.Extension { + * // ... + * } + * ``` + * + * 3. Registration of the extension into the main model + * @see Registry() + * ``` + * ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10); + * ``` + * + *------------------------------------------------------------------------- + * ON VIEW/ACTION INIT + *------------------------------------------------------------------------- + * + * 4. Creation of the core model and its extensions + * @see Model.prototype.constructor() + * ``` + * const extensions = { + * SearchPanel: { + * // ... + * } + * } + * const searchModelConfig = { + * // ... + * }; + * const actionModel = new ActionModel(extensions, searchModelConfig); + * ``` + * + * 5. Loading of all extensions' asynchronous data + * @see Model.prototype.load() + * ``` + * await actionModel.load(); + * ``` + * + * 6. Subscribing to the model changes + * @see useModel() + * ``` + * class ControlPanel extends Component { + * constructor() { + * super(...arguments); + * // env must contain the actionModel + * this.actionModel = useModel('actionModel'); + * } + * } + * ``` + * + *------------------------------------------------------------------------- + * MODEL USAGE ON RUNTIME + *------------------------------------------------------------------------- + * + * Case: dispatch an action + * @see Model.prototype.dispatch() + * ``` + * actionModel.dispatch("updateProperty", value); + * ``` + * + * Case: call a getter + * @see Model.prototype.get() + * ``` + * const result = actionModel.get("property"); + * ``` + * + * @abstract + * @extends EventBus + */ + class Model extends EventBus { + /** + * Instantiated extensions are determined by the `extensions` argument: + * - keys are the extensions names as added in the registry + * - values are the local configurations given to each extension + * The extensions are grouped by the sequence number they where + * registered with in the registry. Extensions being on the same level + * will be loaded in parallel; this means that all extensions belonging + * to the same group are awaited before loading the next group. + * @param {Object<string, any>} [extensions={}] + * @param {Object} [globalConfig={}] global configuration: can be + * accessed by itself and each of the added extensions. + * @param {Object} [globalConfig.env] + * @param {string} [globalConfig.importedState] + */ + constructor(extensions = {}, globalConfig = {}) { + super(); + + this.config = globalConfig; + this.env = this.config.env; + + this.dispatching = false; + this.extensions = []; + this.externalState = {}; + this.mapping = {}; + this.rev = 1; + + const { name, registry } = this.constructor; + if (!registry || !(registry instanceof Registry)) { + throw new Error(`Unimplemented registry on model "${name}".`); + } + // Order, group and sequencially instantiate all extensions + const registryExtensions = Object.entries(registry.entries()); + const extensionNameLayers = registryExtensions.map( + ([name, { layer }]) => ({ name, layer }) + ); + const groupedNameLayers = groupBy(extensionNameLayers, "layer"); + for (const groupNameLayers of Object.values(groupedNameLayers)) { + for (const { name } of groupNameLayers) { + if (name in extensions) { + this.addExtension(name, extensions[name]); + } + } + } + this.importState(this.config.importedState); + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Method used internally to instantiate all extensions. Can also be + * called externally to add extensions after model instantiation. + * @param {string} extensionName + * @param {Object} extensionConfig + */ + addExtension(extensionName, extensionConfig) { + const { name, registry } = this.constructor; + const Extension = registry.get(extensionName); + if (!Extension) { + throw new Error(`Unknown model extension "${extensionName}" in model "${name}"`); + } + // Extension config = this.config ∪ extension.config + const get = this.__get.bind(this, Extension.name); + const trigger = this.trigger.bind(this); + const config = Object.assign({ get, trigger }, this.config, extensionConfig); + const extension = new Extension(config); + if (!(Extension.layer in this.extensions)) { + this.extensions[Extension.layer] = []; + } + this.extensions[Extension.layer].push(extension); + } + + /** + * Returns the result of the first related method on any instantiated + * extension. This method must be overridden if multiple extensions + * return a value with a common method (and dispatchAll does not + * suffice). After the dispatch of the action, all models are partially + * reloaded and components are notified afterwards. + * @param {string} method + * @param {...any} args + */ + dispatch(method, ...args) { + const isInitialDispatch = !this.dispatching; + this.dispatching = true; + for (const extension of this.extensions.flat()) { + extension.dispatch(method, ...args); + } + if (!isInitialDispatch) { + return; + } + this.dispatching = false; + let rev = this.rev; + // Calls 'after dispatch' hooks + // Purpose: fetch updated data from the server. This is considered + // a loading action and is thus performed by groups instead of + // loading all extensions at once. + this._loadExtensions({ isInitialLoad: false }).then(() => { + // Notifies subscribed components + // Purpose: re-render components bound by 'useModel' + if (rev === this.rev) { + this._notifyComponents(); + } + }); + } + + /** + * Stringifies and exports an object holding the exported state of each + * active extension. + * @returns {string} + */ + exportState() { + const exported = {}; + for (const extension of this.extensions.flat()) { + exported[extension.constructor.name] = extension.exportState(); + } + const fullState = Object.assign({}, this.externalState, exported); + return JSON.stringify(fullState); + } + + /** + * Returns the result of the first related getter on any instantiated + * extension. This method must be overridden if multiple extensions + * share a common getter (and getAll does not make the job). + * @param {string} property + * @param {...any} args + * @returns {any} + */ + get(property, ...args) { + for (const extension of this.extensions.flat()) { + const result = extension.get(property, ...args); + if (isNotNull(result)) { + return result; + } + } + return null; + } + + /** + * Parses the given stringified state object and imports each state + * part to its related extension. + * @param {string} [stringifiedState="null"] + */ + importState(stringifiedState = "null") { + const state = JSON.parse(stringifiedState) || {}; + Object.assign(this.externalState, state); + for (const extension of this.extensions.flat()) { + extension.importState(state[extension.constructor.name]); + } + } + + /** + * Must be called after construction and state preparation/import. + * Waits for all asynchronous work needed by the model extensions to be + * ready. + * /!\ The current model extensions do not require a smarter system at + * the moment (therefore using layers instead of dependencies). It + * should be changed if at some point an extension needs another + * specific extension to be loaded instead of a whole batch (with the + * current system some promises will be waited needlessly). + * @returns {Promise} + */ + async load() { + await this._loadExtensions({ isInitialLoad: true }); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Returns the list of the results of all extensions providing a getter + * for the given property returning a non-null value, excluding the + * extension whose name is equal to "excluded". This method is given to + * each extension in the "config" object bound to the model scope and + * having the extension name bound as the first argument. + * @private + * @param {string} excluded + * @param {string} property + * @param {...any} args + * @returns {any[]} + */ + __get(excluded, property, ...args) { + const results = []; + for (const extension of this.extensions.flat()) { + if (extension.constructor.name !== excluded) { + const result = extension.get(property, ...args); + if (isNotNull(result)) { + results.push(result); + } + } + } + return results; + } + + /** + * Private handler to loop over all extension layers sequencially and + * wait for a given callback to be completed on all extensions of a + * same layer. + * @private + * @param {Object} params + * @param {boolean} params.isInitialLoad whether this call comes + * from the initial load. + * @returns {Promise} + */ + async _loadExtensions(params) { + for (let layer = 0; layer < this.extensions.length; layer++) { + await Promise.all(this.extensions[layer].map( + (extension) => extension.callLoad(params) + )); + } + } + + /** + * @see Context.__notifyComponents() in owl.js for explanation + * @private + */ + async _notifyComponents() { + const rev = ++this.rev; + const subscriptions = this.subscriptions.update; + const groups = partitionBy(subscriptions, (s) => + s.owner ? s.owner.__owl__.depth : -1 + ); + for (let group of groups) { + const proms = group.map((sub) => + sub.callback.call(sub.owner, rev) + ); + Component.scheduler.flush(); + await Promise.all(proms); + } + } + } + + Model.Extension = ModelExtension; + + /** + * This is more or less the hook 'useContextWithCB' from owl only slightly + * simplified. + * + * @param {string} modelName + * @returns {model} + */ + function useModel(modelName) { + const component = Component.current; + const model = component.env[modelName]; + if (!(model instanceof Model)) { + throw new Error(`No Model found when connecting '${ + component.name + }'`); + } + + const mapping = model.mapping; + const __owl__ = component.__owl__; + const componentId = __owl__.id; + if (!__owl__.observer) { + __owl__.observer = new Observer(); + __owl__.observer.notifyCB = component.render.bind(component); + } + const currentCB = __owl__.observer.notifyCB; + __owl__.observer.notifyCB = function () { + if (model.rev > mapping[componentId]) { + return; + } + currentCB(); + }; + mapping[componentId] = 0; + const renderFn = __owl__.renderFn; + __owl__.renderFn = function (comp, params) { + mapping[componentId] = model.rev; + return renderFn(comp, params); + }; + + model.on("update", component, async (modelRev) => { + if (mapping[componentId] < modelRev) { + mapping[componentId] = modelRev; + await component.render(); + } + }); + + const __destroy = component.__destroy; + component.__destroy = (parent) => { + model.off("update", component); + __destroy.call(component, parent); + }; + + return model; + } + + return { + Model, + useModel, + }; +}); diff --git a/addons/web/static/src/js/owl_compatibility.js b/addons/web/static/src/js/owl_compatibility.js new file mode 100644 index 00000000..aabb0f98 --- /dev/null +++ b/addons/web/static/src/js/owl_compatibility.js @@ -0,0 +1,540 @@ +odoo.define('web.OwlCompatibility', function () { + "use strict"; + + /** + * This file defines the necessary tools for the transition phase where Odoo + * legacy widgets and Owl components will coexist. There are two possible + * scenarios: + * 1) An Owl component has to instantiate legacy widgets + * 2) A legacy widget has to instantiate Owl components + */ + + const { Component, hooks, tags } = owl; + const { useRef, useSubEnv } = hooks; + const { xml } = tags; + + const widgetSymbol = odoo.widgetSymbol; + const children = new WeakMap(); // associates legacy widgets with their Owl children + + /** + * Case 1) An Owl component has to instantiate legacy widgets + * ---------------------------------------------------------- + * + * The ComponentAdapter is an Owl component meant to be used as universal + * adapter for Owl components that embed Odoo legacy widgets (or dynamically + * both Owl components and Odoo legacy widgets), e.g.: + * + * Owl Component + * | + * ComponentAdapter (Owl component) + * | + * Legacy Widget(s) (or Owl component(s)) + * + * + * The adapter takes the component/widget class as 'Component' prop, and the + * arguments (except first arg 'parent') to initialize it as props. + * For instance: + * <ComponentAdapter Component="LegacyWidget" params="params"/> + * will be translated to: + * const LegacyWidget = this.props.Component; + * const legacyWidget = new LegacyWidget(this, this.props.params); + * + * If more than one argument (in addition to 'parent') is given to initialize + * the legacy widget, the arguments order (to initialize the sub widget) has + * to be somehow specified. There are two alternatives. One can either (1) + * specify the prop 'widgetArgs', corresponding to the array of arguments, + * otherwise (2) a subclass of ComponentAdapter has to be defined. This + * subclass must override the 'widgetArgs' getter to translate arguments + * received as props to an array of arguments for the call to init. + * For instance: + * (1) <ComponentAdapter Component="LegacyWidget" firstArg="a" secondArg="b" widgetsArgs="[a, b]"/> + * (2) class SpecificAdapter extends ComponentAdapter { + * get widgetArgs() { + * return [this.props.firstArg, this.props.secondArg]; + * } + * } + * <SpecificAdapter Component="LegacyWidget" firstArg="a" secondArg="b"/> + * + * If the legacy widget has to be updated when props change, one must define + * a subclass of ComponentAdapter to override 'updateWidget' and 'renderWidget'. The + * 'updateWidget' function takes the nextProps as argument, and should update the + * internal state of the widget (might be async, and return a Promise). + * However, to ensure that the DOM is updated all at once, it shouldn't do + * a re-rendering. This is the role of function 'renderWidget', which will be + * called just before patching the DOM, and which thus must be synchronous. + * For instance: + * class SpecificAdapter extends ComponentAdapter { + * updateWidget(nextProps) { + * return this.widget.updateState(nextProps); + * } + * renderWidget() { + * return this.widget.render(); + * } + * } + */ + class ComponentAdapter extends Component { + /** + * Creates the template on-the-fly, depending on the type of Component + * (legacy widget or Owl component). + * + * @override + */ + constructor(parent, props) { + if (!props.Component) { + throw Error(`ComponentAdapter: 'Component' prop is missing.`); + } + let template; + if (!(props.Component.prototype instanceof Component)) { + template = tags.xml`<div/>`; + } else { + let propsStr = ''; + for (let p in props) { + if (p !== 'Component') { + propsStr += ` ${p}="props.${p}"`; + } + } + template = tags.xml`<t t-component="props.Component"${propsStr}/>`; + } + ComponentAdapter.template = template; + super(...arguments); + this.template = template; + ComponentAdapter.template = null; + + this.widget = null; // widget instance, if Component is a legacy widget + } + + /** + * Starts the legacy widget (not in the DOM yet) + * + * @override + */ + willStart() { + if (!(this.props.Component.prototype instanceof Component)) { + this.widget = new this.props.Component(this, ...this.widgetArgs); + return this.widget._widgetRenderAndInsert(() => {}); + } + } + + /** + * Updates the internal state of the legacy widget (but doesn't re-render + * it yet). + * + * @override + */ + willUpdateProps(nextProps) { + if (this.widget) { + return this.updateWidget(nextProps); + } + } + + /** + * Hooks just before the actual patch to replace the fake div in the + * vnode by the actual node of the legacy widget. If the widget has to + * be re-render (because it has previously been updated), re-render it. + * This must be synchronous. + * + * @override + */ + __patch(target, vnode) { + if (this.widget) { + if (this.__owl__.vnode) { // not at first rendering + this.renderWidget(); + } + vnode.elm = this.widget.el; + } + const result = super.__patch(...arguments); + if (this.widget && this.el !== this.widget.el) { + this.__owl__.vnode.elm = this.widget.el; + } + return result; + } + + /** + * @override + */ + mounted() { + if (this.widget && this.widget.on_attach_callback) { + this.widget.on_attach_callback(); + } + } + + /** + * @override + */ + willUnmount() { + if (this.widget && this.widget.on_detach_callback) { + this.widget.on_detach_callback(); + } + } + + /** + * @override + */ + __destroy() { + super.__destroy(...arguments); + if (this.widget) { + this.widget.destroy(); + } + } + + /** + * Getter that translates the props (except 'Component') into the array + * of arguments used to initialize the legacy widget. + * + * Must be overriden if at least two props (other that Component) are + * given. + * + * @returns {Array} + */ + get widgetArgs() { + if (this.props.widgetArgs) { + return this.props.widgetArgs; + } + const args = Object.keys(this.props); + args.splice(args.indexOf('Component'), 1); + if (args.length > 1) { + throw new Error(`ComponentAdapter has more than 1 argument, 'widgetArgs' must be overriden.`); + } + return args.map(a => this.props[a]); + } + + /** + * Can be overriden to update the internal state of the widget when props + * change. To ensure that the DOM is updated at once, this function should + * not do a re-rendering (which should be done by 'render' instead). + * + * @param {Object} nextProps + * @returns {Promise} + */ + updateWidget(/*nextProps*/) { + if (this.env.isDebug('assets')) { + console.warn(`ComponentAdapter: Widget could not be updated, maybe override 'updateWidget' function?`); + } + } + + /** + * Can be overriden to re-render the widget after an update. This + * function will be called just before patchin the DOM, s.t. the DOM is + * updated at once. It must be synchronous + */ + renderWidget() { + if (this.env.isDebug('assets')) { + console.warn(`ComponentAdapter: Widget could not be re-rendered, maybe override 'renderWidget' function?`); + } + } + + /** + * Mocks _trigger_up to redirect Odoo legacy events to OWL events. + * + * @private + * @param {OdooEvent} ev + */ + _trigger_up(ev) { + const evType = ev.name; + const payload = ev.data; + if (evType === '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 (evType === 'get_session') { + if (payload.callback) { + payload.callback(this.env.session); + } + } else if (evType === 'load_views') { + const params = { + model: payload.modelName, + context: payload.context, + views_descr: payload.views, + }; + this.env.dataManager + .load_views(params, payload.options || {}) + .then(payload.on_success); + } else if (evType === 'load_filters') { + return this.env.dataManager + .load_filters(payload) + .then(payload.on_success); + } else { + payload.__targetWidget = ev.target; + this.trigger(evType.replace(/_/g, '-'), payload); + } + } + } + + + /** + * Case 2) A legacy widget has to instantiate Owl components + * --------------------------------------------------------- + * + * The WidgetAdapterMixin and the ComponentWrapper are meant to be used + * together when an Odoo legacy widget needs to instantiate Owl components. + * In this case, the widgets/components hierarchy would look like: + * + * Legacy Widget + WidgetAdapterMixin + * | + * ComponentWrapper (Owl component) + * | + * Owl Component + * + * In this case, the parent legacy widget must use the WidgetAdapterMixin, + * which ensures that Owl hooks (mounted, willUnmount, destroy...) are + * properly called on the sub components. Moreover, it must instantiate a + * ComponentWrapper, and provide it the Owl component class to use alongside + * its props. This wrapper will ensure that the Owl component will be + * correctly updated (with willUpdateProps) like it would be if it was embed + * in an Owl hierarchy. Moreover, this wrapper automatically redirects all + * events triggered by the Owl component (or its descendants) to legacy + * custom events (trigger_up) on the parent legacy widget. + + * For example: + * class MyComponent extends Component {} + * MyComponent.template = xml`<div>Owl component with value <t t-esc="props.value"/></div>`; + * const MyWidget = Widget.extend(WidgetAdapterMixin, { + * start() { + * this.component = new ComponentWrapper(this, MyComponent, {value: 44}); + * return this.component.mount(this.el); + * }, + * update() { + * return this.component.update({value: 45}); + * }, + * }); + */ + const WidgetAdapterMixin = { + /** + * Calls on_attach_callback on each child ComponentWrapper, which will + * call __callMounted on each sub component (recursively), to mark them + * as mounted. + */ + on_attach_callback() { + for (const component of children.get(this) || []) { + component.on_attach_callback(); + } + }, + /** + * Calls on_detach_callback on each child ComponentWrapper, which will + * call __callWillUnmount to mark itself and its children as no longer + * mounted. + */ + on_detach_callback() { + for (const component of children.get(this) || []) { + component.on_detach_callback(); + } + }, + /** + * Destroys each sub component when the widget is destroyed. We call the + * private __destroy function as there is no need to remove the el from + * the DOM (will be removed alongside this widget). + */ + destroy() { + for (const component of children.get(this) || []) { + component.__destroy(); + } + children.delete(this); + }, + }; + class ComponentWrapper extends Component { + /** + * Stores the reference of the instance in the parent (in __components). + * Also creates a sub environment with a function that will be called + * just before events are triggered (see component_extension.js). This + * allows to add DOM event listeners on-the-fly, to redirect those Owl + * custom (yet DOM) events to legacy custom events (trigger_up). + * + * @override + * @param {Widget|null} parent + * @param {Component} Component this is a Class, not an instance + * @param {Object} props + */ + constructor(parent, Component, props) { + if (parent instanceof Component) { + throw new Error('ComponentWrapper must be used with a legacy Widget as parent'); + } + super(null, props); + if (parent) { + this._register(parent); + } + useSubEnv({ + [widgetSymbol]: this._addListener.bind(this) + }); + + this.parentWidget = parent; + this.Component = Component; + this.props = props || {}; + this._handledEvents = new Set(); // Owl events we are redirecting + + this.componentRef = useRef("component"); + } + + /** + * Calls __callMounted on itself and on each sub component (as this + * function isn't recursive) when the component is appended into the DOM. + */ + on_attach_callback() { + function recursiveCallMounted(component) { + if ( + component.__owl__.status !== 2 /* RENDERED */ && + component.__owl__.status !== 3 /* MOUNTED */ && + component.__owl__.status !== 4 /* UNMOUNTED */ + ) { + // Avoid calling mounted on a component that is not even + // rendered. Doing otherwise will lead to a crash if a + // specific mounted callback is legitimately relying on the + // component being mounted. + return; + } + for (const key in component.__owl__.children) { + recursiveCallMounted(component.__owl__.children[key]); + } + component.__callMounted(); + } + recursiveCallMounted(this); + } + /** + * Calls __callWillUnmount to notify the component it will be unmounted. + */ + on_detach_callback() { + this.__callWillUnmount(); + } + + /** + * Overrides to remove the reference to this component in the parent. + * + * @override + */ + destroy() { + if (this.parentWidget) { + const parentChildren = children.get(this.parentWidget); + if (parentChildren) { + const index = parentChildren.indexOf(this); + children.get(this.parentWidget).splice(index, 1); + } + } + super.destroy(); + } + + /** + * Changes the parent of the wrapper component. This is a function of the + * legacy widgets (ParentedMixin), so we have to handle it someway. + * It simply removes the reference of this component in the current + * parent (if there was one), and adds the reference to the new one. + * + * We have at least one usecase for this: in views, the renderer is + * instantiated without parent, then a controller is instantiated with + * the renderer as argument, and finally, setParent is called to set the + * controller as parent of the renderer. This implies that Owl renderers + * can't trigger events in their constructor. + * + * @param {Widget} parent + */ + setParent(parent) { + if (parent instanceof Component) { + throw new Error('ComponentWrapper must be used with a legacy Widget as parent'); + } + this._register(parent); + if (this.parentWidget) { + const parentChildren = children.get(this.parentWidget); + parentChildren.splice(parentChildren.indexOf(this), 1); + } + this.parentWidget = parent; + } + + /** + * Updates the props and re-render the component. + * + * @async + * @param {Object} props + * @return {Promise} + */ + async update(props = {}) { + if (this.__owl__.status === 5 /* destroyed */) { + return new Promise(() => {}); + } + + Object.assign(this.props, props); + + let prom; + if (this.__owl__.status === 3 /* mounted */) { + prom = this.render(); + } else { + // we may not be in the DOM, but actually want to be redrawn + // (e.g. we were detached from the DOM, and now we're going to + // be re-attached, but we need to be reloaded first). In this + // case, we have to call 'mount' as Owl would skip the rendering + // if we simply call render. + prom = this.mount(...this._mountArgs); + } + return prom; + } + + /** + * Adds an event handler that will redirect the given Owl event to an + * Odoo legacy event. This function is called just before the event is + * actually triggered. + * + * @private + * @param {string} evType + */ + _addListener(evType) { + if (this.parentWidget && !this._handledEvents.has(evType)) { + this._handledEvents.add(evType); + this.el.addEventListener(evType, ev => { + // as the WrappeComponent has the same root node as the + // actual sub Component, we have to check that the event + // hasn't been stopped by that component (it would naturally + // call stopPropagation, whereas it should actually call + // stopImmediatePropagation to prevent from getting here) + if (!ev.cancelBubble) { + ev.stopPropagation(); + const detail = Object.assign({}, ev.detail, { + __originalComponent: ev.originalComponent, + }); + this.parentWidget.trigger_up(ev.type.replace(/-/g, '_'), detail); + } + }); + } + } + + /** + * Registers this instance as a child of the given parent in the + * 'children' weakMap. + * + * @private + * @param {Widget} parent + */ + _register(parent) { + let parentChildren = children.get(parent); + if (!parentChildren) { + parentChildren = []; + children.set(parent, parentChildren); + } + parentChildren.push(this); + } + /** + * Stores mount target and position at first mount. That way, when updating + * while out of DOM, we know where and how to remount. + * @see update() + * @override + */ + async mount(target, options) { + if (options && options.position === 'self') { + throw new Error( + 'Unsupported position: "self" is not allowed for wrapper components. ' + + 'Contact the JS Framework team or open an issue if your use case is relevant.' + ); + } + this._mountArgs = arguments; + return super.mount(...arguments); + } + } + ComponentWrapper.template = xml`<t t-component="Component" t-props="props" t-ref="component"/>`; + + return { + ComponentAdapter, + ComponentWrapper, + WidgetAdapterMixin, + }; +}); diff --git a/addons/web/static/src/js/promise_extension.js b/addons/web/static/src/js/promise_extension.js new file mode 100644 index 00000000..4758975c --- /dev/null +++ b/addons/web/static/src/js/promise_extension.js @@ -0,0 +1,22 @@ +/** + * This file adds a 'guardedCatch' function to the Promise API. This function + * has to be used when we don't want to swallow real errors (crashes), like + * 'catch' does (i.e. basically all the time in Odoo). We only execute the + * 'onRejected' handler if the rejection's reason is not an Error, and we always + * return a rejected Promise to let the rejection bubble up (and trigger the + * 'unhandledrejection' event). + */ + +(function () { + var _catch = Promise.prototype.catch; + Promise.prototype.guardedCatch = function (onRejected) { + return _catch.call(this, function (reason) { + if (!reason || !(reason instanceof Error)) { + if (onRejected) { + onRejected.call(this, reason); + } + } + return Promise.reject(reason); + }); + }; +})(); diff --git a/addons/web/static/src/js/public/lazyloader.js b/addons/web/static/src/js/public/lazyloader.js new file mode 100644 index 00000000..8ca0948c --- /dev/null +++ b/addons/web/static/src/js/public/lazyloader.js @@ -0,0 +1,111 @@ +odoo.define('web.public.lazyloader', function (require) { +'use strict'; + +var blockEvents = ['submit', 'click']; +var blockFunction = function (ev) { + ev.preventDefault(); + ev.stopImmediatePropagation(); +}; + +var waitingLazy = false; + +/** + * Blocks the DOM sections which explicitely require the lazy loaded JS to be + * working (those sections should be marked with the 'o_wait_lazy_js' class). + * + * @see stopWaitingLazy + */ +function waitLazy() { + if (waitingLazy) { + return; + } + waitingLazy = true; + + var lazyEls = document.querySelectorAll('.o_wait_lazy_js'); + for (var i = 0; i < lazyEls.length; i++) { + var element = lazyEls[i]; + blockEvents.forEach(function (evType) { + element.addEventListener(evType, blockFunction); + }); + } +} +/** + * Unblocks the DOM sections blocked by @see waitLazy and removes the related + * 'o_wait_lazy_js' class from the whole DOM. + */ +function stopWaitingLazy() { + if (!waitingLazy) { + return; + } + waitingLazy = false; + + var lazyEls = document.querySelectorAll('.o_wait_lazy_js'); + for (var i = 0; i < lazyEls.length; i++) { + var element = lazyEls[i]; + blockEvents.forEach(function (evType) { + element.removeEventListener(evType, blockFunction); + }); + element.classList.remove('o_wait_lazy_js'); + } +} + +// Start waiting for lazy loading as soon as the DOM is available +if (document.readyState !== 'loading') { + waitLazy(); +} else { + document.addEventListener('DOMContentLoaded', function () { + waitLazy(); + }); +} + +// As soon as everything is fully loaded, start loading all the remaining JS +// and unblock the related DOM section when all of it have been loaded and +// executed +var doResolve = null; +var _allScriptsLoaded = new Promise(function (resolve) { + if (doResolve) { + resolve(); + } else { + doResolve = resolve; + } +}).then(function () { + stopWaitingLazy(); +}); +if (document.readyState === 'complete') { + setTimeout(_loadScripts, 0); +} else { + window.addEventListener('load', function () { + setTimeout(_loadScripts, 0); + }); +} + +/** + * @param {DOMElement[]} scripts + * @param {integer} index + */ +function _loadScripts(scripts, index) { + if (scripts === undefined) { + scripts = document.querySelectorAll('script[data-src]'); + } + if (index === undefined) { + index = 0; + } + if (index >= scripts.length) { + if (typeof doResolve === 'function') { + doResolve(); + } else { + doResolve = true; + } + return; + } + var script = scripts[index]; + script.addEventListener('load', _loadScripts.bind(this, scripts, index + 1)); + script.src = script.dataset.src; + script.removeAttribute('data-src'); +} + +return { + loadScripts: _loadScripts, + allScriptsLoaded: _allScriptsLoaded, +}; +}); diff --git a/addons/web/static/src/js/public/public_crash_manager.js b/addons/web/static/src/js/public/public_crash_manager.js new file mode 100644 index 00000000..8eae270f --- /dev/null +++ b/addons/web/static/src/js/public/public_crash_manager.js @@ -0,0 +1,31 @@ +odoo.define('web.PublicCrashManager', function (require) { +"use strict"; + +const core = require('web.core'); +const CrashManager = require('web.CrashManager').CrashManager; + +const PublicCrashManager = CrashManager.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _displayWarning(message, title, options) { + this.displayNotification(Object.assign({}, options, { + title, + message, + sticky: true, + })); + }, +}); + +core.serviceRegistry.add('crash_manager', PublicCrashManager); + +return { + CrashManager: PublicCrashManager, +}; + +}); diff --git a/addons/web/static/src/js/public/public_env.js b/addons/web/static/src/js/public/public_env.js new file mode 100644 index 00000000..581cccd4 --- /dev/null +++ b/addons/web/static/src/js/public/public_env.js @@ -0,0 +1,11 @@ +odoo.define("web.public_env", function (require) { + "use strict"; + + /** + * This file defines the env to use in the public side. + */ + + const commonEnv = require("web.commonEnv"); + + return commonEnv; +}); diff --git a/addons/web/static/src/js/public/public_notification.js b/addons/web/static/src/js/public/public_notification.js new file mode 100644 index 00000000..260937f0 --- /dev/null +++ b/addons/web/static/src/js/public/public_notification.js @@ -0,0 +1,9 @@ +odoo.define('web.public.Notification', function (require) { +'use strict'; + +var Notification = require('web.Notification'); + +Notification.include({ + xmlDependencies: ['/web/static/src/xml/notification.xml'], +}); +}); diff --git a/addons/web/static/src/js/public/public_root.js b/addons/web/static/src/js/public/public_root.js new file mode 100644 index 00000000..93e1c4fd --- /dev/null +++ b/addons/web/static/src/js/public/public_root.js @@ -0,0 +1,336 @@ +odoo.define('web.public.root', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var dom = require('web.dom'); +const env = require('web.public_env'); +var session = require('web.session'); +var utils = require('web.utils'); +var publicWidget = require('web.public.widget'); + +var publicRootRegistry = new publicWidget.RootWidgetRegistry(); + +// Load localizations outside the PublicRoot to not wait for DOM ready (but +// wait for them in PublicRoot) +function getLang() { + var html = document.documentElement; + return (html.getAttribute('lang') || 'en_US').replace('-', '_'); +} +var lang = utils.get_cookie('frontend_lang') || getLang(); // FIXME the cookie value should maybe be in the ctx? +var localeDef = ajax.loadJS('/web/webclient/locale/' + lang.replace('-', '_')); + +/** + * Element which is designed to be unique and that will be the top-most element + * in the widget hierarchy. So, all other widgets will be indirectly linked to + * this Class instance. Its main role will be to retrieve RPC demands from its + * children and handle them. + */ +var PublicRoot = publicWidget.RootWidget.extend({ + events: _.extend({}, publicWidget.RootWidget.prototype.events || {}, { + 'submit .js_website_submit_form': '_onWebsiteFormSubmit', + 'click .js_disable_on_click': '_onDisableOnClick', + }), + custom_events: _.extend({}, publicWidget.RootWidget.prototype.custom_events || {}, { + call_service: '_onCallService', + context_get: '_onContextGet', + main_object_request: '_onMainObjectRequest', + widgets_start_request: '_onWidgetsStartRequest', + widgets_stop_request: '_onWidgetsStopRequest', + }), + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + this.env = env; + this.publicWidgets = []; + }, + /** + * @override + */ + willStart: function () { + // TODO would be even greater to wait for localeDef only when necessary + return Promise.all([ + this._super.apply(this, arguments), + session.is_bound, + localeDef + ]); + }, + /** + * @override + */ + start: function () { + var defs = [ + this._super.apply(this, arguments), + this._startWidgets() + ]; + + // Display image thumbnail + this.$(".o_image[data-mimetype^='image']").each(function () { + var $img = $(this); + if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) { + $img.css('background-image', "url('" + $img.data('src') + "')"); + } + }); + + // Auto scroll + if (window.location.hash.indexOf("scrollTop=") > -1) { + this.el.scrollTop = +window.location.hash.match(/scrollTop=([0-9]+)/)[1]; + } + + // Fix for IE: + if ($.fn.placeholder) { + $('input, textarea').placeholder(); + } + + this.$el.children().on('error.datetimepicker', this._onDateTimePickerError.bind(this)); + + return Promise.all(defs); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Retrieves the global context of the public environment. This is the + * context which is automatically added to each RPC. + * + * @private + * @param {Object} [context] + * @returns {Object} + */ + _getContext: function (context) { + return _.extend({ + 'lang': getLang(), + }, context || {}); + }, + /** + * Retrieves the global context of the public environment (as + * @see _getContext) but with extra informations that would be useless to + * send with each RPC. + * + * @private + * @param {Object} [context] + * @returns {Object} + */ + _getExtraContext: function (context) { + return this._getContext(context); + }, + /** + * @private + * @param {Object} [options] + * @returns {Object} + */ + _getPublicWidgetsRegistry: function (options) { + return publicWidget.registry; + }, + /** + * As the root instance is designed to be unique, the associated + * registry has been instantiated outside of the class and is simply + * returned here. + * + * @private + * @override + */ + _getRegistry: function () { + return publicRootRegistry; + }, + /** + * Creates an PublicWidget instance for each DOM element which matches the + * `selector` key of one of the registered widgets + * (@see PublicWidget.selector). + * + * @private + * @param {jQuery} [$from] + * only initialize the public widgets whose `selector` matches the + * element or one of its descendant (default to the wrapwrap element) + * @param {Object} [options] + * @returns {Deferred} + */ + _startWidgets: function ($from, options) { + var self = this; + + if ($from === undefined) { + $from = this.$('#wrapwrap'); + if (!$from.length) { + // TODO Remove this once all frontend layouts possess a + // #wrapwrap element (which is necessary for those pages to be + // adapted correctly if the user installs website). + $from = this.$el; + } + } + if (options === undefined) { + options = {}; + } + + this._stopWidgets($from); + + var defs = _.map(this._getPublicWidgetsRegistry(options), function (PublicWidget) { + var selector = PublicWidget.prototype.selector || ''; + var $target = dom.cssFind($from, selector, true); + + var defs = _.map($target, function (el) { + var widget = new PublicWidget(self, options); + self.publicWidgets.push(widget); + return widget.attachTo($(el)); + }); + return Promise.all(defs); + }); + return Promise.all(defs); + }, + /** + * Destroys all registered widget instances. Website would need this before + * saving while in edition mode for example. + * + * @private + * @param {jQuery} [$from] + * only stop the public widgets linked to the given element(s) or one + * of its descendants + */ + _stopWidgets: function ($from) { + var removedWidgets = _.map(this.publicWidgets, function (widget) { + if (!$from + || $from.filter(widget.el).length + || $from.find(widget.el).length) { + widget.destroy(); + return widget; + } + return null; + }); + this.publicWidgets = _.difference(this.publicWidgets, removedWidgets); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Calls the requested service from the env. Automatically adds the global + * context to RPCs. + * + * @private + * @param {OdooEvent} event + */ + _onCallService: function (ev) { + function _computeContext(context, noContextKeys) { + context = _.extend({}, this._getContext(), context); + if (noContextKeys) { + context = _.omit(context, noContextKeys); + } + return JSON.parse(JSON.stringify(context)); + } + + const payload = ev.data; + 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); + + var route = args[0]; + if (_.str.startsWith(route, '/web/dataset/call_kw/')) { + var params = args[1]; + var options = args[2]; + var noContextKeys; + if (options) { + noContextKeys = options.noContextKeys; + args[2] = _.omit(options, 'noContextKeys'); + } + params.kwargs.context = _computeContext.call(this, params.kwargs.context, noContextKeys); + } + } else if (payload.service === 'ajax' && payload.method === 'loadLibs') { + args[1] = _computeContext.call(this, args[1]); + } + + const service = this.env.services[payload.service]; + const result = service[payload.method].apply(service, args); + payload.callback(result); + }, + /** + * Called when someone asked for the global public context. + * + * @private + * @param {OdooEvent} ev + */ + _onContextGet: function (ev) { + if (ev.data.extra) { + ev.data.callback(this._getExtraContext(ev.data.context)); + } else { + ev.data.callback(this._getContext(ev.data.context)); + } + }, + /** + * Checks information about the page main object. + * + * @private + * @param {OdooEvent} ev + */ + _onMainObjectRequest: function (ev) { + var repr = $('html').data('main-object'); + var m = repr.match(/(.+)\((\d+),(.*)\)/); + ev.data.callback({ + model: m[1], + id: m[2] | 0, + }); + }, + /** + * Called when the root is notified that the public widgets have to be + * (re)started. + * + * @private + * @param {OdooEvent} ev + */ + _onWidgetsStartRequest: function (ev) { + this._startWidgets(ev.data.$target, ev.data.options) + .then(ev.data.onSuccess) + .guardedCatch(ev.data.onFailure); + }, + /** + * Called when the root is notified that the public widgets have to be + * stopped. + * + * @private + * @param {OdooEvent} ev + */ + _onWidgetsStopRequest: function (ev) { + this._stopWidgets(ev.data.$target); + }, + /** + * @todo review + * @private + */ + _onWebsiteFormSubmit: function (ev) { + var $buttons = $(ev.currentTarget).find('button[type="submit"], a.a-submit'); + _.each($buttons, function (btn) { + var $btn = $(btn); + $btn.html('<i class="fa fa-spinner fa-spin"></i> ' + $btn.text()); + $btn.prop('disabled', true); + }); + }, + /** + * Called when the root is notified that the button should be + * disabled after the first click. + * + * @private + * @param {Event} ev + */ + _onDisableOnClick: function (ev) { + $(ev.currentTarget).addClass('disabled'); + }, + /** + * Library clears the wrong date format so just ignore error + * + * @private + * @param {Event} ev + */ + _onDateTimePickerError: function (ev) { + return false; + }, +}); + +return { + PublicRoot: PublicRoot, + publicRootRegistry: publicRootRegistry, +}; +}); diff --git a/addons/web/static/src/js/public/public_root_instance.js b/addons/web/static/src/js/public/public_root_instance.js new file mode 100644 index 00000000..a7422a97 --- /dev/null +++ b/addons/web/static/src/js/public/public_root_instance.js @@ -0,0 +1,33 @@ +odoo.define('root.widget', function (require) { +'use strict'; + +const AbstractService = require('web.AbstractService'); +const env = require('web.public_env'); +var lazyloader = require('web.public.lazyloader'); +var rootData = require('web.public.root'); + +/** + * Configure Owl with the public env + */ +owl.config.mode = env.isDebug() ? "dev" : "prod"; +owl.Component.env = env; + +/** + * Deploy services in the env + */ +AbstractService.prototype.deployServices(env); + +/** + * This widget is important, because the tour manager needs a root widget in + * order to work. The root widget must be a service provider with the ajax + * service, so that the tour manager can let the server know when tours have + * been consumed. + */ +var publicRoot = new rootData.PublicRoot(null); +return lazyloader.allScriptsLoaded.then(function () { + return publicRoot.attachTo(document.body).then(function () { + return publicRoot; + }); +}); + +}); diff --git a/addons/web/static/src/js/public/public_widget.js b/addons/web/static/src/js/public/public_widget.js new file mode 100644 index 00000000..79382e11 --- /dev/null +++ b/addons/web/static/src/js/public/public_widget.js @@ -0,0 +1,356 @@ +odoo.define('web.public.widget', function (require) { +'use strict'; + +/** + * Provides a way to start JS code for public contents. + */ + +var Class = require('web.Class'); +var dom = require('web.dom'); +var mixins = require('web.mixins'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +/** + * Specialized Widget which automatically instantiates child widgets to attach + * to internal DOM elements once it is started. The widgets to instantiate are + * known thanks to a linked registry which contains info about the widget + * classes and jQuery selectors to use to find the elements to attach them to. + * + * @todo Merge with 'PublicWidget' ? + */ +var RootWidget = Widget.extend({ + custom_events: _.extend({}, Widget.prototype.custom_events || {}, { + 'registry_update': '_onRegistryUpdate', + 'get_session': '_onGetSession', + }), + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + this._widgets = []; + this._listenToUpdates = false; + this._getRegistry().setParent(this); + }, + /** + * @override + * @see _attachComponents + */ + start: function () { + var defs = [this._super.apply(this, arguments)]; + + defs.push(this._attachComponents()); + this._listenToUpdates = true; + + return Promise.all(defs); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiates a child widget according to the given registry data. + * + * @private + * @param {Object} childInfo + * @param {function} childInfo.Widget - the widget class to instantiate + * @param {string} childInfo.selector + * the jQuery selector to use to find the internal DOM element which + * needs to be attached to the instantiated widget + * @param {jQuery} [$from] - only check DOM elements which are descendant of + * the given one. If not given, use this.$el. + * @returns {Deferred} + */ + _attachComponent: function (childInfo, $from) { + var self = this; + var $elements = dom.cssFind($from || this.$el, childInfo.selector); + var defs = _.map($elements, function (element) { + var w = new childInfo.Widget(self); + self._widgets.push(w); + return w.attachTo(element); + }); + return Promise.all(defs); + }, + /** + * Instantiates the child widgets that need to be according to the linked + * registry. + * + * @private + * @param {jQuery} [$from] - only check DOM elements which are descendant of + * the given one. If not given, use this.$el. + * @returns {Deferred} + */ + _attachComponents: function ($from) { + var self = this; + var childInfos = this._getRegistry().get(); + var defs = _.map(childInfos, function (childInfo) { + return self._attachComponent(childInfo, $from); + }); + return Promise.all(defs); + }, + /** + * Returns the `RootWidgetRegistry` instance that is linked to this + * `RootWidget` instance. + * + * @abstract + * @private + * @returns {RootWidgetRegistry} + */ + _getRegistry: function () {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Get the curuent session module. + * + * @private + * @param {OdooEvent} ev + */ + _onGetSession: function (event) { + if (event.data.callback) { + event.data.callback(session); + } + }, + /** + * Called when the linked registry is updated after this `RootWidget` + * + * @private + * @param {OdooEvent} ev + */ + _onRegistryUpdate: function (ev) { + ev.stopPropagation(); + if (this._listenToUpdates) { + this._attachComponent(ev.data); + } + }, +}); + +var RootWidgetRegistry = Class.extend(mixins.EventDispatcherMixin, { + /** + * @constructor + */ + init: function () { + mixins.EventDispatcherMixin.init.call(this); + this._registry = []; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds an element to the registry (info of what and how to instantiate). + * + * @param {function} Widget - the widget class to instantiate + * @param {string} selector + * the jQuery selector to use to find the internal DOM element which + * needs to be attached to the instantiated widget + */ + add: function (Widget, selector) { + var registryInfo = { + Widget: Widget, + selector: selector, + }; + this._registry.push(registryInfo); + this.trigger_up('registry_update', registryInfo); + }, + /** + * Retrieves all the registry elements. + */ + get: function () { + return this._registry; + }, +}); + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +/** + * Provides a way for executing code once a website DOM element is loaded in the + * dom. + */ +var PublicWidget = Widget.extend({ + /** + * The selector attribute, if defined, allows to automatically create an + * instance of this widget on page load for each DOM element which + * matches this selector. The `PublicWidget.$target` element will then be + * that particular DOM element. This should be the main way of instantiating + * `PublicWidget` elements. + * + * @todo do not make this part of the Widget but rather an info to give when + * registering the widget. + */ + selector: false, + /** + * Extension of @see Widget.events + * + * A description of the event handlers to bind/delegate once the widget + * has been rendered:: + * + * 'click .hello .world': 'async _onHelloWorldClick', + * _^_ _^_ _^_ _^_ + * | | | | + * | (Optional) jQuery | Handler method name + * | delegate selector | + * | |_ (Optional) space separated options + * | * async: use the automatic system + * |_ Event name with making handlers promise-ready (see + * potential jQuery makeButtonHandler, makeAsyncHandler) + * namespaces + * + * Note: the values may be replaced by a function declaration. This is + * however a deprecated behavior. + * + * @type {Object} + */ + events: {}, + + /** + * @constructor + * @param {Object} parent + * @param {Object} [options] + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.options = options || {}; + }, + /** + * Destroys the widget and basically restores the target to the state it + * was before the start method was called (unlike standard widget, the + * associated $el DOM is not removed, if this was instantiated thanks to the + * selector property). + */ + destroy: function () { + if (this.selector) { + var $oldel = this.$el; + // The difference with the default behavior is that we unset the + // associated element first so that: + // 1) its events are unbinded + // 2) it is not removed from the DOM + this.setElement(null); + } + + this._super.apply(this, arguments); + + if (this.selector) { + // Reassign the variables afterwards to allow extensions to use them + // after calling the _super method + this.$el = $oldel; + this.el = $oldel[0]; + this.$target = this.$el; + this.target = this.el; + } + }, + /** + * @override + */ + setElement: function () { + this._super.apply(this, arguments); + if (this.selector) { + this.$target = this.$el; + this.target = this.el; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @see this.events + * @override + */ + _delegateEvents: function () { + var self = this; + var originalEvents = this.events; + + var events = {}; + _.each(this.events, function (method, event) { + // If the method is a function, use the default Widget system + if (typeof method !== 'string') { + events[event] = method; + return; + } + // If the method is only a function name without options, use the + // default Widget system + var methodOptions = method.split(' '); + if (methodOptions.length <= 1) { + events[event] = method; + return; + } + // If the method has no meaningful options, use the default Widget + // system + var isAsync = _.contains(methodOptions, 'async'); + if (!isAsync) { + events[event] = method; + return; + } + + method = self.proxy(methodOptions[methodOptions.length - 1]); + if (_.str.startsWith(event, 'click')) { + // Protect click handler to be called multiple times by + // mistake by the user and add a visual disabling effect + // for buttons. + method = dom.makeButtonHandler(method); + } else { + // Protect all handlers to be recalled while the previous + // async handler call is not finished. + method = dom.makeAsyncHandler(method); + } + events[event] = method; + }); + + this.events = events; + this._super.apply(this, arguments); + this.events = originalEvents; + }, + /** + * @private + * @param {boolean} [extra=false] + * @param {Object} [extraContext] + * @returns {Object} + */ + _getContext: function (extra, extraContext) { + var context; + this.trigger_up('context_get', { + extra: extra || false, + context: extraContext, + callback: function (ctx) { + context = ctx; + }, + }); + return context; + }, +}); + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +/** + * The registry object contains the list of widgets that should be instantiated + * thanks to their selector property if any. + */ +var registry = {}; + +/** + * This is a fix for apple device (<= IPhone 4, IPad 2) + * Standard bootstrap requires data-toggle='collapse' element to be <a/> tags. + * Unfortunatly some layouts use a <div/> tag instead. The fix forces an empty + * click handler on these div, which allows standard bootstrap to work. + */ +registry._fixAppleCollapse = PublicWidget.extend({ + selector: 'div[data-toggle="collapse"]', + events: { + 'click': function () {}, + }, +}); + +return { + RootWidget: RootWidget, + RootWidgetRegistry: RootWidgetRegistry, + Widget: PublicWidget, + registry: registry, +}; +}); diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js new file mode 100644 index 00000000..d4cb3096 --- /dev/null +++ b/addons/web/static/src/js/report/client_action.js @@ -0,0 +1,124 @@ +odoo.define('report.client_action', function (require) { +'use strict'; + +var AbstractAction = require('web.AbstractAction'); +var core = require('web.core'); +var session = require('web.session'); +var utils = require('report.utils'); + +var QWeb = core.qweb; + + +var AUTHORIZED_MESSAGES = [ + 'report:do_action', +]; + +var ReportAction = AbstractAction.extend({ + hasControlPanel: true, + contentTemplate: 'report.client_action', + + init: function (parent, action, options) { + this._super.apply(this, arguments); + + options = options || {}; + + this.action_manager = parent; + this._title = options.display_name || options.name; + + this.report_url = options.report_url; + + // Extra info that will be useful to build a qweb-pdf action. + this.report_name = options.report_name; + this.report_file = options.report_file; + this.data = options.data || {}; + this.context = options.context || {}; + }, + + start: function () { + var self = this; + this.iframe = this.$('iframe')[0]; + this.$buttons = $(QWeb.render('report.client_action.ControlButtons', {})); + this.$buttons.on('click', '.o_report_print', this.on_click_print); + this.controlPanelProps.cp_content = { + $buttons: this.$buttons, + }; + return Promise.all([this._super.apply(this, arguments), session.is_bound]).then(async function () { + var web_base_url = session['web.base.url']; + var trusted_host = utils.get_host_from_url(web_base_url); + var trusted_protocol = utils.get_protocol_from_url(web_base_url); + self.trusted_origin = utils.build_origin(trusted_protocol, trusted_host); + + // Load the report in the iframe. Note that we use a relative URL. + self.iframe.src = self.report_url; + }); + }, + + on_attach_callback: function () { + // Register now the postMessage event handler. We only want to listen to ~trusted + // messages and we can only filter them by their origin, so we chose to ignore the + // messages that do not come from `web.base.url`. + $(window).on('message', this, this.on_message_received); + this._super(); + }, + + on_detach_callback: function () { + $(window).off('message', this.on_message_received); + this._super(); + }, + + /** + * Event handler of the message post. We only handle them if they're from + * `web.base.url` host and protocol and if they're part of `AUTHORIZED_MESSAGES`. + */ + on_message_received: function (ev) { + // Check the origin of the received message. + var message_origin = utils.build_origin(ev.originalEvent.source.location.protocol, ev.originalEvent.source.location.host); + if (message_origin === this.trusted_origin) { + + // Check the syntax of the received message. + var message = ev.originalEvent.data; + if (_.isObject(message)) { + message = message.message; + } + if (! _.isString(message) || (_.isString(message) && ! _.contains(AUTHORIZED_MESSAGES, message))) { + return; + } + + switch(message) { + case 'report:do_action': + return this.do_action(ev.originalEvent.data.action); + default: + } + } + }, + + /** + * Helper allowing to send a message to the `this.el` iframe's window and + * seting the `targetOrigin` as `this.trusted_origin` (which is the + * `web.base.url` ir.config_parameter key) - in other word, only when using + * this method we only send the message to a trusted domain. + */ + _post_message: function (message) { + this.iframe.contentWindow.postMessage(message, this.trusted_origin); + }, + + on_click_print: function () { + var action = { + 'type': 'ir.actions.report', + 'report_type': 'qweb-pdf', + 'report_name': this.report_name, + 'report_file': this.report_file, + 'data': this.data, + 'context': this.context, + 'display_name': this.title, + }; + return this.do_action(action); + }, + +}); + +core.action_registry.add('report.client_action', ReportAction); + +return ReportAction; + +}); diff --git a/addons/web/static/src/js/report/report.js b/addons/web/static/src/js/report/report.js new file mode 100644 index 00000000..daf50c0e --- /dev/null +++ b/addons/web/static/src/js/report/report.js @@ -0,0 +1,42 @@ +odoo.define('report', function (require) { +'use strict'; + +require('web.dom_ready'); +var utils = require('report.utils'); + +if (window.self === window.top) { + return; +} + +$(document.body) + .addClass('o_in_iframe') + .addClass('container-fluid') + .removeClass('container'); + +var web_base_url = $('html').attr('web-base-url'); +var trusted_host = utils.get_host_from_url(web_base_url); +var trusted_protocol = utils.get_protocol_from_url(web_base_url); +var trusted_origin = utils.build_origin(trusted_protocol, trusted_host); + +// Allow sending commands to the webclient +// `do_action` command +$('[res-id][res-model][view-type]') + .wrap('<a/>') + .attr('href', '#') + .on('click', function (ev) { + ev.preventDefault(); + var action = { + 'type': 'ir.actions.act_window', + 'view_mode': $(this).attr('view-mode') || $(this).attr('view-type'), + 'res_id': Number($(this).attr('res-id')), + 'res_model': $(this).attr('res-model'), + 'views': [ + [$(this).attr('view-id') || false, $(this).attr('view-type')], + ], + }; + window.parent.postMessage({ + 'message': 'report:do_action', + 'action': action, + }, trusted_origin); + }); +}); diff --git a/addons/web/static/src/js/report/utils.js b/addons/web/static/src/js/report/utils.js new file mode 100644 index 00000000..64776efb --- /dev/null +++ b/addons/web/static/src/js/report/utils.js @@ -0,0 +1,26 @@ +odoo.define('report.utils', function (require) { +'use strict'; + +function get_protocol_from_url (url) { + var a = document.createElement('a'); + a.href = url; + return a.protocol; +} + +function get_host_from_url (url) { + var a = document.createElement('a'); + a.href = url; + return a.host; +} + +function build_origin (protocol, host) { + return protocol + '//' + host; +} + +return { + 'get_protocol_from_url': get_protocol_from_url, + 'get_host_from_url': get_host_from_url, + 'build_origin': build_origin, +}; + +}); diff --git a/addons/web/static/src/js/services/ajax_service.js b/addons/web/static/src/js/services/ajax_service.js new file mode 100644 index 00000000..da3d436c --- /dev/null +++ b/addons/web/static/src/js/services/ajax_service.js @@ -0,0 +1,41 @@ +odoo.define('web.AjaxService', function (require) { +"use strict"; + +var AbstractService = require('web.AbstractService'); +var ajax = require('web.ajax'); +var core = require('web.core'); +var session = require('web.session'); + +var AjaxService = AbstractService.extend({ + /** + * @param {Object} libs - @see ajax.loadLibs + * @param {Object} [context] - @see ajax.loadLibs + * @param {Object} [tplRoute] - @see ajax.loadLibs + */ + loadLibs: function (libs, context, tplRoute) { + return ajax.loadLibs(libs, context, tplRoute); + }, + rpc: function (route, args, options, target) { + var rpcPromise; + var promise = new Promise(function (resolve, reject) { + rpcPromise = session.rpc(route, args, options); + rpcPromise.then(function (result) { + if (!target.isDestroyed()) { + resolve(result); + } + }).guardedCatch(function (reason) { + if (!target.isDestroyed()) { + reject(reason); + } + }); + }); + promise.abort = rpcPromise.abort.bind(rpcPromise); + return promise; + }, +}); + +core.serviceRegistry.add('ajax', AjaxService); + +return AjaxService; + +}); diff --git a/addons/web/static/src/js/services/config.js b/addons/web/static/src/js/services/config.js new file mode 100644 index 00000000..6c576c35 --- /dev/null +++ b/addons/web/static/src/js/services/config.js @@ -0,0 +1,122 @@ +odoo.define('web.config', function (require) { +"use strict"; + +const Bus = require('web.Bus'); + +const bus = new Bus(); + +/** + * This module contains all the (mostly) static 'environmental' information. + * This is often necessary to allow the rest of the web client to properly + * render itself. + * + * Note that many information currently stored in session should be moved to + * this file someday. + */ + +var config = { + device: { + /** + * bus to use in order to be able to handle device config related events + * - 'size_changed' : triggered when window size is + * corresponding to a new bootstrap breakpoint. The new size_class + * is provided. + */ + bus: bus, + /** + * touch is a boolean, true if the device supports touch interaction + * + * @type Boolean + */ + touch: 'ontouchstart' in window || 'onmsgesturechange' in window, + /** + * size_class is an integer: 0, 1, 2, 3 or 4, depending on the (current) + * size of the device. This is a dynamic property, updated whenever the + * browser is resized + * + * @type Number + */ + size_class: null, + /** + * A frequent use case is to have a different render in 'mobile' mode, + * meaning when the screen is small. This flag (boolean) is true when + * the size is XS/VSM/SM. It is also updated dynamically. + * + * @type Boolean + */ + isMobile: null, + /** + * Mobile device detection using userAgent. + * This flag doesn't depend on the size/resolution of the screen. + * It targets mobile devices which suggests that there is a virtual keyboard. + * + * @return {boolean} + */ + isMobileDevice: navigator.userAgent.match(/Android/i) || + navigator.userAgent.match(/webOS/i) || + navigator.userAgent.match(/iPhone/i) || + navigator.userAgent.match(/iPad/i) || + navigator.userAgent.match(/iPod/i) || + navigator.userAgent.match(/BlackBerry/i) || + navigator.userAgent.match(/Windows Phone/i), + /** + * Mapping between the numbers 0,1,2,3,4,5,6 and some descriptions + */ + SIZES: { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 }, + }, + /** + * States whether the current environment is in debug or not. + * + * @param debugMode the debug mode to check, empty for simple debug mode + * @returns {boolean} + */ + isDebug: function (debugMode) { + if (debugMode) { + return odoo.debug && odoo.debug.indexOf(debugMode) !== -1; + } + return odoo.debug; + }, +}; + + +var medias = [ + window.matchMedia('(max-width: 474px)'), + window.matchMedia('(min-width: 475px) and (max-width: 575px)'), + window.matchMedia('(min-width: 576px) and (max-width: 767px)'), + window.matchMedia('(min-width: 768px) and (max-width: 991px)'), + window.matchMedia('(min-width: 992px) and (max-width: 1199px)'), + window.matchMedia('(min-width: 1200px) and (max-width: 1533px)'), + window.matchMedia('(min-width: 1534px)'), +]; + +/** + * Return the current size class + * + * @returns {integer} a number between 0 and 5, included + */ +function _getSizeClass() { + for (var i = 0 ; i < medias.length ; i++) { + if (medias[i].matches) { + return i; + } + } +} +/** + * Update the size dependant properties in the config object. This method + * should be called every time the size class changes. + */ +function _updateSizeProps() { + var sc = _getSizeClass(); + if (sc !== config.device.size_class) { + config.device.size_class = sc; + config.device.isMobile = config.device.size_class <= config.device.SIZES.SM; + config.device.bus.trigger('size_changed', config.device.size_class); + } +} + +_.invoke(medias, 'addListener', _updateSizeProps); +_updateSizeProps(); + +return config; + +}); diff --git a/addons/web/static/src/js/services/core.js b/addons/web/static/src/js/services/core.js new file mode 100644 index 00000000..1d4619f6 --- /dev/null +++ b/addons/web/static/src/js/services/core.js @@ -0,0 +1,49 @@ +odoo.define('web.core', function (require) { +"use strict"; + +var Bus = require('web.Bus'); +var config = require('web.config'); +var Class = require('web.Class'); +var QWeb = require('web.QWeb'); +var Registry = require('web.Registry'); +var translation = require('web.translation'); + +/** + * Whether the client is currently in "debug" mode + * + * @type Boolean + */ +var bus = new Bus(); + +_.each('click,dblclick,keydown,keypress,keyup'.split(','), function (evtype) { + $('html').on(evtype, function (ev) { + bus.trigger(evtype, ev); + }); +}); +_.each('resize,scroll'.split(','), function (evtype) { + $(window).on(evtype, function (ev) { + bus.trigger(evtype, ev); + }); +}); + +return { + qweb: new QWeb(config.isDebug()), + + // core classes and functions + Class: Class, + bus: bus, + main_bus: new Bus(), + _t: translation._t, + _lt: translation._lt, + + // registries + action_registry: new Registry(), + crash_registry: new Registry(), + serviceRegistry: new Registry(), + /** + * @type {String} + */ + csrf_token: odoo.csrf_token, +}; + +}); diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js new file mode 100644 index 00000000..abd40e93 --- /dev/null +++ b/addons/web/static/src/js/services/crash_manager.js @@ -0,0 +1,412 @@ +odoo.define('web.ErrorDialogRegistry', function (require) { +"use strict"; + +var Registry = require('web.Registry'); + +return new Registry(); +}); + +odoo.define('web.CrashManager', function (require) { +"use strict"; + +const AbstractService = require('web.AbstractService'); +var ajax = require('web.ajax'); +const BrowserDetection = require('web.BrowserDetection'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var ErrorDialogRegistry = require('web.ErrorDialogRegistry'); +var Widget = require('web.Widget'); + +var _t = core._t; +var _lt = core._lt; + +// Register this eventlistener before qunit does. +// Some errors needs to be negated by the crash_manager. +window.addEventListener('unhandledrejection', ev => + core.bus.trigger('crash_manager_unhandledrejection', ev) +); + +let active = true; + +/** + * An extension of Dialog Widget to render the warnings and errors on the website. + * Extend it with your template of choice like ErrorDialog/WarningDialog + */ +var CrashManagerDialog = Dialog.extend({ + xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat( + ['/web/static/src/xml/crash_manager.xml'] + ), + + /** + * @param {Object} error + * @param {string} error.message the message in Warning/Error Dialog + * @param {string} error.traceback the traceback in ErrorDialog + * + * @constructor + */ + init: function (parent, options, error) { + this._super.apply(this, [parent, options]); + this.message = error.message; + this.traceback = error.traceback; + core.bus.off('close_dialogs', this); + }, +}); + +var ErrorDialog = CrashManagerDialog.extend({ + template: 'CrashManager.error', +}); + +var WarningDialog = CrashManagerDialog.extend({ + template: 'CrashManager.warning', + + /** + * Sets size to medium by default. + * + * @override + */ + init: function (parent, options, error) { + this._super(parent, _.extend({ + size: 'medium', + }, options), error); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focuses the ok button. + * + * @override + */ + open: function () { + this._super({shouldFocusButtons: true}); + }, +}); + +var CrashManager = AbstractService.extend({ + init: function () { + var self = this; + active = true; + this.isConnected = true; + this.odooExceptionTitleMap = { + 'odoo.addons.base.models.ir_mail_server.MailDeliveryException': _lt("MailDeliveryException"), + 'odoo.exceptions.AccessDenied': _lt("Access Denied"), + 'odoo.exceptions.AccessError': _lt("Access Error"), + 'odoo.exceptions.MissingError': _lt("Missing Record"), + 'odoo.exceptions.UserError': _lt("User Error"), + 'odoo.exceptions.ValidationError': _lt("Validation Error"), + 'odoo.exceptions.Warning': _lt("Warning"), + }; + + this.browserDetection = new BrowserDetection(); + this._super.apply(this, arguments); + + // crash manager integration + core.bus.on('rpc_error', this, this.rpc_error); + window.onerror = function (message, file, line, col, error) { + // Scripts injected in DOM (eg: google API's js files) won't return a clean error on window.onerror. + // The browser will just give you a 'Script error.' as message and nothing else for security issue. + // To enable onerror to work properly with CORS file, you should: + // 1. add crossorigin="anonymous" to your <script> tag loading the file + // 2. enabling 'Access-Control-Allow-Origin' on the server serving the file. + // Since in some case it wont be possible to to this, this handle should have the possibility to be + // handled by the script manipulating the injected file. For this, you will use window.onOriginError + // If it is not handled, we should display something clearer than the common crash_manager error dialog + // since it won't show anything except "Script error." + // This link will probably explain it better: https://blog.sentry.io/2016/05/17/what-is-script-error.html + if (!file && !line && !col) { + // Chrome and Opera set "Script error." on the `message` and hide the `error` + // Firefox handles the "Script error." directly. It sets the error thrown by the CORS file into `error` + if (window.onOriginError) { + window.onOriginError(); + delete window.onOriginError; + } else { + self.show_error({ + type: _t("Odoo Client Error"), + message: _t("Unknown CORS error"), + data: {debug: _t("An unknown CORS error occured. The error probably originates from a JavaScript file served from a different origin. (Opening your browser console might give you a hint on the error.)")}, + }); + } + } else { + // ignore Chrome video internal error: https://crbug.com/809574 + if (!error && message === 'ResizeObserver loop limit exceeded') { + return; + } + var traceback = error ? error.stack : ''; + self.show_error({ + type: _t("Odoo Client Error"), + message: message, + data: {debug: file + ':' + line + "\n" + _t('Traceback:') + "\n" + traceback}, + }); + } + }; + + // listen to unhandled rejected promises, and throw an error when the + // promise has been rejected due to a crash + core.bus.on('crash_manager_unhandledrejection', this, function (ev) { + if (ev.reason && ev.reason instanceof Error) { + // Error.prototype.stack is non-standard. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error + // However, most engines provide an implementation. + // In particular, Chrome formats the contents of Error.stack + // https://v8.dev/docs/stack-trace-api#compatibility + let traceback; + if (self.browserDetection.isBrowserChrome()) { + traceback = ev.reason.stack; + } else { + traceback = `${_t("Error:")} ${ev.reason.message}\n${ev.reason.stack}`; + } + self.show_error({ + type: _t("Odoo Client Error"), + message: '', + data: {debug: _t('Traceback:') + "\n" + traceback}, + }); + } else { + // the rejection is not due to an Error, so prevent the browser + // from displaying an 'unhandledrejection' error in the console + ev.stopPropagation(); + ev.stopImmediatePropagation(); + ev.preventDefault(); + } + }); + }, + enable: function () { + active = true; + }, + disable: function () { + active = false; + }, + handleLostConnection: function () { + var self = this; + if (!this.isConnected) { + // already handled, nothing to do. This can happen when several + // rpcs are done in parallel and fail because of a lost connection. + return; + } + this.isConnected = false; + var delay = 2000; + core.bus.trigger('connection_lost'); + + setTimeout(function checkConnection() { + ajax.jsonRpc('/web/webclient/version_info', 'call', {}, {shadow:true}).then(function () { + core.bus.trigger('connection_restored'); + self.isConnected = true; + }).guardedCatch(function () { + // exponential backoff, with some jitter + delay = (delay * 1.5) + 500*Math.random(); + setTimeout(checkConnection, delay); + }); + }, delay); + }, + rpc_error: function(error) { + // Some qunit tests produces errors before the DOM is set. + // This produces an error loop as the modal/toast has no DOM to attach to. + if (!document.body || !active || this.connection_lost) return; + + // Connection lost error + if (error.code === -32098) { + this.handleLostConnection(); + return; + } + + // Special exception handlers, see crash_registry bellow + var handler = core.crash_registry.get(error.data.name, true); + if (handler) { + new (handler)(this, error).display(); + return; + } + + // Odoo custom exception: UserError, AccessError, ... + if (_.has(this.odooExceptionTitleMap, error.data.name)) { + error = _.extend({}, error, { + data: _.extend({}, error.data, { + message: error.data.arguments[0], + title: this.odooExceptionTitleMap[error.data.name], + }), + }); + this.show_warning(error); + return; + } + + // Any other Python exception + this.show_error(error); + }, + show_warning: function (error, options) { + if (!active) { + return; + } + var message = error.data ? error.data.message : error.message; + var title = _t("Something went wrong !"); + if (error.type) { + title = _.str.capitalize(error.type); + } else if (error.data && error.data.title) { + title = _.str.capitalize(error.data.title); + } + return this._displayWarning(message, title, options); + }, + show_error: function (error) { + if (!active) { + return; + } + error.traceback = error.data.debug; + var dialogClass = error.data.context && ErrorDialogRegistry.get(error.data.context.exception_class) || ErrorDialog; + var dialog = new dialogClass(this, { + title: _.str.capitalize(error.type) || _t("Odoo Error"), + }, error); + + + // When the dialog opens, initialize the copy feature and destroy it when the dialog is closed + var $clipboardBtn; + var clipboard; + dialog.opened(function () { + // When the full traceback is shown, scroll it to the end (useful for better python error reporting) + dialog.$(".o_error_detail").on("shown.bs.collapse", function (e) { + e.target.scrollTop = e.target.scrollHeight; + }); + + $clipboardBtn = dialog.$(".o_clipboard_button"); + $clipboardBtn.tooltip({title: _t("Copied !"), trigger: "manual", placement: "left"}); + clipboard = new window.ClipboardJS($clipboardBtn[0], { + text: function () { + return (_t("Error") + ":\n" + error.message + "\n\n" + error.data.debug).trim(); + }, + // Container added because of Bootstrap modal that give the focus to another element. + // We need to give to correct focus to ClipboardJS (see in ClipboardJS doc) + // https://github.com/zenorocha/clipboard.js/issues/155 + container: dialog.el, + }); + clipboard.on("success", function (e) { + _.defer(function () { + $clipboardBtn.tooltip("show"); + _.delay(function () { + $clipboardBtn.tooltip("hide"); + }, 800); + }); + }); + }); + dialog.on("closed", this, function () { + $clipboardBtn.tooltip('dispose'); + clipboard.destroy(); + }); + + return dialog.open(); + }, + show_message: function(exception) { + return this.show_error({ + type: _t("Odoo Client Error"), + message: exception, + data: {debug: ""} + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} message + * @param {string} title + * @param {Object} options + */ + _displayWarning: function (message, title, options) { + return new WarningDialog(this, Object.assign({}, options, { + title, + }), { + message, + }).open(); + }, +}); + +/** + * An interface to implement to handle exceptions. Register implementation in instance.web.crash_manager_registry. +*/ +var ExceptionHandler = { + /** + * @param parent The parent. + * @param error The error object as returned by the JSON-RPC implementation. + */ + init: function(parent, error) {}, + /** + * Called to inform to display the widget, if necessary. A typical way would be to implement + * this interface in a class extending instance.web.Dialog and simply display the dialog in this + * method. + */ + display: function() {}, +}; + + +/** + * Handle redirection warnings, which behave more or less like a regular + * warning, with an additional redirection button. + */ +var RedirectWarningHandler = Widget.extend(ExceptionHandler, { + init: function(parent, error) { + this._super(parent); + this.error = error; + }, + display: function() { + var self = this; + var error = this.error; + var additional_context = _.extend({}, this.context, error.data.arguments[3]); + + new WarningDialog(this, { + title: _.str.capitalize(error.type) || _t("Odoo Warning"), + buttons: [ + {text: error.data.arguments[2], classes : "btn-primary", click: function() { + self.do_action( + error.data.arguments[1], + { + additional_context: additional_context, + }); + self.destroy(); + }, close: true}, + {text: _t("Cancel"), click: function() { self.destroy(); }, close: true} + ] + }, { + message: error.data.arguments[0], + }).open(); + } +}); + +core.crash_registry.add('odoo.exceptions.RedirectWarning', RedirectWarningHandler); + +function session_expired(cm) { + return { + display: function () { + const notif = { + type: _t("Odoo Session Expired"), + message: _t("Your Odoo session expired. The current page is about to be refreshed."), + }; + const options = { + buttons: [{ + text: _t("Ok"), + click: () => window.location.reload(true), + close: true + }], + }; + cm.show_warning(notif, options); + } + }; +} +core.crash_registry.add('odoo.http.SessionExpiredException', session_expired); +core.crash_registry.add('werkzeug.exceptions.Forbidden', session_expired); + +core.crash_registry.add('504', function (cm) { + return { + display: function () { + cm.show_warning({ + type: _t("Request timeout"), + message: _t("The operation was interrupted. This usually means that the current operation is taking too much time.")}); + } + }; +}); + +return { + CrashManager: CrashManager, + ErrorDialog: ErrorDialog, + WarningDialog: WarningDialog, + disable: () => active = false, +}; +}); diff --git a/addons/web/static/src/js/services/crash_manager_service.js b/addons/web/static/src/js/services/crash_manager_service.js new file mode 100644 index 00000000..44182561 --- /dev/null +++ b/addons/web/static/src/js/services/crash_manager_service.js @@ -0,0 +1,9 @@ +odoo.define('crash_manager.service', function (require) { +'use strict'; + +const core = require('web.core'); +const CrashManager = require('web.CrashManager').CrashManager; + +core.serviceRegistry.add('crash_manager', CrashManager); + +}); diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js new file mode 100644 index 00000000..f5bb482a --- /dev/null +++ b/addons/web/static/src/js/services/data_manager.js @@ -0,0 +1,225 @@ +odoo.define('web.DataManager', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var rpc = require('web.rpc'); +var session = require('web.session'); +var utils = require('web.utils'); + +return core.Class.extend({ + init: function () { + this._init_cache(); + core.bus.on('clear_cache', this, this.invalidate.bind(this)); + }, + + _init_cache: function () { + this._cache = { + actions: {}, + filters: {}, + views: {}, + }; + }, + + /** + * Invalidates the whole cache + * Suggestion: could be refined to invalidate some part of the cache + */ + invalidate: function () { + session.invalidateCacheKey('load_menus'); + this._init_cache(); + }, + + /** + * Loads an action from its id or xmlid. + * + * @param {int|string} [action_id] the action id or xmlid + * @param {Object} [additional_context] used to load the action + * @return {Promise} resolved with the action whose id or xmlid is action_id + */ + load_action: function (action_id, additional_context) { + var self = this; + var key = this._gen_key(action_id, additional_context || {}); + + if (config.isDebug('assets') || !this._cache.actions[key]) { + this._cache.actions[key] = rpc.query({ + route: "/web/action/load", + params: { + action_id: action_id, + additional_context: additional_context, + }, + }).then(function (action) { + self._cache.actions[key] = action.no_cache ? null : self._cache.actions[key]; + return action; + }).guardedCatch(() => this._invalidate('actions', key)); + } + + return this._cache.actions[key].then(function (action) { + return $.extend(true, {}, action); + }); + }, + + /** + * Loads various information concerning views: fields_view for each view, + * the fields of the corresponding model, and optionally the filters. + * + * @param {Object} params + * @param {String} params.model + * @param {Object} params.context + * @param {Array} params.views_descr array of [view_id, view_type] + * @param {Object} [options={}] dictionary of various options: + * - options.load_filters: whether or not to load the filters, + * - options.action_id: the action_id (required to load filters), + * - options.toolbar: whether or not a toolbar will be displayed, + * @return {Promise} resolved with the requested views information + */ + load_views: async function ({ model, context, views_descr } , options = {}) { + const viewsKey = this._gen_key(model, views_descr, options, context); + const filtersKey = this._gen_key(model, options.action_id); + const withFilters = Boolean(options.load_filters); + const shouldLoadViews = config.isDebug('assets') || !this._cache.views[viewsKey]; + const shouldLoadFilters = config.isDebug('assets') || ( + withFilters && !this._cache.filters[filtersKey] + ); + if (shouldLoadViews) { + // Views info should be loaded + options.load_filters = shouldLoadFilters; + this._cache.views[viewsKey] = rpc.query({ + args: [], + kwargs: { context, options, views: views_descr }, + model, + method: 'load_views', + }).then(result => { + // Freeze the fields dict as it will be shared between views and + // no one should edit it + utils.deepFreeze(result.fields); + for (const [viewId, viewType] of views_descr) { + const fvg = result.fields_views[viewType]; + fvg.viewFields = fvg.fields; + fvg.fields = result.fields; + } + + // Insert filters, if any, into the filters cache + if (shouldLoadFilters) { + this._cache.filters[filtersKey] = Promise.resolve(result.filters); + } + return result.fields_views; + }).guardedCatch(() => this._invalidate('views', viewsKey)); + } + const result = await this._cache.views[viewsKey]; + if (withFilters && result.search) { + if (shouldLoadFilters) { + await this.load_filters({ + actionId: options.action_id, + context, + forceReload: false, + modelName: model, + }); + } + result.search.favoriteFilters = await this._cache.filters[filtersKey]; + } + return result; + }, + + /** + * Loads the filters of a given model and optional action id. + * + * @param {Object} params + * @param {number} params.actionId + * @param {Object} params.context + * @param {boolean} [params.forceReload=true] can be set to false to prevent forceReload + * @param {string} params.modelName + * @return {Promise} resolved with the requested filters + */ + load_filters: function (params) { + const key = this._gen_key(params.modelName, params.actionId); + const forceReload = params.forceReload !== false && config.isDebug('assets'); + if (forceReload || !this._cache.filters[key]) { + this._cache.filters[key] = rpc.query({ + args: [params.modelName, params.actionId], + kwargs: { + context: params.context || {}, + // get_context() de dataset + }, + model: 'ir.filters', + method: 'get_filters', + }).guardedCatch(() => this._invalidate('filters', key)); + } + return this._cache.filters[key]; + }, + + /** + * Calls 'create_or_replace' on 'ir_filters'. + * + * @param {Object} [filter] the filter description + * @return {Promise} resolved with the id of the created or replaced filter + */ + create_filter: function (filter) { + return rpc.query({ + args: [filter], + model: 'ir.filters', + method: 'create_or_replace', + }) + .then(filterId => { + const filtersKey = this._gen_key(filter.model_id, filter.action_id); + this._invalidate('filters', filtersKey); + return filterId; + }); + }, + + /** + * Calls 'unlink' on 'ir_filters'. + * + * @param {integer} filterId Id of the filter to remove + * @return {Promise} + */ + delete_filter: function (filterId) { + return rpc.query({ + args: [filterId], + model: 'ir.filters', + method: 'unlink', + }) + // Invalidate the whole cache since we have no idea where the filter came from. + .then(() => this._invalidate('filters')); + }, + + /** + * Private function that generates a cache key from its arguments + */ + _gen_key: function () { + return _.map(Array.prototype.slice.call(arguments), function (arg) { + if (!arg) { + return false; + } + return _.isObject(arg) ? JSON.stringify(arg) : arg; + }).join(','); + }, + + /** + * Invalidate a cache entry or a whole cache section. + * + * @private + * @param {string} section + * @param {string} key + */ + _invalidate(section, key) { + if (key) { + delete this._cache[section][key]; + } else { + this._cache[section] = {}; + } + }, +}); + +}); + +odoo.define('web.data_manager', function (require) { +"use strict"; + +var DataManager = require('web.DataManager'); + +var data_manager = new DataManager(); + +return data_manager; + +}); diff --git a/addons/web/static/src/js/services/local_storage_service.js b/addons/web/static/src/js/services/local_storage_service.js new file mode 100644 index 00000000..694dc6da --- /dev/null +++ b/addons/web/static/src/js/services/local_storage_service.js @@ -0,0 +1,20 @@ +odoo.define('web.LocalStorageService', function (require) { +'use strict'; + +/** + * This module defines a service to access the localStorage object. + */ + +var AbstractStorageService = require('web.AbstractStorageService'); +var core = require('web.core'); +var localStorage = require('web.local_storage'); + +var LocalStorageService = AbstractStorageService.extend({ + storage: localStorage, +}); + +core.serviceRegistry.add('local_storage', LocalStorageService); + +return LocalStorageService; + +}); diff --git a/addons/web/static/src/js/services/notification_service.js b/addons/web/static/src/js/services/notification_service.js new file mode 100644 index 00000000..295b49d7 --- /dev/null +++ b/addons/web/static/src/js/services/notification_service.js @@ -0,0 +1,111 @@ +odoo.define('web.NotificationService', function (require) { +'use strict'; + +var AbstractService = require('web.AbstractService'); +var Notification = require('web.Notification'); +var core = require('web.core'); + +var id = 0; + +/** + * Notification Service + * + * The Notification Service is simply a service used to display notifications in + * the top/right part of the screen. + * + * If you want to display such a notification, you probably do not want to do it + * by using this file. The proper way is to use the do_warn or do_notify + * methods on the Widget class. + */ +var NotificationService = AbstractService.extend({ + custom_events: { + close: '_onCloseNotification', + }, + + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + this.notifications = {}; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * It may sometimes be useful to close programmatically a notification. For + * example, when there is a sticky notification warning the user about some + * condition (connection lost), but the condition does not apply anymore. + * + * @param {number} notificationId + * @param {boolean} [silent=false] if true, the notification does not call + * onClose callback + */ + close: function (notificationId, silent) { + var notification = this.notifications[notificationId]; + if (!notification) { + return; + } + notification.close(silent); + }, + /** + * Display a notification at the appropriate location, and returns the + * reference id to the same widget. + * + * Note that this method does not wait for the appendTo method to complete. + * + * @param {Object} params + * @param {function} [params.Notification] javascript class of a notification + * to instantiate by default use 'web.Notification' + * @param {string} params.title notification title + * @param {string} params.subtitle notification subtitle + * @param {string} params.message notification main message + * @param {string} params.type 'notification' or 'warning' + * @param {boolean} [params.sticky=false] if true, the notification will stay + * visible until the user clicks on it. + * @param {string} [params.className] className to add on the dom + * @param {function} [params.onClose] callback when the user click on the x + * or when the notification is auto close (no sticky) + * @param {Object[]} params.buttons + * @param {function} params.buttons[0].click callback on click + * @param {Boolean} [params.buttons[0].primary] display the button as primary + * @param {string} [params.buttons[0].text] button label + * @param {string} [params.buttons[0].icon] font-awsome className or image src + * @returns {Number} notification id + */ + notify: function (params) { + if (!this.$el) { + this.$el = $('<div class="o_notification_manager"/>'); + this.$el.prependTo('body'); + } + var NotificationWidget = params.Notification || Notification; + var notification = this.notifications[++id] = new NotificationWidget(this, params); + notification.appendTo(this.$el); + return id; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onCloseNotification: function (ev) { + ev.stopPropagation(); + for (var notificationId in this.notifications) { + if (this.notifications[notificationId] === ev.target) { + delete this.notifications[notificationId]; + break; + } + } + }, +}); + +core.serviceRegistry.add('notification', NotificationService); + +return NotificationService; +}); diff --git a/addons/web/static/src/js/services/report_service.js b/addons/web/static/src/js/services/report_service.js new file mode 100644 index 00000000..ed4907a6 --- /dev/null +++ b/addons/web/static/src/js/services/report_service.js @@ -0,0 +1,35 @@ +odoo.define('web.ReportService', function (require) { +"use strict"; + +/** + * This file defines the service for the report generation in Odoo. + */ + +var AbstractService = require('web.AbstractService'); +var core = require('web.core'); + +var ReportService = AbstractService.extend({ + dependencies: ['ajax'], + + /** + * Checks the state of the installation of wkhtmltopdf on the server. + * Implements an internal cache to do the request only once. + * + * @returns {Promise} resolved with the state of wkhtmltopdf on the server + * (possible values are 'ok', 'broken', 'install', 'upgrade', 'workers'). + */ + checkWkhtmltopdf: function () { + if (!this.wkhtmltopdfState) { + this.wkhtmltopdfState = this._rpc({ + route:'/report/check_wkhtmltopdf' + }); + } + return this.wkhtmltopdfState; + }, +}); + +core.serviceRegistry.add('report', ReportService); + +return ReportService; + +}); diff --git a/addons/web/static/src/js/services/session.js b/addons/web/static/src/js/services/session.js new file mode 100644 index 00000000..35720f81 --- /dev/null +++ b/addons/web/static/src/js/services/session.js @@ -0,0 +1,12 @@ +odoo.define('web.session', function (require) { +"use strict"; + +var Session = require('web.Session'); +var modules = odoo._modules; + +var session = new Session(undefined, undefined, {modules: modules, use_cors: false}); +session.is_bound = session.session_bind(); + +return session; + +}); diff --git a/addons/web/static/src/js/services/session_storage_service.js b/addons/web/static/src/js/services/session_storage_service.js new file mode 100644 index 00000000..41c47af3 --- /dev/null +++ b/addons/web/static/src/js/services/session_storage_service.js @@ -0,0 +1,20 @@ +odoo.define('web.SessionStorageService', function (require) { +'use strict'; + +/** + * This module defines a service to access the sessionStorage object. + */ + +var AbstractStorageService = require('web.AbstractStorageService'); +var core = require('web.core'); +var sessionStorage = require('web.sessionStorage'); + +var SessionStorageService = AbstractStorageService.extend({ + storage: sessionStorage, +}); + +core.serviceRegistry.add('session_storage', SessionStorageService); + +return SessionStorageService; + +}); diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js new file mode 100644 index 00000000..b0560805 --- /dev/null +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -0,0 +1,127 @@ +odoo.define('web.DebugManager', function (require) { +"use strict"; + +var core = require('web.core'); +var session = require('web.session'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +/** + * DebugManager base + general features (applicable to any context) + */ +var DebugManager = Widget.extend({ + template: "WebClient.DebugManager", + xmlDependencies: ['/web/static/src/xml/debug.xml'], + events: { + "click a[data-action]": "perform_callback", + }, + init: function () { + this._super.apply(this, arguments); + this._events = null; + var debug = odoo.debug; + this.debug_mode = debug; + this.debug_mode_help = debug && debug !== '1' ? ' (' + debug + ')' : ''; + }, + start: function () { + core.bus.on('rpc:result', this, function (req, resp) { + this._debug_events(resp.debug); + }); + + this.$dropdown = this.$(".o_debug_dropdown"); + // whether the current user is an administrator + this._is_admin = session.is_system; + return Promise.resolve( + this._super() + ).then(function () { + return this.update(); + }.bind(this)); + }, + /** + * Calls the appropriate callback when clicking on a Debug option + */ + perform_callback: function (evt) { + evt.preventDefault(); + var params = $(evt.target).data(); + var callback = params.action; + + if (callback && this[callback]) { + // Perform the callback corresponding to the option + this[callback](params, evt); + } else { + console.warn("No handler for ", callback); + } + }, + + _debug_events: function (events) { + if (!this._events) { + return; + } + if (events && events.length) { + this._events.push(events); + } + this.trigger('update-stats', this._events); + }, + + /** + * Update the debug manager: reinserts all "universal" controls + */ + update: function () { + this.$dropdown + .empty() + .append(QWeb.render('WebClient.DebugManager.Global', { + manager: this, + })); + return Promise.resolve(); + }, + split_assets: function () { + window.location = $.param.querystring(window.location.href, 'debug=assets'); + }, + tests_assets: function () { + // Enable also 'assets' to see non minimized assets + window.location = $.param.querystring(window.location.href, 'debug=assets,tests'); + }, + /** + * Delete assets bundles to force their regeneration + * + * @returns {void} + */ + regenerateAssets: function () { + var self = this; + var domain = utils.assetsDomain(); + this._rpc({ + model: 'ir.attachment', + method: 'search', + args: [domain], + }).then(function (ids) { + self._rpc({ + model: 'ir.attachment', + method: 'unlink', + args: [ids], + }).then(window.location.reload()); + }); + }, + leave_debug_mode: function () { + var qs = $.deparam.querystring(); + qs.debug = ''; + window.location.search = '?' + $.param(qs); + }, + /** + * @private + * @param {string} model + * @param {string} operation + * @returns {Promise<boolean>} + */ + _checkAccessRight(model, operation) { + return this._rpc({ + model: model, + method: 'check_access_rights', + kwargs: {operation, raise_exception: false}, + }) + }, +}); + +return DebugManager; + +}); diff --git a/addons/web/static/src/js/tools/debug_manager_backend.js b/addons/web/static/src/js/tools/debug_manager_backend.js new file mode 100644 index 00000000..2dd8fc0d --- /dev/null +++ b/addons/web/static/src/js/tools/debug_manager_backend.js @@ -0,0 +1,807 @@ +odoo.define('web.DebugManager.Backend', function (require) { +"use strict"; + +var ActionManager = require('web.ActionManager'); +var DebugManager = require('web.DebugManager'); +var dialogs = require('web.view_dialogs'); +var startClickEverywhere = require('web.clickEverywhere'); +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var field_utils = require('web.field_utils'); +var SystrayMenu = require('web.SystrayMenu'); +var utils = require('web.utils'); +var WebClient = require('web.WebClient'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +/** + * DebugManager features depending on backend + */ +DebugManager.include({ + requests_clear: function () { + if (!this._events) { + return; + } + this._events = []; + this.trigger('update-stats', this._events); + }, + show_timelines: function () { + if (this._overlay) { + this._overlay.destroy(); + this._overlay = null; + return; + } + this._overlay = new RequestsOverlay(this); + this._overlay.appendTo(document.body); + }, + + /** + * Updates current action (action descriptor) on tag = action, + */ + update: function (tag, descriptor) { + return this._super().then(function () { + this.$dropdown.find(".o_debug_split_assets").before(QWeb.render('WebClient.DebugManager.Backend', { + manager: this, + })); + }.bind(this)); + }, + select_view: function () { + var self = this; + new dialogs.SelectCreateDialog(this, { + res_model: 'ir.ui.view', + title: _t('Select a view'), + disable_multiple_selection: true, + domain: [['type', '!=', 'qweb'], ['type', '!=', 'search']], + on_selected: function (records) { + self._rpc({ + model: 'ir.ui.view', + method: 'search_read', + domain: [['id', '=', records[0].id]], + fields: ['name', 'model', 'type'], + limit: 1, + }) + .then(function (views) { + var view = views[0]; + view.type = view.type === 'tree' ? 'list' : view.type; // ignore tree view + self.do_action({ + type: 'ir.actions.act_window', + name: view.name, + res_model: view.model, + views: [[view.id, view.type]] + }); + }); + } + }).open(); + }, + /** + * Runs the JS (desktop) tests + */ + perform_js_tests: function () { + this.do_action({ + name: _t("JS Tests"), + target: 'new', + type: 'ir.actions.act_url', + url: '/web/tests?mod=*' + }); + }, + /** + * Runs the JS mobile tests + */ + perform_js_mobile_tests: function () { + this.do_action({ + name: _t("JS Mobile Tests"), + target: 'new', + type: 'ir.actions.act_url', + url: '/web/tests/mobile?mod=*' + }); + }, + perform_click_everywhere_test: function () { + var $homeMenu = $("nav.o_main_navbar > a.o_menu_toggle.fa-th"); + $homeMenu.click(); + startClickEverywhere(); + }, +}); + +/** + * DebugManager features depending on having an action, and possibly a model + * (window action) + */ +DebugManager.include({ + async start() { + const [_, canSeeRecordRules, canSeeModelAccess] = await Promise.all([ + this._super(...arguments), + this._checkAccessRight('ir.rule', 'read'), + this._checkAccessRight('ir.model.access', 'read'), + ]) + this.canSeeRecordRules = canSeeRecordRules; + this.canSeeModelAccess = canSeeModelAccess; + }, + /** + * Return the ir.model id from the model name + * @param {string} modelName + */ + async getModelId(modelName) { + const [modelId] = await this._rpc({ + model: 'ir.model', + method: 'search', + args: [[['model', '=', modelName]]], + kwargs: { limit: 1}, + }); + return modelId + }, + /** + * Updates current action (action descriptor) on tag = action, + */ + update: function (tag, descriptor) { + if (tag === 'action') { + this._action = descriptor; + } + return this._super().then(function () { + this.$dropdown.find(".o_debug_leave_section").before(QWeb.render('WebClient.DebugManager.Action', { + manager: this, + action: this._action + })); + }.bind(this)); + }, + edit: function (params, evt) { + this.do_action({ + res_model: params.model, + res_id: params.id, + name: evt.target.text, + type: 'ir.actions.act_window', + views: [[false, 'form']], + view_mode: 'form', + target: 'new', + flags: {action_buttons: true, headless: true} + }); + }, + async get_view_fields () { + const modelId = await this.getModelId(this._action.res_model); + this.do_action({ + res_model: 'ir.model.fields', + name: _t('View Fields'), + views: [[false, 'list'], [false, 'form']], + domain: [['model_id', '=', modelId]], + type: 'ir.actions.act_window', + context: { + 'default_model_id': modelId + } + }); + }, + manage_filters: function () { + this.do_action({ + res_model: 'ir.filters', + name: _t('Manage Filters'), + views: [[false, 'list'], [false, 'form']], + type: 'ir.actions.act_window', + context: { + search_default_my_filters: true, + search_default_model_id: this._action.res_model + } + }); + }, + translate: function() { + this._rpc({ + model: 'ir.translation', + method: 'get_technical_translations', + args: [this._action.res_model], + }) + .then(this.do_action); + }, + async actionRecordRules() { + const modelId = await this.getModelId(this._action.res_model); + this.do_action({ + res_model: 'ir.rule', + name: _t('Model Record Rules'), + views: [[false, 'list'], [false, 'form']], + domain: [['model_id', '=', modelId]], + type: 'ir.actions.act_window', + context: { + 'default_model_id': modelId, + }, + }); + }, + async actionModelAccess() { + const modelId = await this.getModelId(this._action.res_model); + this.do_action({ + res_model: 'ir.model.access', + name: _t('Model Access'), + views: [[false, 'list'], [false, 'form']], + domain: [['model_id', '=', modelId]], + type: 'ir.actions.act_window', + context: { + 'default_model_id': modelId, + }, + }); + }, +}); + +/** + * DebugManager features depending on having a form view or single record. + * These could theoretically be split, but for now they'll be considered one + * and the same. + */ +DebugManager.include({ + start: function () { + this._can_edit_views = false; + return Promise.all([ + this._super(), + this._checkAccessRight('ir.ui.view', 'write') + .then(function (ar) { + this._can_edit_views = ar; + }.bind(this)) + ] + ); + }, + update: function (tag, descriptor, widget) { + if (tag === 'action' || tag === 'view') { + this._controller = widget; + } + return this._super(tag, descriptor).then(function () { + this.$dropdown.find(".o_debug_leave_section").before(QWeb.render('WebClient.DebugManager.View', { + action: this._action, + can_edit: this._can_edit_views, + controller: this._controller, + withControlPanel: this._controller && this._controller.withControlPanel, + manager: this, + view: this._controller && _.findWhere(this._action.views, { + type: this._controller.viewType, + }), + })); + }.bind(this)); + }, + get_attachments: function() { + var selectedIDs = this._controller.getSelectedIds(); + if (!selectedIDs.length) { + console.warn(_t("No attachment available")); + return; + } + this.do_action({ + res_model: 'ir.attachment', + name: _t('Manage Attachments'), + views: [[false, 'list'], [false, 'form']], + type: 'ir.actions.act_window', + domain: [['res_model', '=', this._action.res_model], ['res_id', '=', selectedIDs[0]]], + context: { + default_res_model: this._action.res_model, + default_res_id: selectedIDs[0], + }, + }); + }, + get_metadata: function() { + var self = this; + var selectedIDs = this._controller.getSelectedIds(); + if (!selectedIDs.length) { + console.warn(_t("No metadata available")); + return; + } + this._rpc({ + model: this._action.res_model, + method: 'get_metadata', + args: [selectedIDs], + }).then(function(result) { + var metadata = result[0]; + metadata.creator = field_utils.format.many2one(metadata.create_uid); + metadata.lastModifiedBy = field_utils.format.many2one(metadata.write_uid); + var createDate = field_utils.parse.datetime(metadata.create_date); + metadata.create_date = field_utils.format.datetime(createDate); + var modificationDate = field_utils.parse.datetime(metadata.write_date); + metadata.write_date = field_utils.format.datetime(modificationDate); + var dialog = new Dialog(this, { + title: _.str.sprintf(_t("Metadata (%s)"), self._action.res_model), + size: 'medium', + $content: QWeb.render('WebClient.DebugViewLog', { + perm : metadata, + }) + }); + dialog.open().opened(function () { + dialog.$el.on('click', 'a[data-action="toggle_noupdate"]', function (ev) { + ev.preventDefault(); + self._rpc({ + model: 'ir.model.data', + method: 'toggle_noupdate', + args: [self._action.res_model, metadata.id] + }).then(function (res) { + dialog.close(); + self.get_metadata(); + }) + }); + }) + }); + }, + set_defaults: function() { + var self = this; + + var display = function (fieldInfo, value) { + var displayed = value; + if (value && fieldInfo.type === 'many2one') { + displayed = value.data.display_name; + value = value.data.id; + } else if (value && fieldInfo.type === 'selection') { + displayed = _.find(fieldInfo.selection, function (option) { + return option[0] === value; + })[1]; + } + return [value, displayed]; + }; + + var renderer = this._controller.renderer; + var state = renderer.state; + var fields = state.fields; + var fieldsInfo = state.fieldsInfo.form; + var fieldNamesInView = state.getFieldNames(); + var fieldNamesOnlyOnView = ['message_attachment_count']; + var fieldsValues = state.data; + var modifierDatas = {}; + _.each(fieldNamesInView, function (fieldName) { + modifierDatas[fieldName] = _.find(renderer.allModifiersData, function (modifierdata) { + return modifierdata.node.attrs.name === fieldName; + }); + }); + this.fields = _.chain(fieldNamesInView) + .difference(fieldNamesOnlyOnView) + .map(function (fieldName) { + var modifierData = modifierDatas[fieldName]; + var invisibleOrReadOnly; + if (modifierData) { + var evaluatedModifiers = modifierData.evaluatedModifiers[state.id]; + invisibleOrReadOnly = evaluatedModifiers.invisible || evaluatedModifiers.readonly; + } + var fieldInfo = fields[fieldName]; + var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]); + var value = valueDisplayed[0]; + var displayed = valueDisplayed[1]; + // ignore fields which are empty, invisible, readonly, o2m + // or m2m + if (!value || invisibleOrReadOnly || fieldInfo.type === 'one2many' || + fieldInfo.type === 'many2many' || fieldInfo.type === 'binary' || + fieldsInfo[fieldName].options.isPassword || !_.isEmpty(fieldInfo.depends)) { + return false; + } + return { + name: fieldName, + string: fieldInfo.string, + value: value, + displayed: displayed, + }; + }) + .compact() + .sortBy(function (field) { return field.string; }) + .value(); + + var conditions = _.chain(fieldNamesInView) + .filter(function (fieldName) { + var fieldInfo = fields[fieldName]; + return fieldInfo.change_default; + }) + .map(function (fieldName) { + var fieldInfo = fields[fieldName]; + var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]); + var value = valueDisplayed[0]; + var displayed = valueDisplayed[1]; + return { + name: fieldName, + string: fieldInfo.string, + value: value, + displayed: displayed, + }; + }) + .value(); + var d = new Dialog(this, { + title: _t("Set Default"), + buttons: [ + {text: _t("Close"), close: true}, + {text: _t("Save default"), click: function () { + var $defaults = d.$el.find('#formview_default_fields'); + var fieldToSet = $defaults.val(); + if (!fieldToSet) { + $defaults.parent().addClass('o_form_invalid'); + return; + } + var selfUser = d.$el.find('#formview_default_self').is(':checked'); + var condition = d.$el.find('#formview_default_conditions').val(); + var value = _.find(self.fields, function (field) { + return field.name === fieldToSet; + }).value; + self._rpc({ + model: 'ir.default', + method: 'set', + args: [ + self._action.res_model, + fieldToSet, + value, + selfUser, + true, + condition || false, + ], + }).then(function () { d.close(); }); + }} + ] + }); + d.args = { + fields: this.fields, + conditions: conditions, + }; + d.template = 'FormView.set_default'; + d.open(); + }, + fvg: function() { + var self = this; + var dialog = new Dialog(this, { title: _t("Fields View Get") }); + dialog.opened().then(function () { + $('<pre>').text(utils.json_node_to_xml( + self._controller.renderer.arch, true) + ).appendTo(dialog.$el); + }); + dialog.open(); + }, +}); +function make_context(width, height, fn) { + var canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + // make e.layerX/e.layerY imitate e.offsetX/e.offsetY. + canvas.style.position = 'relative'; + var ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + ctx.mozImageSmoothingEnabled = false; + ctx.oImageSmoothingEnabled = false; + ctx.webkitImageSmoothingEnabled = false; + fn && fn(ctx); + return ctx; +} +var RequestsOverlay = Widget.extend({ + template: 'WebClient.DebugManager.RequestsOverlay', + TRACKS: 8, + TRACK_WIDTH: 9, + events: { + mousemove: function (e) { + this.$tooltip.hide(); + } + }, + init: function () { + this._super.apply(this, arguments); + this._render = _.throttle( + this._render.bind(this), + 1000/15, {leading: false} + ); + }, + start: function () { + var _super = this._super(); + this.$tooltip = this.$('div.o_debug_tooltip'); + this.getParent().on('update-stats', this, this._render); + this._render(); + return _super; + }, + tooltip: function (text, start, end, x, y) { + // x and y are hit point with respect to the viewport. To know where + // this hit point is with respect to the overlay, subtract the offset + // between viewport and overlay, then add scroll factor of overlay + // (which isn't taken in account by the viewport). + // + // Normally the viewport overlay should sum offsets of all + // offsetParents until we reach `null` but in this case the overlay + // should have been added directly to the body, which should have an + // offset of 0. + + var top = y - this.el.offsetTop + this.el.scrollTop + 1; + var left = x - this.el.offsetLeft + this.el.scrollLeft + 1; + this.$tooltip.css({top: top, left: left}).show()[0].innerHTML = ['<p>', text, ' (', (end - start), 'ms)', '</p>'].join(''); + }, + + _render: function () { + var $summary = this.$('header'), + w = $summary[0].clientWidth, + $requests = this.$('.o_debug_requests'); + $summary.find('canvas').attr('width', w); + var tracks = document.getElementById('o_debug_requests_summary'); + + _.invoke(this.getChildren(), 'destroy'); + + var requests = this.getParent()._events; + var bounds = this._get_bounds(requests); + // horizontal scaling factor for summary + var scale = w / (bounds.high - bounds.low); + + // store end-time of "current" requests, to find out which track a + // request should go in, just look for the first track whose end-time + // is smaller than the new request's start time. + var track_ends = _(this.TRACKS).times(_.constant(-Infinity)); + + var ctx = tracks.getContext('2d'); + ctx.lineWidth = this.TRACK_WIDTH; + for (var i = 0; i < requests.length; i++) { + var request = requests[i]; + // FIXME: is it certain that events in the request are sorted by timestamp? + var rstart = Math.floor(request[0][3] * 1e3); + var rend = Math.ceil(request[request.length - 1][3] * 1e3); + // find free track for current request + for(var track=0; track < track_ends.length; ++track) { + if (track_ends[track] < rstart) { break; } + } + // FIXME: display error message of some sort? Re-render with larger area? Something? + if (track >= track_ends.length) { + console.warn("could not find an empty summary track"); + continue; + } + // set new track end + track_ends[track] = rend; + ctx.save(); + ctx.translate(Math.floor((rstart - bounds.low) * scale), track * (this.TRACK_WIDTH + 1)); + this._draw_request(request, ctx, 0, scale); + ctx.restore(); + new RequestDetails(this, request, scale).appendTo($requests); + } + }, + _draw_request: function (request, to_context, step, hscale, handle_event) { + // have one draw surface for each event type: + // * no need to alter context from one event to the next, each surface + // gets its own color for all its lifetime + // * surfaces can be blended in a specified order, which means events + // can be drawn in any order, no need to care about z-index while + // serializing events to the surfaces + var surfaces = { + request: make_context(to_context.canvas.width, to_context.canvas.height, function (ctx) { + ctx.strokeStyle = 'blue'; + ctx.fillStyle = '#88f'; + ctx.lineJoin = 'round'; + ctx.lineWidth = 1; + }), + //func: make_context(to_context.canvas.width, to_context.canvas.height, function (ctx) { + // ctx.strokeStyle = 'gray'; + // ctx.lineWidth = to_context.lineWidth; + // ctx.translate(0, initial_offset); + //}), + sql: make_context(to_context.canvas.width, to_context.canvas.height, function (ctx) { + ctx.strokeStyle = 'red'; + ctx.fillStyle = '#f88'; + ctx.lineJoin = 'round'; + ctx.lineWidth = 1; + }), + template: make_context(to_context.canvas.width, to_context.canvas.height, function (ctx) { + ctx.strokeStyle = 'green'; + ctx.fillStyle = '#8f8'; + ctx.lineJoin = 'round'; + ctx.lineWidth = 1; + }) + }; + // apply scaling manually so zooming in improves display precision + var stacks = {}, start = Math.floor(request[0][3] * 1e3 * hscale); + var event_idx = 0; + + var rect_width = to_context.lineWidth; + for (var i = 0; i < request.length; i++) { + var type, m, event = request[i]; + var tag = event[0], timestamp = Math.floor(event[3] * 1e3 * hscale) - start; + + if (m = /(\w+)-start/.exec(tag)) { + type = m[1]; + if (!(type in stacks)) { stacks[type] = []; } + handle_event && handle_event(event_idx, timestamp, event); + stacks[type].push({ + timestamp: timestamp, + idx: event_idx++ + }); + } else if (m = /(\w+)-end/.exec(tag)) { + type = m[1]; + var stack = stacks[type]; + var estart = stack.pop(), duration = Math.ceil(timestamp - estart.timestamp); + handle_event && handle_event(estart.idx, timestamp, event); + + var surface = surfaces[type]; + if (!surface) { continue; } // FIXME: support for unknown event types + + var y = step * estart.idx; + // path rectangle for the current event on the relevant surface + surface.rect(estart.timestamp + 0.5, y + 0.5, duration || 1, rect_width); + } + } + // add each layer to the main canvas + var keys = ['request', /*'func', */'template', 'sql']; + for (var j = 0; j < keys.length; ++j) { + // stroke and fill all rectangles for the relevant surface/context + var ctx = surfaces[keys[j]]; + ctx.fill(); + ctx.stroke(); + to_context.drawImage(ctx.canvas, 0, 0); + } + }, + /** + * Returns first and last events in milliseconds + * + * @param requests + * @returns {{low: number, high: number}} + * @private + */ + _get_bounds: function (requests) { + var low = +Infinity; + var high =-+Infinity; + + for (var i = 0; i < requests.length; i++) { + var request = requests[i]; + for (var j = 0; j < request.length; j++) { + var event = request[j]; + var timestamp = event[3]; + low = Math.min(low, timestamp); + high = Math.max(high, timestamp); + } + } + return {low: Math.floor(low * 1e3), high: Math.ceil(high * 1e3)}; + } +}); +var RequestDetails = Widget.extend({ + events: { + click: function () { + this._open = !this._open; + this.render(); + }, + 'mousemove canvas': function (e) { + e.stopPropagation(); + var y = e.y || e.offsetY || e.layerY; + if (!y) { return; } + var event = this._payloads[Math.floor(y / this._REQ_HEIGHT)]; + if (!event) { return; } + + this.getParent().tooltip(event.payload, event.start, event.stop, e.clientX, e.clientY); + } + }, + init: function (parent, request, scale) { + this._super.apply(this, arguments); + this._request = request; + this._open = false; + this._scale = scale; + this._REQ_HEIGHT = 20; + }, + start: function () { + this.el.style.borderBottom = '1px solid black'; + this.render(); + return this._super(); + }, + render: function () { + var request_cell_height = this._REQ_HEIGHT, TITLE_WIDTH = 200; + var request = this._request; + var req_start = request[0][3] * 1e3; + var req_duration = request[request.length - 1][3] * 1e3 - req_start; + var height = request_cell_height * (this._open ? request.length / 2 : 1); + var cell_center = request_cell_height / 2; + var ctx = make_context(210 + Math.ceil(req_duration * this._scale), height, function (ctx) { + ctx.lineWidth = cell_center; + }); + this.$el.empty().append(ctx.canvas); + var payloads = this._payloads = []; + + // lazy version: if the render is single-line (!this._open), the extra + // content will be discarded when the text canvas gets pasted onto the + // main canvas. An improvement would be to not do text rendering + // beyond the first event for "closed" requests events… then again + // that makes for more regular rendering profile? + var text_ctx = make_context(TITLE_WIDTH, height, function (ctx) { + ctx.font = '12px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.translate(0, cell_center); + }); + + ctx.save(); + ctx.translate(TITLE_WIDTH + 10, ((request_cell_height/4)|0)); + + this.getParent()._draw_request(request, ctx, this._open ? request_cell_height : 0, this._scale, function (idx, timestamp, event) { + if (/-start$/g.test(event[0])) { + payloads.push({ + payload: event[2], + start: timestamp, + stop: null + }); + + // we want ~200px wide, assume the average character is at + // least 4px wide => there can be *at most* 49 characters + var title = event[2]; + title = title.replace(/\s+$/, ''); + title = title.length <= 50 ? title : ('…' + title.slice(-49)); + while (text_ctx.measureText(title).width > 200) { + title = '…' + title.slice(2); + } + text_ctx.fillText(title, TITLE_WIDTH, request_cell_height * idx); + } else if (/-end$/g.test(event[0])) { + payloads[idx].stop = timestamp; + } + }); + ctx.restore(); + // add the text layer to the main canvas + ctx.drawImage(text_ctx.canvas, 0, 0); + } +}); + +if (config.isDebug()) { + SystrayMenu.Items.push(DebugManager); + + WebClient.include({ + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + */ + current_action_updated: function (action, controller) { + this._super.apply(this, arguments); + this.update_debug_manager(action, controller); + }, + update_debug_manager: function(action, controller) { + var debugManager = _.find(this.menu.systray_menu.widgets, function(item) { + return item instanceof DebugManager; + }); + debugManager.update('action', action, controller && controller.widget); + } + }); + + ActionManager.include({ + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns the action of the controller currently opened in a dialog, + * i.e. a target='new' action, if any. + * + * @returns {Object|null} + */ + getCurrentActionInDialog: function () { + if (this.currentDialogController) { + return this.actions[this.currentDialogController.actionID]; + } + return null; + }, + /** + * Returns the controller currently opened in a dialog, if any. + * + * @returns {Object|null} + */ + getCurrentControllerInDialog: function () { + return this.currentDialogController; + }, + }); + + Dialog.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + open: function() { + var self = this; + // if the dialog is opened by the ActionManager, instantiate a + // DebugManager and insert it into the DOM once the dialog is opened + // (delay this with a setTimeout(0) to ensure that the internal + // state, i.e. the current action and controller, of the + // ActionManager is set to properly update the DebugManager) + this.opened(function() { + setTimeout(function () { + var parent = self.getParent(); + if (parent instanceof ActionManager) { + var action = parent.getCurrentActionInDialog(); + if (action) { + var controller = parent.getCurrentControllerInDialog(); + self.debugManager = new DebugManager(self); + var $header = self.$modal.find('.modal-header:first'); + return self.debugManager.prependTo($header).then(function () { + self.debugManager.update('action', action, controller.widget); + }); + } + } + }, 0); + }); + + return this._super.apply(this, arguments); + }, + }); +} + +return DebugManager; + +}); diff --git a/addons/web/static/src/js/tools/test_menus.js b/addons/web/static/src/js/tools/test_menus.js new file mode 100644 index 00000000..9112b7f5 --- /dev/null +++ b/addons/web/static/src/js/tools/test_menus.js @@ -0,0 +1,321 @@ +(function (exports) { + /** + * The purpose of this test is to click on every installed App and then + * open each view. On each view, click on each filter. + */ + "use strict"; + var clientActionCount = 0; + var viewUpdateCount = 0; + var testedApps; + var testedMenus; + var blackListedMenus = ['base.menu_theme_store', 'base.menu_third_party', 'account.menu_action_account_bank_journal_form', 'pos_adyen.menu_pos_adyen_account']; + var appsMenusOnly = false; + let isEnterprise = odoo.session_info.server_version_info[5] === 'e'; + + function createWebClientHooks() { + var AbstractController = odoo.__DEBUG__.services['web.AbstractController']; + var DiscussWidget = odoo.__DEBUG__.services['mail/static/src/widgets/discuss/discuss.js']; + var WebClient = odoo.__DEBUG__.services["web.WebClient"]; + + WebClient.include({ + current_action_updated : function (action, controller) { + this._super(action, controller); + clientActionCount++; + }, + }); + + AbstractController.include({ + start: function(){ + this.$el.attr('data-view-type', this.viewType); + return this._super.apply(this, arguments); + }, + update: function(params, options) { + return this._super(params, options).then(function (){ + viewUpdateCount++; + }); + }, + }); + + if (DiscussWidget) { + DiscussWidget.include({ + /** + * Overriding a method that is called every time the discuss + * component is updated. + */ + _updateControlPanel: async function () { + await this._super(...arguments); + viewUpdateCount++; + }, + }); + } + } + + function clickEverywhere(xmlId, light){ + appsMenusOnly = light; + setTimeout(_clickEverywhere, 1000, xmlId); + } + + // Main function that starts orchestration of tests + async function _clickEverywhere(xmlId){ + console.log("Starting ClickEverywhere test"); + var startTime = performance.now(); + createWebClientHooks(); + testedApps = []; + testedMenus = []; + // finding applications menus + let appMenuItems; + if (isEnterprise) { + console.log("Odoo flavor: Enterprise"); + appMenuItems = document.querySelectorAll(xmlId ? + `a.o_app.o_menuitem[data-menu-xmlid="${xmlId}"]` : + 'a.o_app.o_menuitem' + ); + } else { + console.log("Odoo flavor: Community"); + appMenuItems = document.querySelectorAll(xmlId ? + `a.o_app[data-menu-xmlid="${xmlId}"]` : + 'a.o_app' + ); + } + console.log("Found", appMenuItems.length, "apps to test"); + try { + for (const app of appMenuItems) { + await testApp(app); + } + console.log("Test took", (performance.now() - startTime) / 1000, "seconds"); + console.log("Successfully tested", testedApps.length, " apps"); + console.log("Successfully tested", testedMenus.length - testedApps.length, "menus"); + console.log("test successful"); + } catch (err) { + console.log("Test took", (performance.now() - startTime) / 1000, "seconds"); + console.error(err || "test failed"); + } + } + + + /** + * Test an "App" menu item by orchestrating the following actions: + * 1 - clicking on its menuItem + * 2 - clicking on each view + * 3 - clicking on each menu + * 3.1 - clicking on each view + * @param {DomElement} element: the App menu item + * @returns {Promise} + */ + async function testApp(element) { + console.log("Testing app menu:", element.dataset.menuXmlid); + if (testedApps.indexOf(element.dataset.menuXmlid) >= 0) return; // Another infinite loop protection + testedApps.push(element.dataset.menuXmlid); + if (isEnterprise) { + await ensureHomeMenu(); + } + await testMenuItem(element); + if (appsMenusOnly === true) return; + const subMenuItems = document.querySelectorAll('.o_menu_entry_lvl_1, .o_menu_entry_lvl_2, .o_menu_entry_lvl_3, .o_menu_entry_lvl_4'); + for (const subMenuItem of subMenuItems) { + await testMenuItem(subMenuItem); + } + if (isEnterprise) { + await ensureHomeMenu(); + } + } + + + /** + * Test a menu item by: + * 1 - clikcing on the menuItem + * 2 - Orchestrate the view switch + * + * @param {DomElement} element: the menu item + * @returns {Promise} + */ + async function testMenuItem(element){ + if (testedMenus.indexOf(element.dataset.menuXmlid) >= 0) return Promise.resolve(); // Avoid infinite loop + var menuDescription = element.innerText.trim() + " " + element.dataset.menuXmlid; + var menuTimeLimit = 10000; + console.log("Testing menu", menuDescription); + testedMenus.push(element.dataset.menuXmlid); + if (blackListedMenus.includes(element.dataset.menuXmlid)) return Promise.resolve(); // Skip black listed menus + if (element.innerText.trim() == 'Settings') menuTimeLimit = 20000; + var startActionCount = clientActionCount; + await triggerClick(element, `menu item "${element.innerText.trim()}"`); + var isModal = false; + return waitForCondition(function () { + // sometimes, the app is just a modal that needs to be closed + var $modal = $('.modal[role="dialog"][open="open"]'); + if ($modal.length > 0) { + const closeButton = document.querySelector('header > button.close'); + if (closeButton) { + closeButton.focus(); + triggerClick(closeButton, "modal close button"); + } else { $modal.modal('hide'); } + isModal = true; + return true; + } + return startActionCount !== clientActionCount; + }, menuTimeLimit).then(function() { + if (!isModal) { + return testFilters(); + } + }).then(function () { + if (!isModal) { + return testViews(); + } + }).catch(function (err) { + console.error("Error while testing", menuDescription); + return Promise.reject(err); + }); + }; + + + /** + * Orchestrate the test of views + * This function finds the buttons that permit to switch views and orchestrate + * the click on each of them + * @returns {Promise} + */ + async function testViews() { + if (appsMenusOnly === true) { + return; + } + const switchButtons = document.querySelectorAll('nav.o_cp_switch_buttons > button.o_switch_view:not(.active):not(.o_map)'); + for (const switchButton of switchButtons) { + // Only way to get the viewType from the switchButton + const viewType = [...switchButton.classList] + .find(cls => cls !== 'o_switch_view' && cls.startsWith('o_')) + .slice(2); + console.log("Testing view switch:", viewType); + // timeout to avoid click debounce + setTimeout(function () { + const target = document.querySelector(`nav.o_cp_switch_buttons > button.o_switch_view.o_${viewType}`); + if (target) { + triggerClick(target, `${viewType} view switcher`); + } + }, 250); + await waitForCondition(() => document.querySelector('.o_action_manager > .o_action.o_view_controller').dataset.viewType === viewType); + await testFilters(); + } + } + + /** + * Test filters + * Click on each filter in the control pannel + */ + async function testFilters() { + if (appsMenusOnly === true) { + return; + } + const filterMenuButton = document.querySelector('.o_control_panel .o_filter_menu > button'); + if (!filterMenuButton) { + return; + } + // Open the filter menu dropdown + await triggerClick(filterMenuButton, `toggling menu "${filterMenuButton.innerText.trim()}"`); + + const filterMenuItems = document.querySelectorAll('.o_control_panel .o_filter_menu > ul > li.o_menu_item'); + console.log("Testing", filterMenuItems.length, "filters"); + + for (const filter of filterMenuItems) { + const currentViewCount = viewUpdateCount; + const filterLink = filter.querySelector('a'); + await triggerClick(filterLink, `filter "${filter.innerText.trim()}"`); + if (filterLink.classList.contains('o_menu_item_parent')) { + // If a fitler has options, it will simply unfold and show all options. + // We then click on the first one. + const firstOption = filter.querySelector('.o_menu_item_options > li.o_item_option > a'); + console.log(); + await triggerClick(firstOption, `filter option "${firstOption.innerText.trim()}"`); + } + await waitForCondition(() => currentViewCount !== viewUpdateCount); + } + } + + // utility functions + /** + * Wait a certain amount of time for a condition to occur + * @param {function} stopCondition a function that returns a boolean + * @returns {Promise} that is rejected if the timeout is exceeded + */ + function waitForCondition(stopCondition, tl=10000) { + var prom = new Promise(function (resolve, reject) { + var interval = 250; + var timeLimit = tl; + + function checkCondition() { + if (stopCondition()) { + resolve(); + } else { + timeLimit -= interval; + if (timeLimit > 0) { + // recursive call until the resolve or the timeout + setTimeout(checkCondition, interval); + } else { + console.error('Timeout, the clicked element took more than', tl/1000,'seconds to load'); + reject(); + } + } + } + setTimeout(checkCondition, interval); + }); + return prom; + } + + + /** + * Chain deferred actions. + * + * @param {jQueryElement} $elements a list of jquery elements to be passed as arg to the function + * @param {Promise} promise the promise on which other promises will be chained + * @param {function} f the function to be deferred + * @returns {Promise} the chained promise + */ + function chainDeferred($elements, promise, f) { + _.each($elements, function(el) { + promise = promise.then(function () { + return f(el); + }); + }); + return promise; + } + + /** + * Make sure the home menu is open + */ + async function ensureHomeMenu() { + const menuToggle = document.querySelector('nav.o_main_navbar > a.o_menu_toggle.fa-th'); + if (menuToggle) { + await triggerClick(menuToggle, 'home menu toggle button'); + await waitForCondition(() => document.querySelector('.o_home_menu')); + } + } + + const MOUSE_EVENTS = [ + 'mouseover', + 'mouseenter', + 'mousedown', + 'mouseup', + 'click', + ]; + + /** + * Simulate all of the mouse events triggered during a click action. + * @param {EventTarget} target the element on which to perform the click + * @param {string} elDescription description of the item + * @returns {Promise} resolved after next animation frame + */ + async function triggerClick(target, elDescription) { + if (target) { + console.log("Clicking on", elDescription); + } else { + throw new Error(`No element "${elDescription}" found.`); + } + MOUSE_EVENTS.forEach(type => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window }); + target.dispatchEvent(event); + }); + await new Promise(setTimeout); + await new Promise(r => requestAnimationFrame(r)); + } + + exports.clickEverywhere = clickEverywhere; +})(window); diff --git a/addons/web/static/src/js/tools/test_menus_loader.js b/addons/web/static/src/js/tools/test_menus_loader.js new file mode 100644 index 00000000..4da82a27 --- /dev/null +++ b/addons/web/static/src/js/tools/test_menus_loader.js @@ -0,0 +1,12 @@ +odoo.define('web.clickEverywhere', function (require) { + "use strict"; + var ajax = require('web.ajax'); + function startClickEverywhere(xmlId, appsMenusOnly) { + ajax.loadJS('web/static/src/js/tools/test_menus.js').then( + function() { + clickEverywhere(xmlId, appsMenusOnly); + } + ); + } + return startClickEverywhere; +}); diff --git a/addons/web/static/src/js/tools/tools.js b/addons/web/static/src/js/tools/tools.js new file mode 100644 index 00000000..3df456de --- /dev/null +++ b/addons/web/static/src/js/tools/tools.js @@ -0,0 +1,22 @@ +odoo.define('web.tools', function (require) { +"use strict"; + +/** + * Wrapper for deprecated functions that display a warning message. + * + * @param {Function} fn the deprecated function + * @param {string} [message=''] optional message to display + * @returns {Function} + */ +function deprecated(fn, message) { + return function () { + console.warn(message || (fn.name + ' is deprecated.')); + return fn.apply(this, arguments); + }; +} + +return { + deprecated: deprecated, +}; + +}); diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js new file mode 100644 index 00000000..ed5aef40 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -0,0 +1,607 @@ +odoo.define('web.AbstractController', function (require) { +"use strict"; + +/** + * The Controller class is the class coordinating the model and the renderer. + * It is the C in MVC, and is what was formerly known in Odoo as a View. + * + * Its role is to listen to events bubbling up from the model/renderer, and call + * the appropriate methods if necessary. It also render control panel buttons, + * and react to changes in the search view. Basically, all interactions from + * the renderer/model with the outside world (meaning server/reading in session/ + * reading localstorage, ...) has to go through the controller. + */ + +var ActionMixin = require('web.ActionMixin'); +var ajax = require('web.ajax'); +var concurrency = require('web.concurrency'); +const { ComponentWrapper } = require('web.OwlCompatibility'); +var mvc = require('web.mvc'); +var session = require('web.session'); + + +var AbstractController = mvc.Controller.extend(ActionMixin, { + custom_events: _.extend({}, ActionMixin.custom_events, { + navigation_move: '_onNavigationMove', + open_record: '_onOpenRecord', + switch_view: '_onSwitchView', + }), + events: { + 'click a[type="action"]': '_onActionClicked', + }, + + /** + * @param {Object} param + * @param {Object[]} params.actionViews + * @param {string} params.activeActions + * @param {string} params.bannerRoute + * @param {Object} [params.controlPanel] + * @param {string} params.controllerID an id to ease the communication with + * upstream components + * @param {string} params.displayName + * @param {Object} params.initialState + * @param {string} params.modelName + * @param {ActionModel} [params.searchModel] + * @param {string} [params.searchPanel] + * @param {string} params.viewType + * @param {boolean} [params.withControlPanel] + * @param {boolean} [params.withSearchPanel] + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this._title = params.displayName; + this.modelName = params.modelName; + this.activeActions = params.activeActions; + this.controllerID = params.controllerID; + this.initialState = params.initialState; + this.bannerRoute = params.bannerRoute; + this.actionViews = params.actionViews; + this.viewType = params.viewType; + // use a DropPrevious to correctly handle concurrent updates + this.dp = new concurrency.DropPrevious(); + + this.withControlPanel = params.withControlPanel; + this.withSearchPanel = params.withSearchPanel; + if (params.searchModel) { + this.searchModel = params.searchModel; + } + if (this.withControlPanel) { + const { Component, props } = params.controlPanel; + this.ControlPanel = Component; + this.controlPanelProps = props; + } + if (this.withSearchPanel) { + const { Component, props } = params.searchPanel; + this.SearchPanel = Component; + this.searchPanelProps = props; + } + }, + + /** + * Simply renders and updates the url. + * + * @returns {Promise} + */ + start: async function () { + this.$el.addClass('o_view_controller'); + this.renderButtons(); + const promises = [this._super(...arguments)]; + if (this.withControlPanel) { + this._updateControlPanelProps(this.initialState); + this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps); + this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus()); + promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' })); + } + if (this.withSearchPanel) { + this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps); + const content = this.el.querySelector(':scope .o_content'); + content.classList.add('o_controller_with_searchpanel'); + promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' })); + } + await Promise.all(promises); + await this._update(this.initialState, { shouldUpdateSearchComponents: false }); + this.updateButtons(); + this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode()); + }, + /** + * @override + */ + destroy: function () { + if (this.$buttons) { + this.$buttons.off(); + } + ActionMixin.destroy.call(this); + this._super.apply(this, arguments); + }, + /** + * Called each time the controller is attached into the DOM. + */ + on_attach_callback: function () { + ActionMixin.on_attach_callback.call(this); + this.searchModel.on('search', this, this._onSearch); + this.searchModel.trigger('focus-control-panel'); + if (this.withControlPanel) { + this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); + } + if (!(this.renderer instanceof owl.Component)) { + this.renderer.on_attach_callback(); + } + }, + /** + * Called each time the controller is detached from the DOM. + */ + on_detach_callback: function () { + ActionMixin.on_detach_callback.call(this); + this.searchModel.off('search', this); + if (this.withControlPanel) { + this.searchModel.off('get-controller-query-params', this); + } + if (!(this.renderer instanceof owl.Component)) { + this.renderer.on_detach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + canBeRemoved: function () { + // AAB: get rid of 'readonlyIfRealDiscard' option when on_hashchange mechanism is improved + return this.discardChanges(undefined, { + noAbandon: true, + readonlyIfRealDiscard: true, + }); + }, + /** + * Discards the changes made on the record associated to the given ID, or + * all changes made by the current controller if no recordID is given. For + * example, when the user opens the 'home' screen, the action manager calls + * this method on the active view to make sure it is ok to open the home + * screen (and lose all current state). + * + * Note that it returns a Promise, because the view could choose to ask the + * user if he agrees to discard. + * + * @param {string} [recordID] + * if not given, we consider all the changes made by the controller + * @param {Object} [options] + * @returns {Promise} resolved if properly discarded, rejected otherwise + */ + discardChanges: function (recordID, options) { + return Promise.resolve(); + }, + /** + * Export the state of the controller containing information that is shared + * between different controllers of a same action (like the current search + * model state or the states of some components). + * + * @returns {Object} + */ + exportState() { + const exported = { + searchModel: this.searchModel.exportState(), + }; + if (this.withSearchPanel) { + const searchPanel = this._searchPanelWrapper.componentRef.comp; + exported.searchPanel = searchPanel.exportState(); + } + return exported; + }, + /** + * Parses and imports a previously exported state. + * + * @param {Object} state + */ + importState(state) { + this.searchModel.importState(state.searchModel); + if (this.withSearchPanel) { + const searchPanel = this._searchPanelWrapper.componentRef.comp; + searchPanel.importState(state.searchPanel); + } + }, + /** + * The use of this method is discouraged. It is still snakecased, because + * it currently is used in many templates, but we will move to a simpler + * mechanism as soon as we can. + * + * @deprecated + * @param {string} action type of action, such as 'create', 'read', ... + * @returns {boolean} + */ + is_action_enabled: function (action) { + return this.activeActions[action]; + }, + /** + * Short helper method to reload the view + * + * @param {Object} [params={}] + * @param {Object} [params.controllerState={}] + * @returns {Promise} + */ + reload: async function (params = {}) { + if (params.controllerState) { + this.importState(params.controllerState); + Object.assign(params, this.searchModel.get('query')); + } + return this.update(params, {}); + }, + /** + * This is the main entry point for the controller. Changes from the search + * view arrive in this method, and internal changes can sometimes also call + * this method. It is basically the way everything notifies the controller + * that something has changed. + * + * The update method is responsible for fetching necessary data, then + * updating the renderer and wait for the rendering to complete. + * + * @param {Object} params will be given to the model and to the renderer + * @param {Object} [options={}] + * @param {boolean} [options.reload=true] if true, the model will reload data + * @returns {Promise} + */ + async update(params, options = {}) { + const shouldReload = 'reload' in options ? options.reload : true; + if (shouldReload) { + this.handle = await this.dp.add(this.model.reload(this.handle, params)); + } + const localState = this.renderer.getLocalState(); + const state = this.model.get(this.handle, { withSampleData: true }); + const promises = [ + this._updateRendererState(state, params).then(() => { + this.renderer.setLocalState(localState); + }), + this._update(this.model.get(this.handle), params) + ]; + await this.dp.add(Promise.all(promises)); + this.updateButtons(); + this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode()); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + + /** + * Meant to be overriden to return a proper object. + * @private + * @param {Object} [state] + * @return {(Object|null)} + */ + _getPagingInfo: function (state) { + return null; + }, + /** + * Meant to be overriden to return a proper object. + * @private + * @param {Object} [state] + * @return {(Object|null)} + */ + _getActionMenuItems: function (state) { + return null; + }, + /** + * Gives the focus to the renderer if not in sample mode. + * + * @private + */ + _giveFocus() { + if (!this.model.isInSampleMode()) { + this.renderer.giveFocus(); + } + }, + /** + * This method is the way a view can notifies the outside world that + * something has changed. The main use for this is to update the url, for + * example with a new id. + * + * @private + */ + _pushState: function () { + this.trigger_up('push_state', { + controllerID: this.controllerID, + state: this.getState(), + }); + }, + /** + * @private + * @param {function} callback function to execute before removing classname + * 'o_view_sample_data' (may be async). This allows to reload and/or + * rerender before removing the className, thus preventing the view from + * flickering. + */ + async _removeSampleData(callback) { + this.model.leaveSampleMode(); + if (callback) { + await callback(); + } + this.el.classList.remove('o_view_sample_data'); + }, + /** + * Renders the html provided by the route specified by the + * bannerRoute attribute on the controller (banner_route in the template). + * Renders it before the view output and add a css class 'o_has_banner' to it. + * There can be only one banner displayed at a time. + * + * If the banner contains stylesheet links or js files, they are moved to <head> + * (and will only be fetched once). + * + * Route example: + * @http.route('/module/hello', auth='user', type='json') + * def hello(self): + * return {'html': '<h1>hello, world</h1>'} + * + * @private + * @returns {Promise} + */ + _renderBanner: async function () { + if (this.bannerRoute !== undefined) { + const response = await this._rpc({ + route: this.bannerRoute, + params: {context: session.user_context}, + }); + if (!response.html) { + this.$el.removeClass('o_has_banner'); + return Promise.resolve(); + } + this.$el.addClass('o_has_banner'); + var $banner = $(response.html); + // we should only display one banner at a time + if (this._$banner && this._$banner.remove) { + this._$banner.remove(); + } + // Css and js are moved to <head> + var defs = []; + $('link[rel="stylesheet"]', $banner).each(function (i, link) { + defs.push(ajax.loadCSS(link.href)); + link.remove(); + }); + $('script[type="text/javascript"]', $banner).each(function (i, js) { + defs.push(ajax.loadJS(js.src)); + js.remove(); + }); + await Promise.all(defs); + $banner.insertBefore(this.$('> .o_content')); + this._$banner = $banner; + } + }, + /** + * @override + * @private + */ + _startRenderer: function () { + if (this.renderer instanceof owl.Component) { + return this.renderer.mount(this.$('.o_content')[0]); + } + return this.renderer.appendTo(this.$('.o_content')); + }, + /** + * This method is called after each update or when the start method is + * completed. + * + * Its primary use is to be used as a hook to update all parts of the UI, + * besides the renderer. For example, it may be used to enable/disable + * some buttons in the control panel, such as the current graph type for a + * graph view. + * + * FIXME: this hook should be synchronous, and called once async rendering + * has been done. + * + * @private + * @param {Object} state the state given by the model + * @param {Object} [params={}] + * @param {Array} [params.breadcrumbs] + * @param {Object} [params.shouldUpdateSearchComponents] + * @returns {Promise} + */ + async _update(state, params) { + // AAB: update the control panel -> this will be moved elsewhere at some point + if (!this.$buttons) { + this.renderButtons(); + } + const promises = [this._renderBanner()]; + if (params.shouldUpdateSearchComponents !== false) { + if (this.withControlPanel) { + this._updateControlPanelProps(state); + if (params.breadcrumbs) { + this.controlPanelProps.breadcrumbs = params.breadcrumbs; + } + promises.push(this.updateControlPanel()); + } + if (this.withSearchPanel) { + this._updateSearchPanel(); + } + } + this._pushState(); + await Promise.all(promises); + }, + /** + * Can be used to update the key 'cp_content'. This method is called in start and _update methods. + * + * @private + * @param {Object} state the state given by the model + */ + _updateControlPanelProps(state) { + if (!this.controlPanelProps.cp_content) { + this.controlPanelProps.cp_content = {}; + } + if (this.$buttons) { + this.controlPanelProps.cp_content.$buttons = this.$buttons; + } + Object.assign(this.controlPanelProps, { + actionMenus: this._getActionMenuItems(state), + pager: this._getPagingInfo(state), + title: this.getTitle(), + }); + }, + /** + * @private + * @param {Object} state + * @param {Object} newProps + * @returns {Promise} + */ + _updatePaging: async function (state, newProps) { + const pagingInfo = this._getPagingInfo(state); + if (pagingInfo) { + Object.assign(pagingInfo, newProps); + return this.updateControlPanel({ pager: pagingInfo }); + } + }, + /** + * Updates the state of the renderer (handle both Widget and Component + * renderers). + * + * @private + * @param {Object} state the model state + * @param {Object} [params={}] will be given to the model and to the renderer + * @return {Promise} + */ + _updateRendererState(state, params = {}) { + if (this.renderer instanceof owl.Component) { + return this.renderer.update(state); + } + return this.renderer.updateState(state, params); + }, + /** + * @private + * @param {Object} [newProps={}] + * @return {Promise} + */ + async _updateSearchPanel(newProps) { + Object.assign(this.searchPanelProps, newProps); + await this._searchPanelWrapper.update(this.searchPanelProps); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When a user clicks on an <a> link with type="action", we need to actually + * do the action. This kind of links is used a lot in no-content helpers. + * + * * if the link has both data-model and data-method attributes, the + * corresponding method is called, chained to any action it would + * return. An optional data-reload-on-close (set to a non-falsy value) + * also causes th underlying view to be reloaded after the dialog is + * closed. + * * if the link has a name attribute, invoke the action with that + * identifier (see :class:`ActionManager.doAction` to not get the + * details) + * * otherwise an *action descriptor* is built from the link's data- + * attributes (model, res-id, views, domain and context) + * + * @private + * @param ev + */ + _onActionClicked: function (ev) { // FIXME: maybe this should also work on <button> tags? + ev.preventDefault(); + var $target = $(ev.currentTarget); + var self = this; + var data = $target.data(); + + if (data.method !== undefined && data.model !== undefined) { + var options = {}; + if (data.reloadOnClose) { + options.on_close = function () { + self.trigger_up('reload'); + }; + } + this.dp.add(this._rpc({ + model: data.model, + method: data.method, + context: session.user_context, + })).then(function (action) { + if (action !== undefined) { + self.do_action(action, options); + } + }); + } else if ($target.attr('name')) { + this.do_action( + $target.attr('name'), + data.context && {additional_context: data.context} + ); + } else { + this.do_action({ + name: $target.attr('title') || _.str.strip($target.text()), + type: 'ir.actions.act_window', + res_model: data.model || this.modelName, + res_id: data.resId, + target: 'current', // TODO: make customisable? + views: data.views || (data.resId ? [[false, 'form']] : [[false, 'list'], [false, 'form']]), + domain: data.domain || [], + }, { + additional_context: _.extend({}, data.context) + }); + } + }, + /** + * Called either from the control panel to focus the controller + * or from the view to focus the search bar + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + switch (ev.data.direction) { + case 'up': + ev.stopPropagation(); + this.searchModel.trigger('focus-control-panel'); + break; + case 'down': + ev.stopPropagation(); + this._giveFocus(); + break; + } + }, + /** + * When an Odoo event arrives requesting a record to be opened, this method + * gets the res_id, and request a switch view in the appropriate mode + * + * Note: this method seems wrong, it relies on the model being a basic model, + * to get the res_id. It should receive the res_id in the event data + * @todo move this to basic controller? + * + * @private + * @param {OdooEvent} ev + * @param {number} ev.data.id The local model ID for the record to be + * opened + * @param {string} [ev.data.mode='readonly'] + */ + _onOpenRecord: function (ev) { + ev.stopPropagation(); + var record = this.model.get(ev.data.id, {raw: true}); + this.trigger_up('switch_view', { + view_type: 'form', + res_id: record.res_id, + mode: ev.data.mode || 'readonly', + model: this.modelName, + }); + }, + /** + * Called when there is a change in the search view, so the current action's + * environment needs to be updated with the new domain, context, groupby,... + * + * @private + * @param {Object} searchQuery + */ + _onSearch: function (searchQuery) { + this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery)); + }, + /** + * Intercepts the 'switch_view' event to add the controllerID into the data, + * and lets the event bubble up. + * + * @param {OdooEvent} ev + */ + _onSwitchView: function (ev) { + ev.data.controllerID = this.controllerID; + }, +}); + +return AbstractController; + +}); diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js new file mode 100644 index 00000000..db2cce99 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_model.js @@ -0,0 +1,286 @@ +odoo.define('web.AbstractModel', function (require) { +"use strict"; + +/** + * An AbstractModel is the M in MVC. We tend to think of MVC more on the server + * side, but we are talking here on the web client side. + * + * The duties of the Model are to fetch all relevant data, and to make them + * available for the rest of the view. Also, every modification to that data + * should pass through the model. + * + * Note that the model is not a widget, it does not need to be rendered or + * appended to the dom. However, it inherits from the EventDispatcherMixin, + * in order to be able to notify its parent by bubbling events up. + * + * The model is able to generate sample (fake) data when there is no actual data + * in database. This feature can be activated by instantiating the model with + * param "useSampleModel" set to true. In this case, the model instantiates a + * duplicated version of itself, parametrized to call a SampleServer (JS) + * instead of doing RPCs. Here is how it works: the main model first load the + * data normally (from database), and then checks whether the result is empty or + * not. If it is, it asks the sample model to load with the exact same params, + * and it thus enters in "sample" mode. The model keeps doing this at reload, + * but only if the (re)load params haven't changed: as soon as a param changes, + * the "sample" mode is left, and it never enters it again in the future (in the + * lifetime of the model instance). To access those sample data from the outside, + * 'get' must be called with the the option "withSampleData" set to true. In + * this case, if the main model is in "sample" mode, it redirects the call to the + * sample model. + */ + +var fieldUtils = require('web.field_utils'); +var mvc = require('web.mvc'); +const SampleServer = require('web.SampleServer'); + + +var AbstractModel = mvc.Model.extend({ + /** + * @param {Widget} parent + * @param {Object} [params={}] + * @param {Object} [params.fields] + * @param {string} [params.modelName] + * @param {boolean} [params.isSampleModel=false] if true, will fetch data + * from a SampleServer instead of doing RPCs + * @param {boolean} [params.useSampleModel=false] if true, will use a sample + * model to generate sample data when there is no "real" data in database + * @param {AbstractModel} [params.SampleModel] the AbstractModel class + * to instantiate as sample model. This model won't do any rpc, but will + * rather call a SampleServer that will generate sample data. This param + * must be set when params.useSampleModel is true. + */ + init(parent, params = {}) { + this._super(...arguments); + this.useSampleModel = params.useSampleModel || false; + if (params.isSampleModel) { + this.isSampleModel = true; + this.sampleServer = new SampleServer(params.modelName, params.fields); + } else if (this.useSampleModel) { + const sampleModelParams = Object.assign({}, params, { + isSampleModel: true, + SampleModel: null, + useSampleModel: false, + }); + this.sampleModel = new params.SampleModel(this, sampleModelParams); + this._isInSampleMode = false; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Override to call get on the sampleModel when we are in sample mode, and + * option 'withSampleData' is set to true. + * + * @override + * @param {any} _ + * @param {Object} [options] + * @param {boolean} [options.withSampleData=false] + */ + get(_, options) { + let state; + if (options && options.withSampleData && this._isInSampleMode) { + state = this.sampleModel.__get(...arguments); + } else { + state = this.__get(...arguments); + } + return state; + }, + /** + * Under some conditions, the model is designed to generate sample data if + * there is no real data in database. This function returns a boolean which + * indicates the mode of the model: if true, we are in "sample" mode. + * + * @returns {boolean} + */ + isInSampleMode() { + return !!this._isInSampleMode; + }, + /** + * Disables the sample data (forever) on this model instance. + */ + leaveSampleMode() { + if (this.useSampleModel) { + this.useSampleModel = false; + this._isInSampleMode = false; + this.sampleModel.destroy(); + } + }, + /** + * Override to check if we need to call the sample model (and if so, to do + * it) after loading the data, in the case where there is no real data to + * display. + * + * @override + */ + async load(params) { + this.loadParams = params; + const handle = await this.__load(...arguments); + await this._callSampleModel('__load', handle, ...arguments); + return handle; + }, + /** + * When something changes, the data may need to be refetched. This is the + * job for this method: reloading (only if necessary) all the data and + * making sure that they are ready to be redisplayed. + * Sometimes, we reload the data with the "same" params as the initial load + * params (see '_haveParamsChanged'). When we do, if we were in "sample" mode, + * we call again the sample server after the reload if there is still no data + * to display. When the parameters change, we automatically leave "sample" + * mode. + * + * @param {any} _ + * @param {Object} [params] + * @returns {Promise} + */ + async reload(_, params) { + const handle = await this.__reload(...arguments); + if (this._isInSampleMode) { + if (!this._haveParamsChanged(params)) { + await this._callSampleModel('__reload', handle, ...arguments); + } else { + this.leaveSampleMode(); + } + } + return handle; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} method + * @param {any} handle + * @param {...any} args + * @returns {Promise} + */ + async _callSampleModel(method, handle, ...args) { + if (this.useSampleModel && this._isEmpty(handle)) { + try { + if (method === '__load') { + await this.sampleModel.__load(...args); + } else if (method === '__reload') { + await this.sampleModel.__reload(...args); + } + this._isInSampleMode = true; + } catch (error) { + if (error instanceof SampleServer.UnimplementedRouteError) { + this.leaveSampleMode(); + } else { + throw error; + } + } + } else { + this.leaveSampleMode(); + } + }, + /** + * @private + * @returns {Object} + */ + __get() { + return {}; + }, + /** + * This function can be overriden to determine if the result of a load or + * a reload is empty. In the affirmative, we will try to generate sample + * data to prevent from having an empty state to display. + * + * @private + * @params {any} handle, the value returned by a load or a reload + * @returns {boolean} + */ + _isEmpty(/* handle */) { + return false; + }, + /** + * To override to do the initial load of the data (this function is supposed + * to be called only once). + * + * @private + * @returns {Promise} + */ + async __load() { + return Promise.resolve(); + }, + /** + * Processes date(time) and selection field values sent by the server. + * Converts data(time) values to moment instances. + * Converts false values of selection fields to 0 if 0 is a valid key, + * because the server doesn't make a distinction between false and 0, and + * always sends false when value is 0. + * + * @param {Object} field the field description + * @param {*} value + * @returns {*} the processed value + */ + _parseServerValue: function (field, value) { + if (field.type === 'date' || field.type === 'datetime') { + // process date(time): convert into a moment instance + value = fieldUtils.parse[field.type](value, field, {isUTC: true}); + } else if (field.type === 'selection' && value === false) { + // process selection: convert false to 0, if 0 is a valid key + var hasKey0 = _.find(field.selection, function (option) { + return option[0] === 0; + }); + value = hasKey0 ? 0 : value; + } + return value; + }, + /** + * To override to reload data (this function may be called several times, + * after the initial load has been done). + * + * @private + * @returns {Promise} + */ + async __reload() { + return Promise.resolve(); + }, + /** + * Determines whether or not the given params (reload params) differ from + * the initial ones (this.loadParams). This is used to leave "sample" mode + * as soon as a parameter (e.g. domain) changes. + * + * @private + * @param {Object} [params={}] + * @param {Object} [params.context] + * @param {Array[]} [params.domain] + * @param {Object} [params.timeRanges] + * @param {string[]} [params.groupBy] + * @returns {boolean} + */ + _haveParamsChanged(params = {}) { + for (const key of ['context', 'domain', 'timeRanges']) { + if (key in params) { + const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]); + if (diff) { + return true; + } + } + } + if (this.useSampleModel && 'groupBy' in params) { + return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy); + } + }, + /** + * Override to redirect all rpcs to the SampleServer if this.isSampleModel + * is true. + * + * @override + */ + async _rpc() { + if (this.isSampleModel) { + return this.sampleServer.mockRpc(...arguments); + } + return this._super(...arguments); + }, +}); + +return AbstractModel; + +}); diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js new file mode 100644 index 00000000..c2cc7f2f --- /dev/null +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -0,0 +1,217 @@ +odoo.define('web.AbstractRenderer', function (require) { +"use strict"; + +/** + * The renderer should not handle pagination, data loading, or coordination + * with the control panel. It is only concerned with rendering. + * + */ + +var mvc = require('web.mvc'); + +// Renderers may display sample data when there is no real data to display. In +// this case the data is displayed with opacity and can't be clicked. Moreover, +// we also want to prevent the user from accessing DOM elements with TAB +// navigation. This is the list of elements we won't allow to focus. +const FOCUSABLE_ELEMENTS = [ + // focusable by default + 'a', 'button', 'input', 'select', 'textarea', + // manually set + '[tabindex="0"]' +].map((sel) => `:scope ${sel}`).join(', '); + +/** + * @class AbstractRenderer + */ +return mvc.Renderer.extend({ + // Defines the elements suppressed when in demo data. This must be a list + // of DOM selectors matching view elements that will: + // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events) + // 2. have themselves and any of their focusable children removed from the + // tab navigation + sampleDataTargets: [], + + /** + * @override + * @param {string} [params.noContentHelp] + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.arch = params.arch; + this.noContentHelp = params.noContentHelp; + this.withSearchPanel = params.withSearchPanel; + }, + /** + * The rendering is asynchronous. The start + * method simply makes sure that we render the view. + * + * @returns {Promise} + */ + async start() { + this.$el.addClass(this.arch.attrs.class); + if (this.withSearchPanel) { + this.$el.addClass('o_renderer_with_searchpanel'); + } + await Promise.all([this._render(), this._super()]); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () {}, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () {}, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns any relevant state that the renderer might want to keep. + * + * The idea is that a renderer can be destroyed, then be replaced by another + * one instantiated with the state from the model and the localState from + * the renderer, and the end result should be the same. + * + * The kind of state that we expect the renderer to have is mostly DOM state + * such as the scroll position, the currently active tab page, ... + * + * This method is called before each updateState, by the controller. + * + * @see setLocalState + * @returns {any} + */ + getLocalState: function () { + }, + /** + * Order to focus to be given to the content of the current view + */ + giveFocus: function () { + }, + /** + * Resets state that renderer keeps, state may contains scroll position, + * the currently active tab page, ... + * + * @see getLocalState + * @see setLocalState + */ + resetLocalState() { + }, + /** + * This is the reverse operation from getLocalState. With this method, we + * expect the renderer to restore all DOM state, if it is relevant. + * + * This method is called after each updateState, by the controller. + * + * @see getLocalState + * @param {any} localState the result of a call to getLocalState + */ + setLocalState: function (localState) { + }, + /** + * Updates the state of the view. It retriggers a full rerender, unless told + * otherwise (for optimization for example). + * + * @param {any} state + * @param {Object} params + * @param {boolean} [params.noRender=false] + * if true, the method only updates the state without rerendering + * @returns {Promise} + */ + async updateState(state, params) { + this._setState(state); + if (!params.noRender) { + await this._render(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders the widget. This method can be overriden to perform actions + * before or after the view has been rendered. + * + * @private + * @returns {Promise} + */ + async _render() { + await this._renderView(); + this._suppressFocusableElements(); + }, + /** + * @private + * @param {Object} context + */ + _renderNoContentHelper: function (context) { + let templateName; + if (!context && this.noContentHelp) { + templateName = "web.ActionHelper"; + context = { noContentHelp: this.noContentHelp }; + } else { + templateName = "web.NoContentHelper"; + } + const template = document.createElement('template'); + // FIXME: retrieve owl qweb instance via the env set on Component s.t. + // it also works in the tests (importing 'web.env' wouldn't). This + // won't be necessary as soon as this will be written in owl. + const owlQWeb = owl.Component.env.qweb; + template.innerHTML = owlQWeb.renderToString(templateName, context); + this.el.append(template.content.firstChild); + }, + /** + * Actual rendering. This method is meant to be overridden by concrete + * renderers. + * + * @abstract + * @private + * @returns {Promise} + */ + async _renderView() { }, + /** + * Assigns a new state to the renderer if not false. + * + * @private + * @param {any} [state=false] + */ + _setState(state = false) { + if (state !== false) { + this.state = state; + } + }, + /** + * Suppresses 'tabindex' property on any focusable element located inside + * root elements defined in the `this.sampleDataTargets` object and assigns + * the 'o_sample_data_disabled' class to these root elements. + * + * @private + * @see sampleDataTargets + */ + _suppressFocusableElements() { + if (!this.state.isSample || this.isEmbedded) { + return; + } + const rootEls = []; + for (const selector of this.sampleDataTargets) { + rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`)); + } + const focusableEls = new Set(rootEls); + for (const rootEl of rootEls) { + rootEl.classList.add('o_sample_data_disabled'); + for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) { + focusableEls.add(focusableEl); + } + } + for (const focusableEl of focusableEls) { + focusableEl.setAttribute('tabindex', -1); + if (focusableEl.classList.contains('dropdown-item')) { + // Tells Bootstrap to ignore the dropdown item in keynav + focusableEl.classList.add('disabled'); + } + } + }, +}); + +}); diff --git a/addons/web/static/src/js/views/abstract_renderer_owl.js b/addons/web/static/src/js/views/abstract_renderer_owl.js new file mode 100644 index 00000000..0c97159e --- /dev/null +++ b/addons/web/static/src/js/views/abstract_renderer_owl.js @@ -0,0 +1,72 @@ +odoo.define('web.AbstractRendererOwl', function () { + "use strict"; + + // Renderers may display sample data when there is no real data to display. In + // this case the data is displayed with opacity and can't be clicked. Moreover, + // we also want to prevent the user from accessing DOM elements with TAB + // navigation. This is the list of elements we won't allow to focus. + const FOCUSABLE_ELEMENTS = [ + // focusable by default + 'a', 'button', 'input', 'select', 'textarea', + // manually set + '[tabindex="0"]' + ].map((sel) => `:scope ${sel}`).join(', '); + + class AbstractRenderer extends owl.Component { + + constructor() { + super(...arguments); + // Defines the elements suppressed when in demo data. This must be a list + // of DOM selectors matching view elements that will: + // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events) + // 2. have themselves and any of their focusable children removed from the + // tab navigation + this.sampleDataTargets = []; + } + + mounted() { + this._suppressFocusableElements(); + } + + patched() { + this._suppressFocusableElements(); + } + + /** + * Suppresses 'tabindex' property on any focusable element located inside + * root elements defined in the `this.sampleDataTargets` object and assigns + * the 'o_sample_data_disabled' class to these root elements. + * + * @private + * @see sampleDataTargets + */ + _suppressFocusableElements() { + if (!this.props.isSample || this.props.isEmbedded) { + const disabledEls = this.el.querySelectorAll(`.o_sample_data_disabled`); + disabledEls.forEach(el => el.classList.remove('o_sample_data_disabled')); + return; + } + const rootEls = []; + for (const selector of this.sampleDataTargets) { + rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`)); + } + const focusableEls = new Set(rootEls); + for (const rootEl of rootEls) { + rootEl.classList.add('o_sample_data_disabled'); + for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) { + focusableEls.add(focusableEl); + } + } + for (const focusableEl of focusableEls) { + focusableEl.setAttribute('tabindex', -1); + if (focusableEl.classList.contains('dropdown-item')) { + // Tells Bootstrap to ignore the dropdown item in keynav + focusableEl.classList.add('disabled'); + } + } + } + } + + return AbstractRenderer; + +}); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js new file mode 100644 index 00000000..9440bf67 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_view.js @@ -0,0 +1,440 @@ +odoo.define('web.AbstractView', function (require) { +"use strict"; + +/** + * This is the base class inherited by all (JS) views. Odoo JS views are the + * widgets used to display information in the main area of the web client + * (note: the search view is not a "JS view" in that sense). + * + * The abstract view role is to take a set of fields, an arch (the xml + * describing the view in db), and some params, and then, to create a + * controller, a renderer and a model. This is the classical MVC pattern, but + * the word 'view' has historical significance in Odoo code, so we replaced the + * V in MVC by the 'renderer' word. + * + * JS views are supposed to be used like this: + * 1. instantiate a view with some arch, fields and params + * 2. call the getController method on the view instance. This returns a + * controller (with a model and a renderer as sub widgets) + * 3. append the controller somewhere + * + * Note that once a controller has been instantiated, the view class is no + * longer useful (unless you want to create another controller), and will be + * in most case discarded. + */ + +const ActionModel = require("web/static/src/js/views/action_model.js"); +var AbstractModel = require('web.AbstractModel'); +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractController = require('web.AbstractController'); +const ControlPanel = require('web.ControlPanel'); +const SearchPanel = require("web/static/src/js/views/search_panel.js"); +var mvc = require('web.mvc'); +var viewUtils = require('web.viewUtils'); + +const { Component } = owl; + +var Factory = mvc.Factory; + +var AbstractView = Factory.extend({ + // name displayed in view switchers + display_name: '', + // indicates whether or not the view is mobile-friendly + mobile_friendly: false, + // icon is the font-awesome icon to display in the view switcher + icon: 'fa-question', + // multi_record is used to distinguish views displaying a single record + // (e.g. FormView) from those that display several records (e.g. ListView) + multi_record: true, + // viewType is the type of the view, like 'form', 'kanban', 'list'... + viewType: undefined, + // determines if a search bar is available + withSearchBar: true, + // determines the search menus available and their orders + searchMenuTypes: ['filter', 'groupBy', 'favorite'], + // determines if a control panel should be instantiated + withControlPanel: true, + // determines if a search panel could be instantiated + withSearchPanel: true, + // determines the MVC components to use + config: _.extend({}, Factory.prototype.config, { + Model: AbstractModel, + Renderer: AbstractRenderer, + Controller: AbstractController, + ControlPanel, + SearchPanel, + }), + + /** + * The constructor function is supposed to set 3 variables: rendererParams, + * controllerParams and loadParams. These values will be used to initialize + * the model, renderer and controllers. + * + * @constructs AbstractView + * + * @param {Object} viewInfo + * @param {Object|string} viewInfo.arch + * @param {Object} viewInfo.fields + * @param {Object} viewInfo.fieldsInfo + * @param {Object} params + * @param {string} [params.modelName] + * @param {Object} [params.action={}] + * @param {Object} [params.context={}] + * @param {string} [params.controllerID] + * @param {number} [params.count] + * @param {number} [params.currentId] + * @param {Object} [params.controllerState] + * @param {string} [params.displayName] + * @param {Array[]} [params.domain=[]] + * @param {Object[]} [params.dynamicFilters] transmitted to the + * ControlPanel + * @param {number[]} [params.ids] + * @param {boolean} [params.isEmbedded=false] + * @param {Object} [params.searchQuery={}] + * @param {Object} [params.searchQuery.context={}] + * @param {Array[]} [params.searchQuery.domain=[]] + * @param {string[]} [params.searchQuery.groupBy=[]] + * @param {Object} [params.userContext={}] + * @param {boolean} [params.useSampleModel] + * @param {boolean} [params.withControlPanel=AbstractView.prototype.withControlPanel] + * @param {boolean} [params.withSearchPanel=AbstractView.prototype.withSearchPanel] + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + var action = params.action || {}; + params = _.defaults(params, this._extractParamsFromAction(action)); + + // in general, the fieldsView has to be processed by the View (e.g. the + // arch is a string that needs to be parsed) ; the only exception is for + // inline form views inside form views, as they are processed alongside + // the main view, but they are opened in a FormViewDialog which + // instantiates another FormView (unlike kanban or list subviews for + // which only a Renderer is instantiated) + if (typeof viewInfo.arch === 'string') { + this.fieldsView = this._processFieldsView(viewInfo); + } else { + this.fieldsView = viewInfo; + } + this.arch = this.fieldsView.arch; + this.fields = this.fieldsView.viewFields; + this.userContext = params.userContext || {}; + + // the boolean parameter 'isEmbedded' determines if the view should be + // considered as a subview. For now this is only used by the graph + // controller that appends a 'Group By' button beside the 'Measures' + // button when the graph view is embedded. + var isEmbedded = params.isEmbedded || false; + + // The noContentHelper's message can be empty, i.e. either a real empty string + // or an empty html tag. In both cases, we consider the helper empty. + var help = params.noContentHelp || ""; + var htmlHelp = document.createElement("div"); + htmlHelp.innerHTML = help; + this.rendererParams = { + arch: this.arch, + isEmbedded: isEmbedded, + noContentHelp: htmlHelp.innerText.trim() ? help : "", + }; + + this.controllerParams = { + actionViews: params.actionViews, + activeActions: { + edit: this.arch.attrs.edit ? !!JSON.parse(this.arch.attrs.edit) : true, + create: this.arch.attrs.create ? !!JSON.parse(this.arch.attrs.create) : true, + delete: this.arch.attrs.delete ? !!JSON.parse(this.arch.attrs.delete) : true, + duplicate: this.arch.attrs.duplicate ? !!JSON.parse(this.arch.attrs.duplicate) : true, + }, + bannerRoute: this.arch.attrs.banner_route, + controllerID: params.controllerID, + displayName: params.displayName, + isEmbedded: isEmbedded, + modelName: params.modelName, + viewType: this.viewType, + }; + + var controllerState = params.controllerState || {}; + var currentId = controllerState.currentId || params.currentId; + this.loadParams = { + context: params.context, + count: params.count || ((this.controllerParams.ids !== undefined) && + this.controllerParams.ids.length) || 0, + domain: params.domain, + modelName: params.modelName, + res_id: currentId, + res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined), + }; + + const useSampleModel = 'useSampleModel' in params ? + params.useSampleModel : + !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample)); + + this.modelParams = { + fields: this.fields, + modelName: params.modelName, + useSampleModel, + }; + if (useSampleModel) { + this.modelParams.SampleModel = this.config.Model; + } + + var defaultOrder = this.arch.attrs.default_order; + if (defaultOrder) { + this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) { + order = order.trim().split(' '); + return {name: order[0], asc: order[1] !== 'desc'}; + }); + } + if (params.searchQuery) { + this._updateMVCParams(params.searchQuery); + } + + this.withControlPanel = this.withControlPanel && params.withControlPanel; + this.withSearchPanel = this.withSearchPanel && + this.multi_record && params.withSearchPanel && + !('search_panel' in params.context && !params.search_panel); + + const searchModelParams = Object.assign({}, params, { action }); + if (this.withControlPanel || this.withSearchPanel) { + const { arch, fields, favoriteFilters } = params.controlPanelFieldsView || {}; + const archInfo = ActionModel.extractArchInfo({ search: arch }, this.viewType); + const controlPanelInfo = archInfo[this.config.ControlPanel.modelExtension]; + const searchPanelInfo = archInfo[this.config.SearchPanel.modelExtension]; + this.withSearchPanel = this.withSearchPanel && Boolean(searchPanelInfo); + Object.assign(searchModelParams, { + fields, + favoriteFilters, + controlPanelInfo, + searchPanelInfo, + }); + } + const searchModel = this._createSearchModel(searchModelParams); + this.controllerParams.searchModel = searchModel; + if (this.controllerParams.controlPanel) { + this.controllerParams.controlPanel.props.searchModel = searchModel; + } + if (this.controllerParams.searchPanel) { + this.controllerParams.searchPanel.props.searchModel = searchModel; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {Object} params + * @param {Object} extraExtensions + * @returns {ActionModel} + */ + _createSearchModel: function (params, extraExtensions) { + // Search model + common config + const { fields, favoriteFilters, controlPanelInfo, searchPanelInfo } = params; + const extensions = Object.assign({}, extraExtensions); + const importedState = params.controllerState || {}; + + // Control panel params + if (this.withControlPanel) { + // Control panel (Model) + const ControlPanelComponent = this.config.ControlPanel; + extensions[ControlPanelComponent.modelExtension] = { + actionId: params.action.id, + // control initialization + activateDefaultFavorite: params.activateDefaultFavorite, + archNodes: controlPanelInfo.children, + dynamicFilters: params.dynamicFilters, + favoriteFilters, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.withControlPanel = true; + // Control panel (Component) + const controlPanelProps = { + action: params.action, + breadcrumbs: params.breadcrumbs, + fields, + searchMenuTypes: params.searchMenuTypes, + view: this.fieldsView, + views: params.action.views && params.action.views.filter( + v => v.multiRecord === this.multi_record + ), + withBreadcrumbs: params.withBreadcrumbs, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.controlPanel = { + Component: ControlPanelComponent, + props: controlPanelProps, + }; + } + + // Search panel params + if (this.withSearchPanel) { + // Search panel (Model) + const SearchPanelComponent = this.config.SearchPanel; + extensions[SearchPanelComponent.modelExtension] = { + archNodes: searchPanelInfo.children, + }; + this.controllerParams.withSearchPanel = true; + this.rendererParams.withSearchPanel = true; + // Search panel (Component) + const searchPanelProps = { + importedState: importedState.searchPanel, + }; + if (searchPanelInfo.attrs.class) { + searchPanelProps.className = searchPanelInfo.attrs.class; + } + this.controllerParams.searchPanel = { + Component: SearchPanelComponent, + props: searchPanelProps, + }; + } + + const searchModel = new ActionModel(extensions, { + env: Component.env, + modelName: params.modelName, + context: Object.assign({}, this.loadParams.context), + domain: this.loadParams.domain || [], + importedState: importedState.searchModel, + searchMenuTypes: params.searchMenuTypes, + searchQuery: params.searchQuery, + fields, + }); + + return searchModel; + }, + + /** + * @override + */ + getController: async function () { + const _super = this._super.bind(this); + const { searchModel } = this.controllerParams; + await searchModel.load(); + this._updateMVCParams(searchModel.get("query")); + // get the parent of the model if it already exists, as _super will + // set the new controller as parent, which we don't want + const modelParent = this.model && this.model.getParent(); + const [controller] = await Promise.all([ + _super(...arguments), + searchModel.isReady(), + ]); + if (modelParent) { + // if we already add a model, restore its parent + this.model.setParent(modelParent); + } + return controller; + }, + /** + * Ensures that only one instance of AbstractModel is created + * + * @override + */ + getModel: function () { + if (!this.model) { + this.model = this._super.apply(this, arguments); + } + return this.model; + }, + /** + * This is useful to customize the actual class to use before calling + * createView. + * + * @param {Controller} Controller + */ + setController: function (Controller) { + this.Controller = Controller; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} [action] + * @param {Object} [action.context || {}] + * @param {boolean} [action.context.no_breadcrumbs=false] + * @param {integer} [action.context.active_id] + * @param {integer[]} [action.context.active_ids] + * @param {Object} [action.controlPanelFieldsView] + * @param {string} [action.display_name] + * @param {Array[]} [action.domain=[]] + * @param {string} [action.help] + * @param {integer} [action.id] + * @param {integer} [action.limit] + * @param {string} [action.name] + * @param {string} [action.res_model] + * @param {string} [action.target] + * @param {boolean} [action.useSampleModel] + * @returns {Object} + */ + _extractParamsFromAction: function (action) { + action = action || {}; + var context = action.context || {}; + var inline = action.target === 'inline'; + const params = { + actionId: action.id || false, + actionViews: action.views || [], + activateDefaultFavorite: !context.active_id && !context.active_ids, + context: action.context || {}, + controlPanelFieldsView: action.controlPanelFieldsView, + currentId: action.res_id ? action.res_id : undefined, // load returns 0 + displayName: action.display_name || action.name, + domain: action.domain || [], + limit: action.limit, + modelName: action.res_model, + noContentHelp: action.help, + searchMenuTypes: inline ? [] : this.searchMenuTypes, + withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true, + withControlPanel: this.withControlPanel, + withSearchBar: inline ? false : this.withSearchBar, + withSearchPanel: this.withSearchPanel, + }; + if ('useSampleModel' in action) { + params.useSampleModel = action.useSampleModel; + } + return params; + }, + /** + * Processes a fieldsView. In particular, parses its arch. + * + * @private + * @param {Object} fieldsView + * @param {string} fieldsView.arch + * @returns {Object} the processed fieldsView + */ + _processFieldsView: function (fieldsView) { + var fv = _.extend({}, fieldsView); + fv.arch = viewUtils.parseArch(fv.arch); + fv.viewFields = _.defaults({}, fv.viewFields, fv.fields); + return fv; + }, + /** + * Hook to update the renderer, controller and load params with the result + * of a search (i.e. a context, a domain and a groupBy). + * + * @private + * @param {Object} searchQuery + * @param {Object} searchQuery.context + * @param {Object} [searchQuery.timeRanges] + * @param {Array[]} searchQuery.domain + * @param {string[]} searchQuery.groupBy + */ + _updateMVCParams: function (searchQuery) { + this.loadParams = _.extend(this.loadParams, { + context: searchQuery.context, + domain: searchQuery.domain, + groupedBy: searchQuery.groupBy, + }); + this.loadParams.orderedBy = Array.isArray(searchQuery.orderedBy) && searchQuery.orderedBy.length ? + searchQuery.orderedBy : + this.loadParams.orderedBy; + if (searchQuery.timeRanges) { + this.loadParams.timeRanges = searchQuery.timeRanges; + this.rendererParams.timeRanges = searchQuery.timeRanges; + } + }, +}); + +return AbstractView; + +}); diff --git a/addons/web/static/src/js/views/action_model.js b/addons/web/static/src/js/views/action_model.js new file mode 100644 index 00000000..c3b69271 --- /dev/null +++ b/addons/web/static/src/js/views/action_model.js @@ -0,0 +1,236 @@ +odoo.define("web/static/src/js/views/action_model.js", function (require) { + "use strict"; + + const Domain = require("web.Domain"); + const { FACET_ICONS } = require("web.searchUtils"); + const { Model } = require("web/static/src/js/model.js"); + const { parseArch } = require("web.viewUtils"); + const pyUtils = require("web.py_utils"); + const Registry = require("web.Registry"); + + const isNotNull = (value) => value !== null && value !== undefined; + const isObject = (obj) => typeof obj === "object" && obj !== null; + + /** + * @extends Model.Extension + */ + class ActionModelExtension extends Model.Extension { + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Initiates the asynchronous tasks of the extension and returns a + * promise resolved as soon as all the informations necessary to build + * the search query are ready. + * @returns {Promise} + */ + async callLoad() { + this.loadPromise = super.callLoad(...arguments); + await this.loadPromise; + } + + /** + * Returns a promise resolved when the extension is completely ready. + * @returns {Promise} + */ + async isReady() { + await this.loadPromise; + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @abstract + * @param {Object} archs + * @param {string | null} [viewType=null] + * @returns {null} + */ + static extractArchInfo() { + return null; + } + } + + /** + * @extends Model + */ + class ActionModel extends Model { + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + */ + get(property) { + switch (property) { + case "query": return this.config.searchQuery || this._getQuery(); + case "facets": return this._getFacets(); + } + return super.get(...arguments); + } + + /** + * Returns a promise resolved when all extensions are completely ready. + * @returns {Promise} + */ + async isReady() { + await this._awaitExtensions(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + async _awaitExtensions() { + await Promise.all(this.extensions.flat().map( + (extension) => extension.isReady() + )); + } + + /** + * @override + */ + __get(excluded, property) { + const results = super.__get(...arguments); + switch (property) { + case "domain": return [this.config.domain, ...results]; + case "context": return [this.config.context, ...results]; + } + return results; + } + + /** + * Validates and formats all facets given by the extensions. This is + * done here rather than in the search bar because the searchMenuTypes + * are available only to the model. + * @private + * @returns {Object[]} + */ + _getFacets() { + const types = this.config.searchMenuTypes || []; + const isValidType = (type) => ( + !['groupBy', 'comparison'].includes(type) || types.includes(type) + ); + const facets = []; + for (const extension of this.extensions.flat()) { + for (const facet of extension.get("facets") || []) { + if (!isValidType(facet.type)) { + continue; + } + facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or"); + if (facet.type in FACET_ICONS) { + facet.icon = FACET_ICONS[facet.type]; + } + facets.push(facet); + } + } + return facets; + } + + /** + * @typedef TimeRanges + * @property {string} fieldName + * @property {string} comparisonRangeId + * @property {Array[]} range + * @property {string} rangeDescription + * @property {Array[]} comparisonRange + * @property {string} comparisonRangeDescription + */ + /** + * @typedef Query + * @property {Object} context + * @property {Array[]} domain + * @property {string[]} groupBy + * @property {string[]} orderedBy + * @property {TimeRanges?} timeRanges + */ + /** + * @private + * @returns {Query} + */ + _getQuery() { + const evalContext = this.env.session.user_context; + const contexts = this.__get(null, "context"); + const domains = this.__get(null, "domain"); + const query = { + context: pyUtils.eval("contexts", contexts, evalContext), + domain: Domain.prototype.normalizeArray( + pyUtils.eval("domains", domains, evalContext) + ), + orderedBy: this.get("orderedBy") || [], + }; + const searchMenuTypes = this.config.searchMenuTypes || []; + if (searchMenuTypes.includes("groupBy")) { + query.groupBy = this.get("groupBy") || []; + } else { + query.groupBy = []; + } + if (searchMenuTypes.includes("comparison")) { + query.timeRanges = this.get("timeRanges") || {}; + } + return query; + } + + /** + * Overridden to trigger a "search" event as soon as the query data + * are ready. + * @override + */ + async _loadExtensions({ isInitialLoad }) { + await super._loadExtensions(...arguments); + if (!isInitialLoad) { + this.trigger("search", this.get("query")); + await this._awaitExtensions(); + } + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @param {Object} archs + * @param {string | null} [viewType=null] + * @returns {Object} + */ + static extractArchInfo(archs, viewType = null) { + const parsedArchs = {}; + if (!archs.search) { + archs.search = "<search/>"; + } + for (const key in archs) { + const { attrs, children } = parseArch(archs[key]); + const objectChildren = children.filter(isObject); + parsedArchs[key] = { + attrs, + children: objectChildren, + }; + } + const archInfo = {}; + for (const key of this.registry.keys()) { + const extension = this.registry.get(key); + const result = extension.extractArchInfo(parsedArchs, viewType); + if (isNotNull(result)) { + archInfo[key] = result; + } + } + return archInfo; + } + } + + ActionModel.Extension = ActionModelExtension; + ActionModel.registry = new Registry(null, + (value) => value.prototype instanceof ActionModel.Extension + ); + + return ActionModel; +}); diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js new file mode 100644 index 00000000..4cbe9027 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -0,0 +1,883 @@ +odoo.define('web.BasicController', function (require) { +"use strict"; + +/** + * The BasicController is mostly here to share code between views that will use + * a BasicModel (or a subclass). Currently, the BasicViews are the form, list + * and kanban views. + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var TranslationDialog = require('web.TranslationDialog'); + +var _t = core._t; + +var BasicController = AbstractController.extend(FieldManagerMixin, { + events: Object.assign({}, AbstractController.prototype.events, { + 'click .o_content': '_onContentClicked', + }), + custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { + discard_changes: '_onDiscardChanges', + pager_changed: '_onPagerChanged', + reload: '_onReload', + resequence_records: '_onResequenceRecords', + set_dirty: '_onSetDirty', + load_optional_fields: '_onLoadOptionalFields', + save_optional_fields: '_onSaveOptionalFields', + translate: '_onTranslate', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.archiveEnabled + * @param {boolean} params.confirmOnDelete + * @param {boolean} params.hasButtons + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.archiveEnabled = params.archiveEnabled; + this.confirmOnDelete = params.confirmOnDelete; + this.hasButtons = params.hasButtons; + FieldManagerMixin.init.call(this, this.model); + this.mode = params.mode || 'readonly'; + // savingDef is used to ensure that we always wait for pending save + // operations to complete before checking if there are changes to + // discard when discardChanges is called + this.savingDef = Promise.resolve(); + // discardingDef is used to ensure that we don't ask twice the user if + // he wants to discard changes, when 'canBeDiscarded' is called several + // times "in parallel" + this.discardingDef = null; + this.viewId = params.viewId; + }, + /** + * @override + * @returns {Promise} + */ + start: async function () { + // add classname to reflect the (absence of) access rights (used to + // correctly display the nocontent helper) + this.$el.toggleClass('o_cannot_create', !this.activeActions.create); + await this._super(...arguments); + }, + /** + * Called each time the controller is dettached into the DOM + */ + on_detach_callback() { + this._super.apply(this, arguments); + this.renderer.resetLocalState(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Determines if we can discard the current changes. If the model is not + * dirty, that is not a problem. However, if it is dirty, we have to ask + * the user for confirmation. + * + * @override + * @param {string} [recordID] - default to main recordID + * @returns {Promise<boolean>} + * resolved if can be discarded, a boolean value is given to tells + * if there is something to discard or not + * rejected otherwise + */ + canBeDiscarded: function (recordID) { + var self = this; + if (this.discardingDef) { + // discard dialog is already open + return this.discardingDef; + } + if (!this.isDirty(recordID)) { + return Promise.resolve(false); + } + + var message = _t("The record has been modified, your changes will be discarded. Do you want to proceed?"); + this.discardingDef = new Promise(function (resolve, reject) { + var dialog = Dialog.confirm(self, message, { + title: _t("Warning"), + confirm_callback: () => { + resolve(true); + self.discardingDef = null; + }, + cancel_callback: () => { + reject(); + self.discardingDef = null; + }, + }); + dialog.on('closed', self.discardingDef, reject); + }); + return this.discardingDef; + }, + /** + * Ask the renderer if all associated field widget are in a valid state for + * saving (valid value and non-empty value for required fields). If this is + * not the case, this notifies the user with a warning containing the names + * of the invalid fields. + * + * Note: changing the style of invalid fields is the renderer's job. + * + * @param {string} [recordID] - default to main recordID + * @return {boolean} + */ + canBeSaved: function (recordID) { + var fieldNames = this.renderer.canBeSaved(recordID || this.handle); + if (fieldNames.length) { + this._notifyInvalidFields(fieldNames); + return false; + } + return true; + }, + /** + * Waits for the mutex to be unlocked and for changes to be saved, then + * calls _.discardChanges. + * This ensures that the confirm dialog isn't displayed directly if there is + * a pending 'write' rpc. + * + * @see _.discardChanges + */ + discardChanges: function (recordID, options) { + return Promise.all([this.mutex.getUnlockedDef(), this.savingDef]) + .then(this._discardChanges.bind(this, recordID || this.handle, options)); + }, + /** + * Method that will be overridden by the views with the ability to have selected ids + * + * @returns {Array} + */ + getSelectedIds: function () { + return []; + }, + /** + * Returns true iff the given recordID (or the main recordID) is dirty. + * + * @param {string} [recordID] - default to main recordID + * @returns {boolean} + */ + isDirty: function (recordID) { + return this.model.isDirty(recordID || this.handle); + }, + /** + * Saves the record whose ID is given if necessary (@see _saveRecord). + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + * Rejected if the record can't be saved + */ + saveRecord: function (recordID, options) { + var self = this; + // Some field widgets can't detect (all) their changes immediately or + // may have to validate them before notifying them, so we ask them to + // commit their current value before saving. This has to be done outside + // of the mutex protection of saving because commitChanges will trigger + // changes and these are also protected. However, we must wait for the + // mutex to be idle to ensure that onchange RPCs returned before asking + // field widgets to commit their value (and validate it, for instance + // for one2many with required fields). So the actual saving has to be + // done after these changes. Also the commitChanges operation might not + // be synchronous for other reason (e.g. the x2m fields will ask the + // user if some discarding has to be made). This operation must also be + // mutex-protected as commitChanges function of x2m has to be aware of + // all final changes made to a row. + var unlockedMutex = this.mutex.getUnlockedDef() + .then(function () { + return self.renderer.commitChanges(recordID || self.handle); + }) + .then(function () { + return self.mutex.exec(self._saveRecord.bind(self, recordID, options)); + }); + this.savingDef = new Promise(function (resolve) { + unlockedMutex.then(resolve).guardedCatch(resolve); + }); + + return unlockedMutex; + }, + /** + * @override + * @returns {Promise} + */ + update: async function (params, options) { + this.mode = params.mode || this.mode; + return this._super(params, options); + }, + /** + * @override + */ + reload: function (params) { + if (params && params.controllerState) { + if (params.controllerState.currentId) { + params.currentId = params.controllerState.currentId; + } + params.ids = params.controllerState.resIds; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Does the necessary action when trying to "abandon" a given record (e.g. + * when trying to make a new record readonly without having saved it). By + * default, if the abandoned record is the main view one, the only possible + * action is to leave the current view. Otherwise, it is a x2m line, ask the + * model to remove it. + * + * @private + * @param {string} [recordID] - default to main recordID + */ + _abandonRecord: function (recordID) { + recordID = recordID || this.handle; + if (recordID === this.handle) { + this.trigger_up('history_back'); + } else { + this.model.removeLine(recordID); + } + }, + /** + * We override applyChanges (from the field manager mixin) to protect it + * with a mutex. + * + * @override + */ + _applyChanges: function (dataPointID, changes, event) { + var _super = FieldManagerMixin._applyChanges.bind(this); + return this.mutex.exec(function () { + return _super(dataPointID, changes, event); + }); + }, + /** + * Archive the current selection + * + * @private + * @param {number[]} ids + * @param {boolean} archive + * @returns {Promise} + */ + _archive: async function (ids, archive) { + if (ids.length === 0) { + return Promise.resolve(); + } + if (archive) { + await this.model.actionArchive(ids, this.handle); + } else { + await this.model.actionUnarchive(ids, this.handle); + } + return this.update({}, {reload: false}); + }, + /** + * When the user clicks on a 'action button', this function determines what + * should happen. + * + * @private + * @param {Object} attrs the attrs of the button clicked + * @param {Object} [record] the current state of the view + * @returns {Promise} + */ + _callButtonAction: function (attrs, record) { + record = record || this.model.get(this.handle); + const actionData = Object.assign({}, attrs, { + context: record.getContext({additionalContext: attrs.context || {}}) + }); + const recordData = { + context: record.getContext(), + currentID: record.data.id, + model: record.model, + resIDs: record.res_ids, + }; + return this._executeButtonAction(actionData, recordData); + }, + /** + * Called by the field manager mixin to confirm that a change just occured + * (after that potential onchanges have been applied). + * + * Basically, this only relays the notification to the renderer with the + * new state. + * + * @param {string} id - the id of one of the view's records + * @param {string[]} fields - the changed fields + * @param {OdooEvent} e - the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, e) { + if (e.name === 'discard_changes' && e.target.reset) { + // the target of the discard event is a field widget. In that + // case, we simply want to reset the specific field widget, + // not the full view + return e.target.reset(this.model.get(e.target.dataPointID), e, true); + } + + var state = this.model.get(this.handle); + return this.renderer.confirmChange(state, id, fields, e); + }, + /** + * Ask the user to confirm he wants to save the record + * @private + */ + _confirmSaveNewRecord: function () { + var self = this; + var def = new Promise(function (resolve, reject) { + var message = _t("You need to save this new record before editing the translation. Do you want to proceed?"); + var dialog = Dialog.confirm(self, message, { + title: _t("Warning"), + confirm_callback: resolve.bind(self, true), + cancel_callback: reject, + }); + dialog.on('closed', self, reject); + }); + return def; + }, + /** + * Delete records (and ask for confirmation if necessary) + * + * @param {string[]} ids list of local record ids + */ + _deleteRecords: function (ids) { + var self = this; + function doIt() { + return self.model + .deleteRecords(ids, self.modelName) + .then(self._onDeletedRecords.bind(self, ids)); + } + if (this.confirmOnDelete) { + const message = ids.length > 1 ? + _t("Are you sure you want to delete these records?") : + _t("Are you sure you want to delete this record?"); + Dialog.confirm(this, message, { confirm_callback: doIt }); + } else { + doIt(); + } + }, + /** + * Disables buttons so that they can't be clicked anymore. + * + * @private + */ + _disableButtons: function () { + if (this.$buttons) { + this.$buttons.find('button').attr('disabled', true); + } + }, + /** + * Discards the changes made to the record whose ID is given, if necessary. + * Automatically leaves to default mode for the given record. + * + * @private + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.readonlyIfRealDiscard=false] + * After discarding record changes, the usual option is to make the + * record readonly. However, the action manager calls this function + * at inappropriate times in the current code and in that case, we + * don't want to go back to readonly if there is nothing to discard + * (e.g. when switching record in edit mode in form view, we expect + * the new record to be in edit mode too, but the view manager calls + * this function as the URL changes...) @todo get rid of this when + * the webclient/action_manager's hashchange mechanism is improved. + * @param {boolean} [options.noAbandon=false] + * @returns {Promise} + */ + _discardChanges: function (recordID, options) { + var self = this; + recordID = recordID || this.handle; + options = options || {}; + return this.canBeDiscarded(recordID) + .then(function (needDiscard) { + if (options.readonlyIfRealDiscard && !needDiscard) { + return; + } + self.model.discardChanges(recordID); + if (options.noAbandon) { + return; + } + if (self.model.canBeAbandoned(recordID)) { + self._abandonRecord(recordID); + return; + } + return self._confirmSave(recordID); + }); + }, + /** + * Enables buttons so they can be clicked again. + * + * @private + */ + _enableButtons: function () { + if (this.$buttons) { + this.$buttons.find('button').removeAttr('disabled'); + } + }, + /** + * Executes the action associated with a button + * + * @private + * @param {Object} actionData: the descriptor of the action + * @param {string} actionData.type: the button's action's type, accepts "object" or "action" + * @param {string} actionData.name: the button's action's name + * either the model method's name for type "object" + * or the action's id in database, or xml_id + * @param {string} actionData.context: the action's execution context + * + * @param {Object} recordData: basic information on the current record(s) + * @param {number[]} recordData.resIDs: record ids: + * - on which an object method applies + * - that will be used as active_ids to load an action + * @param {string} recordData.model: model name + * @param {Object} recordData.context: the records' context, will be used to load + * the action, and merged into actionData.context at execution time + * + * @returns {Promise} + */ + async _executeButtonAction(actionData, recordData) { + const prom = new Promise((resolve, reject) => { + this.trigger_up('execute_action', { + action_data: actionData, + env: recordData, + on_closed: () => this.isDestroyed() ? Promise.resolve() : this.reload(), + on_success: resolve, + on_fail: () => this.update({}, { reload: false }).then(reject).guardedCatch(reject) + }); + }); + return this.alive(prom); + }, + /** + * Override to add the current record ID (currentId) and the list of ids + * (resIds) in the current dataPoint to the exported state. + * + * @override + */ + exportState: function () { + var state = this._super.apply(this, arguments); + var env = this.model.get(this.handle, {env: true}); + return _.extend(state, { + currentId: env.currentId, + resIds: env.ids, + }); + }, + /** + * Compute the optional fields local storage key using the given parts. + * + * @param {Object} keyParts + * @param {string} keyParts.viewType view type + * @param {string} [keyParts.relationalField] name of the field with subview + * @param {integer} [keyParts.subViewId] subview id + * @param {string} [keyParts.subViewType] type of the subview + * @param {Object} keyParts.fields fields + * @param {string} keyParts.fields.name field name + * @param {string} keyParts.fields.type field type + * @returns {string} local storage key for optional fields in this view + * @private + */ + _getOptionalFieldsLocalStorageKey: function (keyParts) { + keyParts.model = this.modelName; + keyParts.viewType = this.viewType; + keyParts.viewId = this.viewId; + + var parts = [ + 'model', + 'viewType', + 'viewId', + 'relationalField', + 'subViewType', + 'subViewId', + ]; + + var viewIdentifier = parts.reduce(function (identifier, partName) { + if (partName in keyParts) { + return identifier + ',' + keyParts[partName]; + } + return identifier; + }, 'optional_fields'); + + viewIdentifier = + keyParts.fields.sort(this._nameSortComparer) + .reduce(function (identifier, field) { + return identifier + ',' + field.name; + }, viewIdentifier); + + return viewIdentifier; + }, + /** + * Return the params (currentMinimum, limit and size) to pass to the pager, + * according to the current state. + * + * @private + * @returns {Object} + */ + _getPagingInfo: function (state) { + const isGrouped = state.groupedBy && state.groupedBy.length; + return { + currentMinimum: (isGrouped ? state.groupsOffset : state.offset) + 1, + limit: isGrouped ? state.groupsLimit : state.limit, + size: isGrouped ? state.groupsCount : state.count, + }; + }, + /** + * Return the new actionMenus props. + * + * @override + * @private + */ + _getActionMenuItems: function (state) { + return { + activeIds: this.getSelectedIds(), + context: state.getContext(), + }; + }, + /** + * Sort function used to sort the fields by names, to compute the optional fields keys + * + * @param {Object} left + * @param {Object} right + * @private + */ + _nameSortComparer: function(left, right) { + return left.name < right.name ? -1 : 1; + }, + /** + * Helper function to display a warning that some fields have an invalid + * value. This is used when a save operation cannot be completed. + * + * @private + * @param {string[]} invalidFields - list of field names + */ + _notifyInvalidFields: function (invalidFields) { + var record = this.model.get(this.handle, {raw: true}); + var fields = record.fields; + var warnings = invalidFields.map(function (fieldName) { + var fieldStr = fields[fieldName].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("Invalid fields:"), warnings.join('')); + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @see _deleteRecord + * @param {string[]} ids list of deleted ids (basic model local handles) + */ + _onDeletedRecords: function (ids) { + this.update({}); + }, + /** + * Saves the record whose ID is given, if necessary. Automatically leaves + * edit mode for the given record, unless told otherwise. + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.stayInEdit=false] + * if true, leave the record in edit mode after save + * @param {boolean} [options.reload=true] + * if true, reload the record after (real) save + * @param {boolean} [options.savePoint=false] + * if true, the record will only be 'locally' saved: its changes + * will move from the _changes key to the data key + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + * Rejected if the record can't be saved + */ + _saveRecord: function (recordID, options) { + recordID = recordID || this.handle; + options = _.defaults(options || {}, { + stayInEdit: false, + reload: true, + savePoint: false, + }); + + // Check if the view is in a valid state for saving + // Note: it is the model's job to do nothing if there is nothing to save + if (this.canBeSaved(recordID)) { + var self = this; + var saveDef = this.model.save(recordID, { // Save then leave edit mode + reload: options.reload, + savePoint: options.savePoint, + viewType: options.viewType, + }); + if (!options.stayInEdit) { + saveDef = saveDef.then(function (fieldNames) { + var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly', recordID); + return def.then(function () { + return fieldNames; + }); + }); + } + return saveDef; + } else { + return Promise.reject("SaveRecord: this.canBeSave is false"); // Cannot be saved + } + }, + /** + * Change the mode for the record associated to the given ID. + * If the given recordID is the view's main one, then the whole view mode is + * changed (@see BasicController.update). + * + * @private + * @param {string} mode - 'readonly' or 'edit' + * @param {string} [recordID] + * @returns {Promise} + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) === this.handle) { + return this.update({mode: mode}, {reload: false}).then(function () { + // necessary to allow all sub widgets to use their dimensions in + // layout related activities, such as autoresize on fieldtexts + core.bus.trigger('DOM_updated'); + }); + } + return Promise.resolve(); + }, + /** + * To override such that it returns true iff the primary action button must + * bounce when the user clicked on the given element, according to the + * current state of the view. + * + * @private + * @param {HTMLElement} element the node the user clicked on + * @returns {boolean} + */ + _shouldBounceOnClick: function (/* element */) { + return false; + }, + /** + * Helper method, to get the current environment variables from the model + * and notifies the component chain (by bubbling an event up) + * + * @private + * @param {Object} [newProps={}] + */ + _updateControlPanel: function (newProps = {}) { + const state = this.model.get(this.handle); + const props = Object.assign(newProps, { + actionMenus: this._getActionMenuItems(state), + pager: this._getPagingInfo(state), + title: this.getTitle(), + }); + return this.updateControlPanel(props); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user clicks on the 'content' part of the controller + * (typically the renderer area). Makes the first primary button in the + * control panel bounce, in some situations (see _shouldBounceOnClick). + * + * @private + * @param {MouseEvent} ev + */ + _onContentClicked(ev) { + if (this.$buttons && this._shouldBounceOnClick(ev.target)) { + this.$buttons.find('.btn-primary:visible:first').odooBounce(); + } + }, + /** + * Called when a list element asks to discard the changes made to one of + * its rows. It can happen with a x2many (if we are in a form view) or with + * a list view. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardChanges: function (ev) { + var self = this; + ev.stopPropagation(); + var recordID = ev.data.recordID; + this._discardChanges(recordID) + .then(function () { + // TODO this will tell the renderer to rerender the widget that + // asked for the discard but will unfortunately lose the click + // made on another row if any + self._confirmChange(recordID, [ev.data.fieldName], ev) + .then(ev.data.onSuccess).guardedCatch(ev.data.onSuccess); + }) + .guardedCatch(ev.data.onFailure); + }, + /** + * Forces to save directly the changes if the controller is in readonly, + * because in that case the changes come from widgets that are editable even + * in readonly (e.g. Priority). + * + * @private + * @param {OdooEvent} ev + */ + _onFieldChanged: function (ev) { + if (this.mode === 'readonly' && !('force_save' in ev.data)) { + ev.data.force_save = true; + } + FieldManagerMixin._onFieldChanged.apply(this, arguments); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onPagerChanged: async function (ev) { + ev.stopPropagation(); + const { currentMinimum, limit } = ev.data; + const state = this.model.get(this.handle, { raw: true }); + const reloadParams = state.groupedBy && state.groupedBy.length ? { + groupsLimit: limit, + groupsOffset: currentMinimum - 1, + } : { + limit, + offset: currentMinimum - 1, + }; + await this.reload(reloadParams); + // reset the scroll position to the top on page changed only + if (state.limit === limit) { + this.trigger_up('scrollTo', { top: 0 }); + } + }, + /** + * When a reload event triggers up, we need to reload the full view. + * For example, after a form view dialog saved some data. + * + * @todo: rename db_id into handle + * + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {string} [ev.data.db_id] handle of the data to reload and + * re-render (reload the whole form by default) + * @param {string[]} [ev.data.fieldNames] list of the record's fields to + * reload + * @param {Function} [ev.data.onSuccess] callback executed after reload is resolved + * @param {Function} [ev.data.onFailure] callback executed when reload is rejected + */ + _onReload: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + var data = ev && ev.data || {}; + var handle = data.db_id; + var prom; + if (handle) { + // reload the relational field given its db_id + prom = this.model.reload(handle).then(this._confirmSave.bind(this, handle)); + } else { + // no db_id given, so reload the main record + prom = this.reload({ + fieldNames: data.fieldNames, + keepChanges: data.keepChanges || false, + }); + } + prom.then(ev.data.onSuccess).guardedCatch(ev.data.onFailure); + }, + /** + * Resequence records in the given order. + * + * @private + * @param {OdooEvent} ev + * @param {string[]} ev.data.recordIds + * @param {integer} ev.data.offset + * @param {string} ev.data.handleField + */ + _onResequenceRecords: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + this.trigger_up('mutexify', { + action: async () => { + let state = this.model.get(this.handle); + const resIDs = ev.data.recordIds + .map(recordID => state.data.find(d => d.id === recordID).res_id); + const options = { + offset: ev.data.offset, + field: ev.data.handleField, + }; + await this.model.resequence(this.modelName, resIDs, this.handle, options); + this._updateControlPanel(); + state = this.model.get(this.handle); + return this._updateRendererState(state, { noRender: true }); + }, + }); + }, + /** + * Load the optional columns settings in local storage for this view + * + * @param {OdooEvent} ev + * @param {Object} ev.data.keyParts see _getLocalStorageKey + * @param {function} ev.data.callback function to call with the result + * @private + */ + _onLoadOptionalFields: function (ev) { + var res = this.call( + 'local_storage', + 'getItem', + this._getOptionalFieldsLocalStorageKey(ev.data.keyParts) + ); + ev.data.callback(res); + }, + /** + * Save the optional columns settings in local storage for this view + * + * @param {OdooEvent} ev + * @param {Object} ev.data.keyParts see _getLocalStorageKey + * @param {Array<string>} ev.data.optionalColumnsEnabled list of optional + * field names that have been enabled + * @private + */ + _onSaveOptionalFields: function (ev) { + this.call( + 'local_storage', + 'setItem', + this._getOptionalFieldsLocalStorageKey(ev.data.keyParts), + ev.data.optionalColumnsEnabled + ); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onSetDirty: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + this.model.setDirty(ev.data.dataPointID); + }, + /** + * open the translation view for the current field + * + * @private + * @param {OdooEvent} ev + */ + _onTranslate: async function (ev) { + ev.stopPropagation(); + + if (this.model.isNew(ev.data.id)) { + await this._confirmSaveNewRecord(); + var updatedFields = await this.saveRecord(ev.data.id, { stayInEdit: true }); + await this._confirmChange(ev.data.id, updatedFields, ev); + } + var record = this.model.get(ev.data.id, { raw: true }); + var res_id = record.res_id || record.res_ids[0]; + var result = await this._rpc({ + route: '/web/dataset/call_button', + params: { + model: 'ir.translation', + method: 'translate_fields', + args: [record.model, res_id, ev.data.fieldName], + kwargs: { context: record.getContext() }, + } + }); + + this.translationDialog = new TranslationDialog(this, { + domain: result.domain, + searchName: result.context.search_default_name, + fieldName: ev.data.fieldName, + userLanguageValue: ev.target.value || '', + dataPointID: record.id, + isComingFromTranslationAlert: ev.data.isComingFromTranslationAlert, + isText: result.context.translation_type === 'text', + showSrc: result.context.translation_show_src, + }); + return this.translationDialog.open(); + }, +}); + +return BasicController; +}); diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js new file mode 100644 index 00000000..1e9868e0 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -0,0 +1,5190 @@ +odoo.define('web.BasicModel', function (require) { +"use strict"; + +/** + * Basic Model + * + * This class contains all the logic necessary to communicate between the + * python models and the web client. More specifically, its job is to give a + * simple unified API to the rest of the web client (in particular, the views and + * the field widgets) to query and modify actual records in db. + * + * From a high level perspective, BasicModel is essentially a hashmap with + * integer keys and some data and metadata object as value. Each object in this + * hashmap represents a piece of data, and can be reloaded and modified by using + * its id as key in many methods. + * + * Here is a description of what those data point look like: + * var dataPoint = { + * _cache: {Object|undefined} + * _changes: {Object|null}, + * aggregateValues: {Object}, + * context: {Object}, + * count: {integer}, + * data: {Object|Object[]}, + * domain: {*[]}, + * fields: {Object}, + * fieldsInfo: {Object}, + * getContext: {function}, + * getDomain: {function}, + * getFieldNames: {function}, + * groupedBy: {string[]}, + * id: {integer}, + * isOpen: {boolean}, + * loadMoreOffset: {integer}, + * limit: {integer}, + * model: {string}, + * offset: {integer}, + * openGroupByDefault: {boolean}, + * orderedBy: {Object[]}, + * orderedResIDs: {integer[]}, + * parentID: {string}, + * rawContext: {Object}, + * relationField: {string}, + * res_id: {integer|null}, + * res_ids: {integer[]}, + * specialData: {Object}, + * _specialDataCache: {Object}, + * static: {boolean}, + * type: {string} 'record' | 'list' + * value: ?, + * }; + * + * Notes: + * - id: is totally unrelated to res_id. id is a web client local concept + * - res_id: if set to a number or a virtual id (a virtual id is a character + * string composed of an integer and has a dash and other information), it + * is an actual id for a record in the server database. If set to + * 'virtual_' + number, it is a record not yet saved (so, in create mode). + * - res_ids: if set, it represent the context in which the data point is actually + * used. For example, a given record in a form view (opened from a list view) + * might have a res_id = 2 and res_ids = [1,2,3] + * - offset: this is mainly used for pagination. Useful when we need to load + * another page, then we can simply change the offset and reload. + * - count is basically the number of records being manipulated. We can't use + * res_ids, because we might have a very large number of records, or a + * domain, and the res_ids would be the current page, not the full set. + * - model is the actual name of a (odoo) model, such as 'res.partner' + * - fields contains the description of all the fields from the model. Note that + * these properties might have been modified by a view (for example, with + * required=true. So, the fields kind of depends of the context of the + * data point. + * - field_names: list of some relevant field names (string). Usually, it + * denotes the fields present in the view. Only those fields should be + * loaded. + * - _cache and _changes are private, they should not leak out of the basicModel + * and be used by anyone else. + * + * Commands: + * commands are the base commands for x2many (0 -> 6), but with a + * slight twist: each [0, _, values] command is augmented with a virtual id: + * it means that when the command is added in basicmodel, it generates an id + * looking like this: 'virtual_' + number, and uses this id to identify the + * element, so it can be edited later. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var Context = require('web.Context'); +var core = require('web.core'); +var Domain = require('web.Domain'); +const pyUtils = require('web.py_utils'); +var session = require('web.session'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); +var localStorage = require('web.local_storage'); + +var _t = core._t; + +// field types that can be aggregated in grouped views +const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary']; + +var x2ManyCommands = { + // (0, virtualID, {values}) + CREATE: 0, + create: function (virtualID, values) { + delete values.id; + return [x2ManyCommands.CREATE, virtualID || false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + update: function (id, values) { + delete values.id; + return [x2ManyCommands.UPDATE, id, values]; + }, + // (2, id[, _]) + DELETE: 2, + delete: function (id) { + return [x2ManyCommands.DELETE, id, false]; + }, + // (3, id[, _]) removes relation, but not linked record itself + FORGET: 3, + forget: function (id) { + return [x2ManyCommands.FORGET, id, false]; + }, + // (4, id[, _]) + LINK_TO: 4, + link_to: function (id) { + return [x2ManyCommands.LINK_TO, id, false]; + }, + // (5[, _[, _]]) + DELETE_ALL: 5, + delete_all: function () { + return [5, false, false]; + }, + // (6, _, ids) replaces all linked records with provided ids + REPLACE_WITH: 6, + replace_with: function (ids) { + return [6, false, ids]; + } +}; + +var BasicModel = AbstractModel.extend({ + // constants + OPEN_GROUP_LIMIT: 10, // after this limit, groups are automatically folded + + // list of models for which the DataManager's cache should be cleared on + // create, update and delete operations + noCacheModels: [ + 'ir.actions.act_window', + 'ir.filters', + 'ir.ui.view', + ], + + /** + * @override + */ + init: function () { + // this mutex is necessary to make sure some operations are done + // sequentially, for example, an onchange needs to be completed before a + // save is performed. + this.mutex = new concurrency.Mutex(); + + // this array is used to accumulate RPC requests done in the same call + // stack, so that they can be batched in the minimum number of RPCs + this.batchedRPCsRequests = []; + + this.localData = Object.create(null); + // used to generate dataPoint ids. Note that the counter is set to 0 for + // each instance, and this is mandatory for the sample data feature to + // work: we need both the main model and the sample model to generate the + // same datapoint ids for their common data (groups, when there are real + // groups in database), so that we can easily do the mapping between + // real and sample data. + this.__id = 0; + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object. + * The default record is added in the data directly. This is meant to be used + * by list or kanban controllers (i.e. not for x2manys in form views, as in + * this case, we store changes as commands). + * + * @param {string} listID a valid handle for a list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @returns {Promise<string>} resolves to the id of the new created record + */ + addDefaultRecord: function (listID, options) { + var self = this; + var list = this.localData[listID]; + var context = _.extend({}, this._getDefaultContext(list), this._getContext(list)); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: list.viewType, + }; + return this._makeDefaultRecord(list.model, params).then(function (id) { + list.count++; + if (position === 'top') { + list.data.unshift(id); + } else { + list.data.push(id); + } + var record = self.localData[id]; + list._cache[record.res_id] = id; + return id; + }); + }, + /** + * Completes the fields and fieldsInfo of a dataPoint with the given ones. + * It is useful for the cases where a record element is shared between + * various views, such as a one2many with a tree and a form view. + * + * @param {string} datapointID a valid element ID (of type 'list' or 'record') + * @param {Object} viewInfo + * @param {Object} viewInfo.fields + * @param {Object} viewInfo.fieldInfo + * @param {string} viewInfo.viewType + * @returns {Promise} resolved when the fieldInfo have been set on the given + * datapoint and all its children, and all rawChanges have been applied + */ + addFieldsInfo: async function (dataPointID, viewInfo) { + var dataPoint = this.localData[dataPointID]; + dataPoint.fields = _.extend({}, dataPoint.fields, viewInfo.fields); + // complete the given fieldInfo with the fields of the main view, so + // that those field will be reloaded if a reload is triggered by the + // secondary view + dataPoint.fieldsInfo = dataPoint.fieldsInfo || {}; + const mainFieldInfo = dataPoint.fieldsInfo[dataPoint[viewInfo.viewType]]; + dataPoint.fieldsInfo[viewInfo.viewType] = _.defaults({}, viewInfo.fieldInfo, mainFieldInfo); + + // Some fields in the new fields info might not be in the previous one, + // so we might have stored changes for them (e.g. coming from onchange + // RPCs), that we haven't been able to process earlier (because those + // fields were unknown at that time). So we now try to process them. + if (dataPoint.type === 'record') { + await this.applyRawChanges(dataPointID, viewInfo.viewType); + } + const proms = []; + const fieldInfo = dataPoint.fieldsInfo[viewInfo.viewType]; + // recursively apply the new field info on sub datapoints + if (dataPoint.type === 'list') { + // case 'list': on all datapoints in the list + Object.values(dataPoint._cache).forEach(subDataPointID => { + proms.push(this.addFieldsInfo(subDataPointID, { + fields: dataPoint.fields, + fieldInfo: dataPoint.fieldsInfo[viewInfo.viewType], + viewType: viewInfo.viewType, + })); + }); + } else { + // case 'record': on datapoints of all x2many fields + const values = _.extend({}, dataPoint.data, dataPoint._changes); + Object.keys(fieldInfo).forEach(fieldName => { + const fieldType = dataPoint.fields[fieldName].type; + if (fieldType === 'one2many' || fieldType === 'many2many') { + const mode = fieldInfo[fieldName].mode; + const views = fieldInfo[fieldName].views; + const x2mDataPointID = values[fieldName]; + if (views[mode] && x2mDataPointID) { + proms.push(this.addFieldsInfo(x2mDataPointID, { + fields: views[mode].fields, + fieldInfo: views[mode].fieldsInfo[mode], + viewType: mode, + })); + } + } + }); + } + return Promise.all(proms); + }, + /** + * Onchange RPCs may return values for fields that are not in the current + * view. Those fields might even be unknown when the onchange returns (e.g. + * in x2manys, we only know the fields that are used in the inner view, but + * not those used in the potential form view opened in a dialog when a sub- + * record is clicked). When this happens, we can't infer their type, so the + * given value can't be processed. It is instead stored in the '_rawChanges' + * key of the record, without any processing. Later on, if this record is + * displayed in another view (e.g. the user clicked on it in the x2many + * list, and the record opens in a dialog), those changes that were left + * behind must be applied. This function applies changes stored in + * '_rawChanges' for a given viewType. + * + * @param {string} recordID local resource id of a record + * @param {string} viewType the current viewType + * @returns {Promise<string>} resolves to the id of the record + */ + applyRawChanges: function (recordID, viewType) { + var record = this.localData[recordID]; + return this._applyOnChange(record._rawChanges, record, { viewType }).then(function () { + return record.id; + }); + }, + /** + * Returns true if a record can be abandoned. + * + * Case for not abandoning the record: + * + * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any + * `onchange` from a `default_get`) + * 2. registered in a list on addition + * + * 2.1. registered as non-new addition + * 2.2. registered as new additon on update + * + * 3. record is not new + * + * Otherwise, the record can be abandoned. + * + * This is useful when discarding changes on this record, as it means that + * we must keep the record even if some fields are invalids (e.g. required + * field is empty). + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + canBeAbandoned: function (id) { + // 1. no drop if flagged + if (this.localData[id]._noAbandon) { + return false; + } + // 2. no drop in a list on "ADD in some cases + var record = this.localData[id]; + var parent = this.localData[record.parentID]; + if (parent) { + var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id}); + if (entry) { + // 2.1. no drop on non-new addition in list + if (!entry.isNew) { + return false; + } + // 2.2. no drop on new addition on "UPDATE" + var lastEntry = _.last(parent._savePoint); + if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) { + return false; + } + } + } + // 3. drop new records + return this.isNew(id); + }, + /** + * Delete a list of records, then, if the records have a parent, reload it. + * + * @todo we should remove the deleted records from the localData + * @todo why can't we infer modelName? Because of grouped datapoint + * --> res_id doesn't correspond to the model and we don't have the + * information about the related model + * + * @param {string[]} recordIds list of local resources ids. They should all + * be of type 'record', be of the same model and have the same parent. + * @param {string} modelName mode name used to unlink the records + * @returns {Promise} + */ + deleteRecords: function (recordIds, modelName) { + var self = this; + var records = _.map(recordIds, function (id) { return self.localData[id]; }); + var context = _.extend(records[0].getContext(), session.user_context); + return this._rpc({ + model: modelName, + method: 'unlink', + args: [_.pluck(records, 'res_id')], + context: context, + }) + .then(function () { + _.each(records, function (record) { + var parent = record.parentID && self.localData[record.parentID]; + if (parent && parent.type === 'list') { + parent.data = _.without(parent.data, record.id); + delete self.localData[record.id]; + // Check if we are on last page and all records are deleted from current + // page i.e. if there is no state.data.length then go to previous page + if (!parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + } + } else { + record.res_ids.splice(record.offset, 1); + record.offset = Math.min(record.offset, record.res_ids.length - 1); + record.res_id = record.res_ids[record.offset]; + record.count--; + } + }); + // optionally clear the DataManager's cache + self._invalidateCache(records[0]); + }); + }, + /** + * Discard all changes in a local resource. Basically, it removes + * everything that was stored in a _changes key. + * + * @param {string} id local resource id + * @param {Object} [options] + * @param {boolean} [options.rollback=false] if true, the changes will + * be reset to the last _savePoint, otherwise, they are reset to null + */ + discardChanges: function (id, options) { + options = options || {}; + var element = this.localData[id]; + var isNew = this.isNew(id); + var rollback = 'rollback' in options ? options.rollback : isNew; + var initialOffset = element.offset; + element._domains = {}; + this._visitChildren(element, function (elem) { + if (rollback && elem._savePoint) { + if (elem._savePoint instanceof Array) { + elem._changes = elem._savePoint.slice(0); + } else { + elem._changes = _.extend({}, elem._savePoint); + } + elem._isDirty = !isNew; + } else { + elem._changes = null; + elem._isDirty = false; + } + elem.offset = 0; + if (elem.tempLimitIncrement) { + elem.limit -= elem.tempLimitIncrement; + delete elem.tempLimitIncrement; + } + }); + element.offset = initialOffset; + }, + /** + * Duplicate a record (by calling the 'copy' route) + * + * @param {string} recordID id for a local resource + * @returns {Promise<string>} resolves to the id of duplicate record + */ + duplicateRecord: function (recordID) { + var self = this; + var record = this.localData[recordID]; + var context = this._getContext(record); + return this._rpc({ + model: record.model, + method: 'copy', + args: [record.data.id], + context: context, + }) + .then(function (res_id) { + var index = record.res_ids.indexOf(record.res_id); + record.res_ids.splice(index + 1, 0, res_id); + return self.load({ + fieldsInfo: record.fieldsInfo, + fields: record.fields, + modelName: record.model, + res_id: res_id, + res_ids: record.res_ids.slice(0), + viewType: record.viewType, + context: context, + }); + }); + }, + /** + * For list resources, this freezes the current records order. + * + * @param {string} listID a valid element ID of type list + */ + freezeOrder: function (listID) { + var list = this.localData[listID]; + if (list.type === 'record') { + return; + } + list = this._applyX2ManyOperations(list); + this._sortList(list); + this.localData[listID].orderedResIDs = list.res_ids; + }, + /** + * The __get method first argument is the handle returned by the load method. + * It is optional (the handle can be undefined). In some case, it makes + * sense to use the handle as a key, for example the BasicModel holds the + * data for various records, each with its local ID. + * + * synchronous method, it assumes that the resource has already been loaded. + * + * @param {string} id local id for the resource + * @param {any} options + * @param {boolean} [options.env=false] if true, will only return res_id + * (if record) or res_ids (if list) + * @param {boolean} [options.raw=false] if true, will not follow relations + * @returns {Object} + */ + __get: function (id, options) { + var self = this; + options = options || {}; + + if (!(id in this.localData)) { + return null; + } + + var element = this.localData[id]; + + if (options.env) { + var env = { + ids: element.res_ids ? element.res_ids.slice(0) : [], + }; + if (element.type === 'record') { + env.currentId = this.isNew(element.id) ? undefined : element.res_id; + } + return env; + } + + if (element.type === 'record') { + var data = _.extend({}, element.data, element._changes); + var relDataPoint; + for (var fieldName in data) { + var field = element.fields[fieldName]; + if (data[fieldName] === null) { + data[fieldName] = false; + } + if (!field) { + continue; + } + + // get relational datapoint + if (field.type === 'many2one') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? relDataPoint.res_id : false; + } else { + data[fieldName] = this.__get(data[fieldName]) || false; + } + } else if (field.type === 'reference') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? + relDataPoint.model + ',' + relDataPoint.res_id : + false; + } else { + data[fieldName] = this.__get(data[fieldName]) || false; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + if (typeof data[fieldName] === 'string') { + relDataPoint = this.localData[data[fieldName]]; + relDataPoint = this._applyX2ManyOperations(relDataPoint); + data[fieldName] = relDataPoint.res_ids; + } else { + // no datapoint has been created yet (because the loading of relational + // data has been batched, and hasn't started yet), so the value is still + // the list of ids in the relation + data[fieldName] = data[fieldName] || []; + } + } else { + data[fieldName] = this.__get(data[fieldName]) || []; + } + } + } + var record = { + context: _.extend({}, element.context), + count: element.count, + data: data, + domain: element.domain.slice(0), + evalModifiers: element.evalModifiers, + fields: element.fields, + fieldsInfo: element.fieldsInfo, + getContext: element.getContext, + getDomain: element.getDomain, + getFieldNames: element.getFieldNames, + id: element.id, + isDirty: element.isDirty, + limit: element.limit, + model: element.model, + offset: element.offset, + ref: element.ref, + res_ids: element.res_ids.slice(0), + specialData: _.extend({}, element.specialData), + type: 'record', + viewType: element.viewType, + }; + + if (!this.isNew(element.id)) { + record.res_id = element.res_id; + } + var evalContext; + Object.defineProperty(record, 'evalContext', { + get: function () { + evalContext = evalContext || self._getEvalContext(element); + return evalContext; + }, + }); + return record; + } + + // apply potential changes (only for x2many lists) + element = this._applyX2ManyOperations(element); + this._sortList(element); + + if (!element.orderedResIDs && element._changes) { + _.each(element._changes, function (change) { + if (change.operation === 'ADD' && change.isNew) { + element.data = _.without(element.data, change.id); + if (change.position === 'top') { + element.data.unshift(change.id); + } else { + element.data.push(change.id); + } + } + }); + } + + var list = { + aggregateValues: _.extend({}, element.aggregateValues), + context: _.extend({}, element.context), + count: element.count, + data: _.map(element.data, function (elemID) { + return self.__get(elemID, options); + }), + domain: element.domain.slice(0), + fields: element.fields, + getContext: element.getContext, + getDomain: element.getDomain, + getFieldNames: element.getFieldNames, + groupedBy: element.groupedBy, + groupsCount: element.groupsCount, + groupsLimit: element.groupsLimit, + groupsOffset: element.groupsOffset, + id: element.id, + isDirty: element.isDirty, + isOpen: element.isOpen, + isSample: this.isSampleModel, + limit: element.limit, + model: element.model, + offset: element.offset, + orderedBy: element.orderedBy, + res_id: element.res_id, + res_ids: element.res_ids.slice(0), + type: 'list', + value: element.value, + viewType: element.viewType, + }; + if (element.fieldsInfo) { + list.fieldsInfo = element.fieldsInfo; + } + return list; + }, + /** + * Generate default values for a given record. Those values are stored in + * the '_changes' key of the record. For relational fields, sub-dataPoints + * are created, and missing relational data is fetched. + * Typically, this function is called when a new record is created. It may + * also be called when a one2many subrecord is open in a form view (dialog), + * to generate the default values for the fields displayed in the o2m form + * view, but not in the list or kanban (mainly to correctly create + * sub-dataPoints for relational fields). + * + * @param {string} recordID local id for a record + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @param {Array} [options.fieldNames] list of field names for which a + * default value must be generated (used to complete the values dict) + * @returns {Promise} + */ + generateDefaultValues(recordID, options = {}) { + const record = this.localData[recordID]; + const viewType = options.viewType || record.viewType; + const fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]); + const numericFields = ['float', 'integer', 'monetary']; + const proms = []; + record._changes = record._changes || {}; + fieldNames.forEach(fieldName => { + record.data[fieldName] = null; + if (!(fieldName in record._changes)) { + const field = record.fields[fieldName]; + if (numericFields.includes(field.type)) { + record._changes[fieldName] = 0; + } else if (field.type === 'one2many' || field.type === 'many2many') { + proms.push(this._processX2ManyCommands(record, fieldName, [], options)); + } else { + record._changes[fieldName] = null; + } + } + }); + return Promise.all(proms); + }, + /** + * Returns the current display_name for the record. + * + * @param {string} id the localID for a valid record element + * @returns {string} + */ + getName: function (id) { + var record = this.localData[id]; + var returnValue = ''; + if (record._changes && 'display_name' in record._changes) { + returnValue = record._changes.display_name; + } + else if ('display_name' in record.data) { + returnValue = record.data.display_name; + } + return returnValue || _t("New"); + }, + /** + * Returns true if a record is dirty. A record is considered dirty if it has + * some unsaved changes, marked by the _isDirty property on the record or + * one of its subrecords. + * + * @param {string} id - the local resource id + * @returns {boolean} + */ + isDirty: function (id) { + var isDirty = false; + this._visitChildren(this.localData[id], function (r) { + if (r._isDirty) { + isDirty = true; + } + }); + return isDirty; + }, + /** + * Returns true iff the datapoint is of type list and either: + * - is not grouped, and contains no records + * - is grouped, and contains columns, but all columns are empty + * In these cases, we will generate sample data to display, instead of an + * empty state. + * + * @override + */ + _isEmpty(dataPointID) { + const dataPoint = this.localData[dataPointID]; + if (dataPoint.type === 'list') { + const hasRecords = dataPoint.count === 0; + if (dataPoint.groupedBy.length) { + return dataPoint.data.length > 0 && hasRecords; + } else { + return hasRecords; + } + } + return false; + }, + /** + * Check if a localData is new, meaning if it is in the process of being + * created and no actual record exists in db. Note: if the localData is not + * of the "record" type, then it is always considered as not new. + * + * Note: A virtual id is a character string composed of an integer and has + * a dash and other information. + * E.g: in calendar, the recursive event have virtual id linked to a real id + * virtual event id "23-20170418020000" is linked to the event id 23 + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isNew: function (id) { + var data = this.localData[id]; + if (data.type !== "record") { + return false; + } + var res_id = data.res_id; + if (typeof res_id === 'number') { + return false; + } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) { + return false; + } + return true; + }, + /** + * Main entry point, the goal of this method is to fetch and process all + * data (following relations if necessary) for a given record/list. + * + * @todo document all params + * + * @private + * @param {any} params + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Object} params.fields contains the description of each field + * @param {string} [params.type] 'record' or 'list' + * @param {string} [params.recordID] an ID for an existing resource. + * @returns {Promise<string>} resolves to a local id, or handle + */ + __load: async function (params) { + await this._super(...arguments); + params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); + // FIXME: the following seems only to be used by the basic_model_tests + // so it should probably be removed and the tests should be adapted + params.viewType = params.viewType || 'default'; + if (!params.fieldsInfo) { + var fieldsInfo = {}; + for (var fieldName in params.fieldNames) { + fieldsInfo[params.fieldNames[fieldName]] = {}; + } + params.fieldsInfo = {}; + params.fieldsInfo[params.viewType] = fieldsInfo; + } + + if (params.type === 'record' && params.res_id === undefined) { + params.allowWarning = true; + return this._makeDefaultRecord(params.modelName, params); + } + var dataPoint = this._makeDataPoint(params); + return this._load(dataPoint).then(function () { + return dataPoint.id; + }); + }, + /** + * Returns the list of res_ids for a given list of local ids. + * + * @param {string[]} localIds + * @returns {integer[]} + */ + localIdsToResIds: function (localIds) { + return localIds.map(localId => this.localData[localId].res_id); + }, + /** + * This helper method is designed to help developpers that want to use a + * field widget outside of a view. In that case, we want a way to create + * data without actually performing a fetch. + * + * @param {string} model name of the model + * @param {Object[]} fields a description of field properties + * @param {Object} [fieldInfo] various field info that we want to set + * @returns {Promise<string>} the local id for the created resource + */ + makeRecord: function (model, fields, fieldInfo) { + var self = this; + var defs = []; + var record_fields = {}; + _.each(fields, function (field) { + record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain', 'selection'); + }); + fieldInfo = fieldInfo || {}; + var fieldsInfo = {}; + fieldsInfo.default = {}; + _.each(fields, function (field) { + fieldsInfo.default[field.name] = fieldInfo[field.name] || {}; + }); + var record = this._makeDataPoint({ + modelName: model, + fields: record_fields, + fieldsInfo: fieldsInfo, + viewType: 'default', + }); + _.each(fields, function (field) { + var dataPoint; + record.data[field.name] = null; + if (field.type === 'many2one') { + if (field.value) { + var id = _.isArray(field.value) ? field.value[0] : field.value; + var display_name = _.isArray(field.value) ? field.value[1] : undefined; + dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: { + id: id, + display_name: display_name, + }, + parentID: record.id, + }); + record.data[field.name] = dataPoint.id; + if (display_name === undefined) { + defs.push(self._fetchNameGet(dataPoint)); + } + } + } else if (field.type === 'reference' && field.value) { + const ref = field.value.split(','); + dataPoint = self._makeDataPoint({ + context: record.context, + data: { id: parseInt(ref[1], 10) }, + modelName: ref[0], + parentID: record.id, + }); + defs.push(self._fetchNameGet(dataPoint)); + record.data[field.name] = dataPoint.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + var relatedFieldsInfo = {}; + relatedFieldsInfo.default = {}; + _.each(field.fields, function (field) { + relatedFieldsInfo.default[field.name] = {}; + }); + var dpParams = { + fieldsInfo: relatedFieldsInfo, + modelName: field.relation, + parentID: record.id, + static: true, + type: 'list', + viewType: 'default', + }; + var needLoad = false; + // As value, you could either pass: + // - a list of ids related to the record + // - a list of object + // We only need to load the datapoint in the first case. + if (field.value && field.value.length) { + if (_.isObject(field.value[0])) { + dpParams.res_ids = _.pluck(field.value, 'id'); + dataPoint = self._makeDataPoint(dpParams); + _.each(field.value, function (data) { + var recordDP = self._makeDataPoint({ + data: data, + modelName: field.relation, + parentID: dataPoint.id, + type: 'record', + }); + dataPoint.data.push(recordDP.id); + dataPoint._cache[recordDP.res_id] = recordDP.id; + }); + } else { + dpParams.res_ids = field.value; + dataPoint = self._makeDataPoint(dpParams); + needLoad = true; + } + } else { + dpParams.res_ids = []; + dataPoint = self._makeDataPoint(dpParams); + } + + if (needLoad) { + defs.push(self._load(dataPoint)); + } + record.data[field.name] = dataPoint.id; + } else if (field.value) { + record.data[field.name] = field.value; + } + }); + return Promise.all(defs).then(function () { + return record.id; + }); + }, + /** + * This is an extremely important method. All changes in any field go + * through this method. It will then apply them in the local state, check + * if onchanges needs to be applied, actually do them if necessary, then + * resolves with the list of changed fields. + * + * @param {string} record_id + * @param {Object} changes a map field => new value + * @param {Object} [options] will be transferred to the applyChange method + * @see _applyChange + * @returns {Promise<string[]>} list of changed fields + */ + notifyChanges: function (record_id, changes, options) { + return this.mutex.exec(this._applyChange.bind(this, record_id, changes, options)); + }, + /** + * Reload all data for a given resource. At any time there is at most one + * reload operation active. + * + * @private + * @param {string} id local id for a resource + * @param {Object} [options] + * @param {boolean} [options.keepChanges=false] if true, doesn't discard the + * changes on the record before reloading it + * @returns {Promise<string>} resolves to the id of the resource + */ + __reload: async function (id, options) { + await this._super(...arguments); + return this.mutex.exec(this._reload.bind(this, id, options)); + }, + /** + * In some case, we may need to remove an element from a list, without going + * through the notifyChanges machinery. The motivation for this is when the + * user click on 'Add a line' in a field one2many with a required field, + * then clicks somewhere else. The new line need to be discarded, but we + * don't want to trigger a real notifyChanges (no need for that, and also, + * we don't want to rerender the UI). + * + * @param {string} elementID some valid element id. It is necessary that the + * corresponding element has a parent. + */ + removeLine: function (elementID) { + var record = this.localData[elementID]; + var parent = this.localData[record.parentID]; + if (parent.static) { + // x2Many case: the new record has been stored in _changes, as a + // command so we remove the command(s) related to that record + parent._changes = _.filter(parent._changes, function (change) { + if (change.id === elementID && + change.operation === 'ADD' && // For now, only an ADD command increases limits + parent.tempLimitIncrement) { + // The record will be deleted from the _changes. + // So we won't be passing into the logic of _applyX2ManyOperations anymore + // implying that we have to cancel out the effects of an ADD command here + parent.tempLimitIncrement--; + parent.limit--; + } + return change.id !== elementID; + }); + } else { + // main list view case: the new record is in data + parent.data = _.without(parent.data, elementID); + parent.count--; + } + }, + /** + * Resequences records. + * + * @param {string} modelName the resIDs model + * @param {Array<integer>} resIDs the new sequence of ids + * @param {string} parentID the localID of the parent + * @param {object} [options] + * @param {integer} [options.offset] + * @param {string} [options.field] the field name used as sequence + * @returns {Promise<string>} resolves to the local id of the parent + */ + resequence: function (modelName, resIDs, parentID, options) { + options = options || {}; + if ((resIDs.length <= 1)) { + return Promise.resolve(parentID); // there is nothing to sort + } + var self = this; + var data = this.localData[parentID]; + var params = { + model: modelName, + ids: resIDs, + }; + if (options.offset) { + params.offset = options.offset; + } + if (options.field) { + params.field = options.field; + } + return this._rpc({ + route: '/web/dataset/resequence', + params: params, + }) + .then(function (wasResequenced) { + if (!wasResequenced) { + // the field on which the resequence was triggered does not + // exist, so no resequence happened server-side + return Promise.resolve(); + } + var field = params.field ? params.field : 'sequence'; + + return self._rpc({ + model: modelName, + method: 'read', + args: [resIDs, [field]], + }).then(function (records) { + if (data.data.length) { + var dataType = self.localData[data.data[0]].type; + if (dataType === 'record') { + _.each(data.data, function (dataPoint) { + var recordData = self.localData[dataPoint].data; + var inRecords = _.findWhere(records, {id: recordData.id}); + if (inRecords) { + recordData[field] = inRecords[field]; + } + }); + data.data = _.sortBy(data.data, function (d) { + return self.localData[d].data[field]; + }); + } + if (dataType === 'list') { + data.data = _.sortBy(data.data, function (d) { + return _.indexOf(resIDs, self.localData[d].res_id) + }); + } + } + data.res_ids = []; + _.each(data.data, function (d) { + var dataPoint = self.localData[d]; + if (dataPoint.type === 'record') { + data.res_ids.push(dataPoint.res_id); + } else { + data.res_ids = data.res_ids.concat(dataPoint.res_ids); + } + }); + self._updateParentResIDs(data); + return parentID; + }) + }); + }, + /** + * Save a local resource, if needed. This is a complicated operation, + * - it needs to check all changes, + * - generate commands for x2many fields, + * - call the /create or /write method according to the record status + * - After that, it has to reload all data, in case something changed, server side. + * + * @param {string} recordID local resource + * @param {Object} [options] + * @param {boolean} [options.reload=true] if true, data will be reloaded + * @param {boolean} [options.savePoint=false] if true, the record will only + * be 'locally' saved: its changes written in a _savePoint key that can + * be restored later by call discardChanges with option rollback to true + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + */ + save: function (recordID, options) { + var self = this; + return this.mutex.exec(function () { + options = options || {}; + var record = self.localData[recordID]; + if (options.savePoint) { + self._visitChildren(record, function (rec) { + var newValue = rec._changes || rec.data; + if (newValue instanceof Array) { + rec._savePoint = newValue.slice(0); + } else { + rec._savePoint = _.extend({}, newValue); + } + }); + + // save the viewType of edition, so that the correct readonly modifiers + // can be evaluated when the record will be saved + for (let fieldName in (record._changes || {})) { + record._editionViewType[fieldName] = options.viewType; + } + } + var shouldReload = 'reload' in options ? options.reload : true; + var method = self.isNew(recordID) ? 'create' : 'write'; + if (record._changes) { + // id never changes, and should not be written + delete record._changes.id; + } + var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'}); + + // id field should never be written/changed + delete changes.id; + + if (method === 'create') { + var fieldNames = record.getFieldNames(); + _.each(fieldNames, function (name) { + if (changes[name] === null) { + delete changes[name]; + } + }); + } + + var prom = new Promise(function (resolve, reject) { + var changedFields = Object.keys(changes); + + if (options.savePoint) { + resolve(changedFields); + return; + } + + // in the case of a write, only perform the RPC if there are changes to save + if (method === 'create' || changedFields.length) { + var args = method === 'write' ? [[record.data.id], changes] : [changes]; + self._rpc({ + model: record.model, + method: method, + args: args, + context: record.getContext(), + }).then(function (id) { + if (method === 'create') { + record.res_id = id; // create returns an id, write returns a boolean + record.data.id = id; + record.offset = record.res_ids.length; + record.res_ids.push(id); + record.count++; + } + + var _changes = record._changes; + + // Erase changes as they have been applied + record._changes = {}; + + // Optionally clear the DataManager's cache + self._invalidateCache(record); + + self.unfreezeOrder(record.id); + + // Update the data directly or reload them + if (shouldReload) { + self._fetchRecord(record).then(function () { + resolve(changedFields); + }); + } else { + _.extend(record.data, _changes); + resolve(changedFields); + } + }).guardedCatch(reject); + } else { + resolve(changedFields); + } + }); + prom.then(function () { + record._isDirty = false; + }); + return prom; + }); + }, + /** + * Manually sets a resource as dirty. This is used to notify that a field + * has been modified, but with an invalid value. In that case, the value is + * not sent to the basic model, but the record should still be flagged as + * dirty so that it isn't discarded without any warning. + * + * @param {string} id a resource id + */ + setDirty: function (id) { + this.localData[id]._isDirty = true; + }, + /** + * For list resources, this changes the orderedBy key. + * + * @param {string} list_id id for the list resource + * @param {string} fieldName valid field name + * @returns {Promise} + */ + setSort: function (list_id, fieldName) { + var list = this.localData[list_id]; + if (list.type === 'record') { + return; + } else if (list._changes) { + _.each(list._changes, function (change) { + delete change.isNew; + }); + } + if (list.orderedBy.length === 0) { + list.orderedBy.push({name: fieldName, asc: true}); + } else if (list.orderedBy[0].name === fieldName){ + if (!list.orderedResIDs) { + list.orderedBy[0].asc = !list.orderedBy[0].asc; + } + } else { + var orderedBy = _.reject(list.orderedBy, function (o) { + return o.name === fieldName; + }); + list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy); + } + + list.orderedResIDs = null; + if (list.static) { + // sorting might require to fetch the field for records where the + // sort field is still unknown (i.e. on other pages for example) + return this._fetchUngroupedList(list); + } + return Promise.resolve(); + }, + /** + * For a given resource of type 'record', get the active field, if any. + * + * Since the ORM can support both `active` and `x_active` fields for + * the archiving mechanism, check if any such field exists and prioritize + * them. The `active` field should always take priority over its custom + * version. + * + * @param {Object} record local resource + * @returns {String|undefined} the field name to use for archiving purposes + * ('active', 'x_active') or undefined if no such field is present + */ + getActiveField: function (record) { + const fields = Object.keys(record.fields); + const has_active = fields.includes('active'); + if (has_active) { + return 'active'; + } + const has_x_active = fields.includes('x_active'); + return has_x_active?'x_active':undefined + }, + /** + * Toggle the active value of given records (to archive/unarchive them) + * + * @param {Array} recordIDs local ids of the records to (un)archive + * @param {boolean} value false to archive, true to unarchive (value of the active field) + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + toggleActive: function (recordIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + var resIDs = _.map(recordIDs, function (recordID) { + return self.localData[recordID].res_id; + }); + return this._rpc({ + model: parent.model, + method: 'toggle_active', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return self.do_action(action, { + on_close: function () { + return self.trigger_up('reload'); + } + }); + } else { + return self.reload(parentID); + } + }); + }, + /** + * Archive the given records + * + * @param {integer[]} resIDs ids of the records to archive + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + actionArchive: function (resIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + return this._rpc({ + model: parent.model, + method: 'action_archive', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return new Promise(function (resolve, reject) { + self.do_action(action, { + on_close: function (result) { + return self.trigger_up('reload', { + onSuccess: resolve, + }); + } + }); + }); + } else { + return self.reload(parentID); + } + }).then(function (datapoint) { + // if there are no records to display and we are not on first page(we check it + // by checking offset is greater than limit i.e. we are not on first page) + // reason for adding logic after reload to make sure there is no records after operation + if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + return self.reload(parentID); + } + return datapoint; + }); + }, + /** + * Unarchive the given records + * + * @param {integer[]} resIDs ids of the records to unarchive + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + actionUnarchive: function (resIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + return this._rpc({ + model: parent.model, + method: 'action_unarchive', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return new Promise(function (resolve, reject) { + self.do_action(action, { + on_close: function () { + return self.trigger_up('reload', { + onSuccess: resolve, + }); + } + }); + }); + } else { + return self.reload(parentID); + } + }).then(function (datapoint) { + // if there are no records to display and we are not on first page(we check it + // by checking offset is greater than limit i.e. we are not on first page) + // reason for adding logic after reload to make sure there is no records after operation + if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + return self.reload(parentID); + } + return datapoint; + }); + }, + /** + * Toggle (open/close) a group in a grouped list, then fetches relevant + * data + * + * @param {string} groupId + * @returns {Promise<string>} resolves to the group id + */ + toggleGroup: function (groupId) { + var self = this; + var group = this.localData[groupId]; + if (group.isOpen) { + group.isOpen = false; + group.data = []; + group.res_ids = []; + group.offset = 0; + this._updateParentResIDs(group); + return Promise.resolve(groupId); + } + if (!group.isOpen) { + group.isOpen = true; + var def; + if (group.count > 0) { + def = this._load(group).then(function () { + self._updateParentResIDs(group); + }); + } + return Promise.resolve(def).then(function () { + return groupId; + }); + } + }, + /** + * For a list datapoint, unfreezes the current records order and sorts it. + * For a record datapoint, unfreezes the x2many list datapoints. + * + * @param {string} elementID a valid element ID + */ + unfreezeOrder: function (elementID) { + var list = this.localData[elementID]; + if (list.type === 'record') { + var data = _.extend({}, list.data, list._changes); + for (var fieldName in data) { + var field = list.fields[fieldName]; + if (!field || !data[fieldName]) { + continue; + } + if (field.type === 'one2many' || field.type === 'many2many') { + var recordlist = this.localData[data[fieldName]]; + recordlist.orderedResIDs = null; + for (var index in recordlist.data) { + this.unfreezeOrder(recordlist.data[index]); + } + } + } + return; + } + list.orderedResIDs = null; + this._sortList(list); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object + * as a 'ADD' command in its _changes. This is meant to be used x2many lists, + * not by list or kanban controllers. + * + * @private + * @param {Object} list a valid list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @param {Array} [options.[context]] additional context to be merged before + * calling the default_get (eg. to set default values). + * If several contexts are found, multiple records are added + * @param {boolean} [options.allowWarning=false] if true, the default record + * operation can complete, even if a warning is raised + * @returns {Promise<[string]>} resolves to the new records ids + */ + _addX2ManyDefaultRecord: function (list, options) { + var self = this; + var position = options && options.position || 'top'; + var params = { + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: options.viewType || list.viewType, + allowWarning: options && options.allowWarning + }; + + var additionalContexts = options && options.context; + var makeDefaultRecords = []; + if (additionalContexts){ + _.each(additionalContexts, function (context) { + params.context = self._getContext(list, {additionalContext: context}); + makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); + }); + } else { + params.context = self._getContext(list); + makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); + } + + return Promise.all(makeDefaultRecords).then(function (resultIds){ + var ids = []; + _.each(resultIds, function (id){ + ids.push(id); + + list._changes.push({operation: 'ADD', id: id, position: position, isNew: true}); + var record = self.localData[id]; + list._cache[record.res_id] = id; + if (list.orderedResIDs) { + var index = list.offset + (position !== 'top' ? list.limit : 0); + list.orderedResIDs.splice(index, 0, record.res_id); + // list could be a copy of the original one + self.localData[list.id].orderedResIDs = list.orderedResIDs; + } + }); + + return ids; + }); + }, + /** + * This method is the private version of notifyChanges. Unlike + * notifyChanges, it is not protected by a mutex. Every changes from the + * user to the model go through this method. + * + * @param {string} recordID + * @param {Object} changes + * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if this flag is set to + * true, then we will not tag the record as dirty. This should be avoided + * for most situations. + * @param {boolean} [options.notifyChange=true] if this flag is set to + * false, then we will not notify and not trigger the onchange, even though + * it was changed. + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.allowWarning=false] if true, change + * operation can complete, even if a warning is raised + * (only supported by X2ManyChange) + * @returns {Promise} list of changed fields + */ + _applyChange: function (recordID, changes, options) { + var self = this; + var record = this.localData[recordID]; + var field; + var defs = []; + options = options || {}; + record._changes = record._changes || {}; + if (!options.doNotSetDirty) { + record._isDirty = true; + } + var initialData = {}; + this._visitChildren(record, function (elem) { + initialData[elem.id] = $.extend(true, {}, _.pick(elem, 'data', '_changes')); + }); + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && (field.type === 'one2many' || field.type === 'many2many')) { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options)); + } else if (field && (field.type === 'many2one' || field.type === 'reference')) { + defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName], options)); + } else { + record._changes[fieldName] = changes[fieldName]; + } + } + + if (options.notifyChange === false) { + return Promise.all(defs).then(function () { + return Promise.resolve(_.keys(changes)); + }); + } + + return Promise.all(defs).then(function () { + var onChangeFields = []; // the fields that have changed and that have an on_change + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && field.onChange) { + var isX2Many = field.type === 'one2many' || field.type === 'many2many'; + if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { + onChangeFields.push(fieldName); + } + } + } + return new Promise(function (resolve, reject) { + if (onChangeFields.length) { + self._performOnChange(record, onChangeFields, { viewType: options.viewType }) + .then(function (result) { + delete record._warning; + resolve(_.keys(changes).concat(Object.keys(result && result.value || {}))); + }).guardedCatch(function () { + self._visitChildren(record, function (elem) { + _.extend(elem, initialData[elem.id]); + }); + reject(); + }); + } else { + resolve(_.keys(changes)); + } + }).then(function (fieldNames) { + return self._fetchSpecialData(record).then(function (fieldNames2) { + // Return the names of the fields that changed (onchange or + // associated special data change) + return _.union(fieldNames, fieldNames2); + }); + }); + }); + }, + /** + * Apply an x2one (either a many2one or a reference field) change. There is + * a need for this function because the server only gives an id when a + * onchange modifies a many2one field. For this reason, we need (sometimes) + * to do a /name_get to fetch a display_name. + * + * Moreover, for the many2one case, a new value can sometimes be set (i.e. + * a display_name is given, but no id). When this happens, we first do a + * name_create. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} [data] + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + */ + _applyX2OneChange: async function (record, fieldName, data, options) { + options = options || {}; + var self = this; + if (!data || (!data.id && !data.display_name)) { + record._changes[fieldName] = false; + return Promise.resolve(); + } + + const field = record.fields[fieldName]; + const coModel = field.type === 'reference' ? data.model : field.relation; + const allowedTypes = ['many2one', 'reference']; + if (allowedTypes.includes(field.type) && !data.id && data.display_name) { + // only display_name given -> do a name_create + const result = await this._rpc({ + model: coModel, + method: 'name_create', + args: [data.display_name], + context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), + }); + // Check if a record is really created. Models without defined + // _rec_name cannot create record based on name_create. + if (!result) { + record._changes[fieldName] = false; + return Promise.resolve(); + } + data = {id: result[0], display_name: result[1]}; + } + + // here, we check that the many2one really changed. If the res_id is the + // same, we do not need to do any extra work. It can happen when the + // user edited a manyone (with the small form view button) with an + // onchange. In that case, the onchange is triggered, but the actual + // value did not change. + var relatedID; + if (record._changes && fieldName in record._changes) { + relatedID = record._changes[fieldName]; + } else { + relatedID = record.data[fieldName]; + } + var relatedRecord = this.localData[relatedID]; + if (relatedRecord && (data.id === this.localData[relatedID].res_id)) { + return Promise.resolve(); + } + var rel_data = _.pick(data, 'id', 'display_name'); + + // the reference field doesn't store its co-model in its field metadata + // but directly in the data (as the co-model isn't fixed) + var def; + if (rel_data.display_name === undefined) { + // TODO: refactor this to use _fetchNameGet + def = this._rpc({ + model: coModel, + method: 'name_get', + args: [data.id], + context: record.context, + }) + .then(function (result) { + rel_data.display_name = result[0][1]; + }); + } + return Promise.resolve(def).then(function () { + var rec = self._makeDataPoint({ + context: record.context, + data: rel_data, + fields: {}, + fieldsInfo: {}, + modelName: coModel, + parentID: record.id, + }); + record._changes[fieldName] = rec.id; + }); + }, + /** + * Applies the result of an onchange RPC on a record. + * + * @private + * @param {Object} values the result of the onchange RPC (a mapping of + * fieldnames to their value) + * @param {Object} record + * @param {Object} [options={}] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {string} [options.firstOnChange] set to true if this is the first + * onchange (if so, some initialization will need to be done) + * @returns {Promise} + */ + _applyOnChange: function (values, record, options = {}) { + var self = this; + var defs = []; + var rec; + const viewType = options.viewType || record.viewType; + record._changes = record._changes || {}; + + for (let name in (values || {})) { + const val = values[name]; + var field = record.fields[name]; + if (!field) { + // this field is unknown so we can't process it for now (it is not + // in the current view anyway, otherwise it wouldn't be unknown. + // we store its value without processing it, so that if we later + // on switch to another view in which this field is displayed, + // we could process it as we would know its type then. + // use case: an onchange sends a create command for a one2many, + // in the dict of values, there is a value for a field that is + // not in the one2many list, but that is in the one2many form. + record._rawChanges[name] = val; + // LPE TODO 1 taskid-2261084: remove this entire comment including code snippet + // when the change in behavior has been thoroughly tested. + // It is impossible to distinguish between values returned by the default_get + // and those returned by the onchange. Since those are not in _changes, they won't be saved. + // if (options.firstOnChange) { + // record._changes[name] = val; + // } + continue; + } + if (record._rawChanges[name]) { + // if previous _rawChanges exists, clear them since the field is now knwon + // and restoring outdated onchange over posterious change is wrong + delete record._rawChanges[name]; + } + var oldValue = name in record._changes ? record._changes[name] : record.data[name]; + var id; + if (field.type === 'many2one') { + id = false; + // in some case, the value returned by the onchange can + // be false (no value), so we need to avoid creating a + // local record for that. + if (val) { + // when the value isn't false, it can be either + // an array [id, display_name] or just an id. + var data = _.isArray(val) ? + {id: val[0], display_name: val[1]} : + {id: val}; + if (!oldValue || (self.localData[oldValue].res_id !== data.id)) { + // only register a change if the value has changed + rec = self._makeDataPoint({ + context: record.context, + data: data, + modelName: field.relation, + parentID: record.id, + }); + id = rec.id; + record._changes[name] = id; + } + } else { + record._changes[name] = false; + } + } else if (field.type === 'reference') { + id = false; + if (val) { + var ref = val.split(','); + var modelName = ref[0]; + var resID = parseInt(ref[1]); + if (!oldValue || self.localData[oldValue].res_id !== resID || + self.localData[oldValue].model !== modelName) { + // only register a change if the value has changed + rec = self._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: modelName, + parentID: record.id, + }); + defs.push(self._fetchNameGet(rec)); + id = rec.id; + record._changes[name] = id; + } + } else { + record._changes[name] = id; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + var listId = record._changes[name] || record.data[name]; + var list; + if (listId) { + list = self.localData[listId]; + } else { + var fieldInfo = record.fieldsInfo[viewType][name]; + if (!fieldInfo) { + // ignore changes of x2many not in view + continue; + } + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + list = self._makeDataPoint({ + fields: view ? view.fields : fieldInfo.relatedFields, + fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + limit: fieldInfo.limit, + modelName: field.relation, + parentID: record.id, + static: true, + type: 'list', + viewType: view ? view.type : fieldInfo.viewType, + }); + } + // TODO: before registering the changes, verify that the x2many + // value has changed + record._changes[name] = list.id; + list._changes = list._changes || []; + + // save it in case of a [5] which will remove the _changes + var oldChanges = list._changes; + _.each(val, function (command) { + var rec, recID; + if (command[0] === 0 || command[0] === 1) { + // CREATE or UPDATE + if (command[0] === 0 && command[1]) { + // updating an existing (virtual) record + var previousChange = _.find(oldChanges, function (operation) { + var child = self.localData[operation.id]; + return child && (child.ref === command[1]); + }); + recID = previousChange && previousChange.id; + rec = self.localData[recID]; + } + if (command[0] === 1 && command[1]) { + // updating an existing record + rec = self.localData[list._cache[command[1]]]; + } + if (!rec) { + var params = { + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + ref: command[1], + }; + if (command[0] === 1) { + params.res_id = command[1]; + } + rec = self._makeDataPoint(params); + list._cache[rec.res_id] = rec.id; + if (options.firstOnChange) { + // this is necessary so the fields are initialized + rec.getFieldNames().forEach(fieldName => { + if (!(fieldName in rec.data)) { + rec.data[fieldName] = null; + } + }); + } + } + // Do not abandon the record if it has been created + // from `default_get`. The list has a savepoint only + // after having fully executed `default_get`. + rec._noAbandon = !list._savePoint; + list._changes.push({operation: 'ADD', id: rec.id}); + if (command[0] === 1) { + list._changes.push({operation: 'UPDATE', id: rec.id}); + } + defs.push(self._applyOnChange(command[2], rec, { + firstOnChange: options.firstOnChange, + })); + } else if (command[0] === 4) { + // LINK TO + linkRecord(list, command[1]); + } else if (command[0] === 5) { + // DELETE ALL + list._changes = [{operation: 'REMOVE_ALL'}]; + } else if (command[0] === 6) { + list._changes = [{operation: 'REMOVE_ALL'}]; + _.each(command[2], function (resID) { + linkRecord(list, resID); + }); + } + }); + var def = self._readUngroupedList(list).then(function () { + var x2ManysDef = self._fetchX2ManysBatched(list); + var referencesDef = self._fetchReferencesBatched(list); + return Promise.all([x2ManysDef, referencesDef]); + }); + defs.push(def); + } else { + var newValue = self._parseServerValue(field, val); + if (newValue !== oldValue) { + record._changes[name] = newValue; + } + } + } + return Promise.all(defs); + + // inner function that adds a record (based on its res_id) to a list + // dataPoint (used for onchanges that return commands 4 (LINK TO) or + // commands 6 (REPLACE WITH)) + function linkRecord (list, resID) { + rec = self.localData[list._cache[resID]]; + if (rec) { + // modifications done on a record are discarded if the onchange + // uses a LINK TO or a REPLACE WITH + self.discardChanges(rec.id); + } + // the dataPoint id will be set when the record will be fetched (for + // now, this dataPoint may not exist yet) + list._changes.push({ + operation: 'ADD', + id: rec ? rec.id : null, + resID: resID, + }); + } + }, + /** + * When an operation is applied to a x2many field, the field widgets + * generate one (or more) command, which describes the exact operation. + * This method tries to interpret these commands and apply them to the + * localData. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} command A command object. It should have a 'operation' + * key. For example, it looks like {operation: ADD, id: 'partner_1'} + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.allowWarning=false] if true, change + * operation can complete, even if a warning is raised + * (only supported by the 'CREATE' command.operation) + * @returns {Promise} + */ + _applyX2ManyChange: async function (record, fieldName, command, options) { + if (command.operation === 'TRIGGER_ONCHANGE') { + // the purpose of this operation is to trigger an onchange RPC, so + // there is no need to apply any change on the record (the changes + // have probably been already applied and saved, usecase: many2many + // edition in a dialog) + return Promise.resolve(); + } + + var self = this; + var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName]; + var list = this.localData[localID]; + var field = record.fields[fieldName]; + var viewType = (options && options.viewType) || record.viewType; + var fieldInfo = record.fieldsInfo[viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var def, rec; + var defs = []; + list._changes = list._changes || []; + + switch (command.operation) { + case 'ADD': + // for now, we are in the context of a one2many field + // the command should look like this: + // { operation: 'ADD', id: localID } + // The corresponding record may contain value for fields that + // are unknown in the list (e.g. fields that are in the + // subrecord form view but not in the kanban or list view), so + // to ensure that onchanges are correctly handled, we extend the + // list's fields with those in the created record + var newRecord = this.localData[command.id]; + _.defaults(list.fields, newRecord.fields); + _.defaults(list.fieldsInfo, newRecord.fieldsInfo); + newRecord.fields = list.fields; + newRecord.fieldsInfo = list.fieldsInfo; + newRecord.viewType = list.viewType; + list._cache[newRecord.res_id] = newRecord.id; + list._changes.push(command); + break; + case 'ADD_M2M': + // force to use link command instead of create command + list._forceM2MLink = true; + // handle multiple add: command[2] may be a dict of values (1 + // record added) or an array of dict of values + var data = _.isArray(command.ids) ? command.ids : [command.ids]; + + // name_create records for which there is no id (typically, could + // be the case of a quick_create in a many2many_tags, so data.length + // is 1) + for (const r of data) { + if (!r.id && r.display_name) { + const prom = this._rpc({ + model: field.relation, + method: 'name_create', + args: [r.display_name], + context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), + }).then(result => { + r.id = result[0]; + r.display_name = result[1]; + }); + defs.push(prom); + } + } + await Promise.all(defs); + + // Ensure the local data repository (list) boundaries can handle incoming records (data) + if (data.length + list.res_ids.length > list.limit) { + list.limit = data.length + list.res_ids.length; + } + + var list_records = {}; + _.each(data, function (d) { + rec = self._makeDataPoint({ + context: record.context, + modelName: field.relation, + fields: view ? view.fields : fieldInfo.relatedFields, + fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + res_id: d.id, + data: d, + viewType: view ? view.type : fieldInfo.viewType, + parentID: list.id, + }); + list_records[d.id] = rec; + list._cache[rec.res_id] = rec.id; + list._changes.push({operation: 'ADD', id: rec.id}); + }); + // read list's records as we only have their ids and optionally their display_name + // (we can't use function readUngroupedList because those records are only in the + // _changes so this is a very specific case) + // this could be optimized by registering the fetched records in the list's _cache + // so that if a record is removed and then re-added, it won't be fetched twice + var fieldNames = list.getFieldNames(); + if (fieldNames.length) { + def = this._rpc({ + model: list.model, + method: 'read', + args: [_.pluck(data, 'id'), fieldNames], + context: _.extend({}, record.context, field.context), + }).then(function (records) { + _.each(records, function (record) { + list_records[record.id].data = record; + self._parseServerData(fieldNames, list, record); + }); + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + }); + defs.push(def); + } + break; + case 'CREATE': + var createOptions = _.extend({ + context: command.context, + position: command.position + }, options || {}); + createOptions.viewType = fieldInfo.mode; + + def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) { + _.each(ids, function(id){ + if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) { + list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1; + list.limit += 1; + } + // FIXME: hack for lunch widget, which does useless default_get and onchange + if (command.data) { + return self._applyChange(id, command.data); + } + }); + }); + defs.push(def); + break; + case 'UPDATE': + list._changes.push({operation: 'UPDATE', id: command.id}); + if (command.data) { + defs.push(this._applyChange(command.id, command.data, { + viewType: view && view.type, + })); + } + break; + case 'FORGET': + // Unlink the record of list. + list._forceM2MUnlink = true; + case 'DELETE': + // filter out existing operations involving the current + // dataPoint, and add a 'DELETE' or 'FORGET' operation only if there is + // no 'ADD' operation for that dataPoint, as it would mean + // that the record wasn't in the relation yet + var idsToRemove = command.ids; + list._changes = _.reject(list._changes, function (change, index) { + var idInCommands = _.contains(command.ids, change.id); + if (idInCommands && change.operation === 'ADD') { + idsToRemove = _.without(idsToRemove, change.id); + } + return idInCommands; + }); + _.each(idsToRemove, function (id) { + var operation = list._forceM2MUnlink ? 'FORGET': 'DELETE'; + list._changes.push({operation: operation, id: id}); + }); + break; + case 'DELETE_ALL': + // first remove all pending 'ADD' operations + list._changes = _.reject(list._changes, function (change) { + return change.operation === 'ADD'; + }); + + // then apply 'DELETE' on existing records + return this._applyX2ManyChange(record, fieldName, { + operation: 'DELETE', + ids: list.res_ids + }, options); + case 'REPLACE_WITH': + // this is certainly not optimal... and not sure that it is + // correct if some ids are added and some other are removed + list._changes = []; + var newIds = _.difference(command.ids, list.res_ids); + var removedIds = _.difference(list.res_ids, command.ids); + var addDef, removedDef, values; + if (newIds.length) { + values = _.map(newIds, function (id) { + return {id: id}; + }); + addDef = this._applyX2ManyChange(record, fieldName, { + operation: 'ADD_M2M', + ids: values + }, options); + } + if (removedIds.length) { + var listData = _.map(list.data, function (localId) { + return self.localData[localId]; + }); + removedDef = this._applyX2ManyChange(record, fieldName, { + operation: 'DELETE', + ids: _.map(removedIds, function (resID) { + if (resID in list._cache) { + return list._cache[resID]; + } + return _.findWhere(listData, {res_id: resID}).id; + }), + }, options); + } + return Promise.all([addDef, removedDef]); + case 'MULTI': + // allows batching multiple operations + _.each(command.commands, function (innerCommand){ + defs.push(self._applyX2ManyChange( + record, + fieldName, + innerCommand, + options + )); + }); + break; + } + + return Promise.all(defs).then(function () { + // ensure to fetch up to 'limit' records (may be useful if records of + // the current page have been removed) + return self._readUngroupedList(list).then(function () { + return self._fetchX2ManysBatched(list); + }); + }); + }, + /** + * In dataPoints of type list for x2manys, the changes are stored as a list + * of operations (being of type 'ADD', 'DELETE', 'FORGET', UPDATE' or 'REMOVE_ALL'). + * This function applies the operation of such a dataPoint without altering + * the original dataPoint. It returns a copy of the dataPoint in which the + * 'count', 'data' and 'res_ids' keys have been updated. + * + * @private + * @param {Object} dataPoint of type list + * @param {Object} [options] mostly contains the range of operations to apply + * @param {Object} [options.from=0] the index of the first operation to apply + * @param {Object} [options.to=length] the index of the last operation to apply + * @param {Object} [options.position] if set, each new operation will be set + * accordingly at the top or the bottom of the list + * @returns {Object} element of type list in which the commands have been + * applied + */ + _applyX2ManyOperations: function (list, options) { + if (!list.static) { + // this function only applies on x2many lists + return list; + } + var self = this; + list = _.extend({}, list); + list.res_ids = list.res_ids.slice(0); + var changes = list._changes || []; + if (options) { + var to = options.to === 0 ? 0 : (options.to || changes.length); + changes = changes.slice(options.from || 0, to); + } + _.each(changes, function (change) { + var relRecord; + if (change.id) { + relRecord = self.localData[change.id]; + } + switch (change.operation) { + case 'ADD': + list.count++; + var resID = relRecord ? relRecord.res_id : change.resID; + if (change.position === 'top' && (options ? options.position !== 'bottom' : true)) { + list.res_ids.unshift(resID); + } else { + list.res_ids.push(resID); + } + break; + case 'FORGET': + case 'DELETE': + list.count--; + // FIXME awa: there is no "relRecord" for o2m field + // seems like using change.id does the trick -> check with framework JS + var deletedResID = relRecord ? relRecord.res_id : change.id; + list.res_ids = _.without(list.res_ids, deletedResID); + break; + case 'REMOVE_ALL': + list.count = 0; + list.res_ids = []; + break; + case 'UPDATE': + // nothing to do for UPDATE commands + break; + } + }); + this._setDataInRange(list); + return list; + }, + /** + * Helper method to build a 'spec', that is a description of all fields in + * the view that have a onchange defined on them. + * + * An onchange spec is necessary as an argument to the /onchange route. It + * looks like this: { field: "1", anotherField: "", relation.subField: "1"} + * + * The first onchange call will fill up the record with default values, so + * we need to send every field name known to us in this case. + * + * @see _performOnChange + * + * @param {Object} record resource object of type 'record' + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {Object} with two keys + * - 'hasOnchange': true iff there is at least a field with onchange + * - 'onchangeSpec': the onchange spec + */ + _buildOnchangeSpecs: function (record, viewType) { + let hasOnchange = false; + const onchangeSpec = {}; + var fieldsInfo = record.fieldsInfo[viewType || record.viewType]; + generateSpecs(fieldsInfo, record.fields); + + // recursively generates the onchange specs for fields in fieldsInfo, + // and their subviews + function generateSpecs(fieldsInfo, fields, prefix) { + prefix = prefix || ''; + _.each(Object.keys(fieldsInfo), function (name) { + var field = fields[name]; + var fieldInfo = fieldsInfo[name]; + var key = prefix + name; + onchangeSpec[key] = (field.onChange) || ""; + if (field.onChange) { + hasOnchange = true; + } + if (field.type === 'one2many' || field.type === 'many2many') { + _.each(fieldInfo.views, function (view) { + generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.'); + }); + } + }); + } + return { hasOnchange, onchangeSpec }; + }, + /** + * Ensures that dataPoint ids are always synchronized between the main and + * sample models when being in sample mode. Here, we now that __id in the + * sample model is always greater than __id in the main model (as it + * contains strictly more datapoints). + * + * @override + */ + async _callSampleModel() { + await this._super(...arguments); + if (this._isInSampleMode) { + this.__id = this.sampleModel.__id; + } + }, + /** + * Compute the default value that the handle field should take. + * We need to compute this in order for new lines to be added at the correct position. + * + * @private + * @param {Object} listID + * @param {string} position + * @return {Object} empty object if no overrie has to be done, or: + * field: the name of the field to override, + * value: the value to use for that field + */ + _computeOverrideDefaultFields: function (listID, position) { + var list = this.localData[listID]; + var handleField; + + // Here listID is actually just parentID, it's not yet confirmed + // to be a list. + // If we are not in the case that interests us, + // listID will be undefined and this check will work. + if (!list) { + return {}; + } + + position = position || 'bottom'; + + // Let's find if there is a field with handle. + if (!list.fieldsInfo) { + return {}; + } + for (var field in list.fieldsInfo.list) { + if (list.fieldsInfo.list[field].widget === 'handle') { + handleField = field; + break; + // If there are 2 handle fields on the same list, + // we take the first one we find. + // And that will be alphabetically on the field name... + } + } + + if (!handleField) { + return {}; + } + + // We don't want to override the default value + // if the list is not ordered by the handle field. + var isOrderedByHandle = list.orderedBy + && list.orderedBy.length + && list.orderedBy[0].asc === true + && list.orderedBy[0].name === handleField; + + if (!isOrderedByHandle) { + return {}; + } + + // We compute the list (get) to apply the pending changes before doing our work, + // otherwise new lines might not be taken into account. + // We use raw: true because we only need to load the first level of relation. + var computedList = this.get(list.id, {raw: true}); + + // We don't need to worry about the position of a new line if the list is empty. + if (!computedList || !computedList.data || !computedList.data.length) { + return {}; + } + + // If there are less elements in the list than the limit of + // the page then take the index of the last existing line. + + // If the button is at the top, we want the new element on + // the first line of the page. + + // If the button is at the bottom, we want the new element + // after the last line of the page + // (= theorically it will be the first element of the next page). + + // We ignore list.offset because computedList.data + // will only have the current page elements. + + var index = Math.min( + computedList.data.length - 1, + position !== 'top' ? list.limit - 1 : 0 + ); + + // This positioning will almost be correct. There might just be + // an issue if several other lines have the same handleFieldValue. + + // TODO ideally: if there is an element with the same handleFieldValue, + // that one and all the following elements must be incremented + // by 1 (at least until there is a gap in the numbering). + + // We don't do it now because it's not an important case. + // However, we can for sure increment by 1 if we are on the last page. + var handleFieldValue = computedList.data[index].data[handleField]; + if (position === 'top') { + handleFieldValue--; + } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) { + handleFieldValue++; + } + return { + field: handleField, + value: handleFieldValue, + }; + }, + /** + * Evaluate modifiers + * + * @private + * @param {Object} element a valid element object, which will serve as eval + * context. + * @param {Object} modifiers + * @returns {Object} + * @throws {Error} if one of the modifier domains is invalid + */ + _evalModifiers: function (element, modifiers) { + let evalContext = null; + const evaluated = {}; + for (const k of ['invisible', 'column_invisible', 'readonly', 'required']) { + const mod = modifiers[k]; + if (mod === undefined || mod === false || mod === true) { + if (k in modifiers) { + evaluated[k] = !!mod; + } + continue; + } + try { + evalContext = evalContext || this._getEvalContext(element); + evaluated[k] = new Domain(mod, evalContext).compute(evalContext); + } catch (e) { + throw new Error(_.str.sprintf('for modifier "%s": %s', k, e.message)); + } + } + return evaluated; + }, + /** + * Fetch name_get for a record datapoint. + * + * @param {Object} dataPoint + * @returns {Promise} + */ + _fetchNameGet: function (dataPoint) { + return this._rpc({ + model: dataPoint.model, + method: 'name_get', + args: [dataPoint.res_id], + context: dataPoint.getContext(), + }).then(function (result) { + dataPoint.data.display_name = result[0][1]; + }); + }, + /** + * Fetch name_get for a field of type Many2one or Reference + * + * @private + * @params {Object} list: must be a datapoint of type list + * (for example: a datapoint representing a x2many) + * @params {string} fieldName: the name of a field of type Many2one or Reference + * @returns {Promise} + */ + _fetchNameGets: function (list, fieldName) { + var self = this; + // We first get the model this way because if list.data is empty + // the _.each below will not make it. + var model = list.fields[fieldName].relation; + var records = []; + var ids = []; + list = this._applyX2ManyOperations(list); + + _.each(list.data, function (localId) { + var record = self.localData[localId]; + var data = record._changes || record.data; + var many2oneId = data[fieldName]; + if (!many2oneId) { return; } + var many2oneRecord = self.localData[many2oneId]; + records.push(many2oneRecord); + ids.push(many2oneRecord.res_id); + // We need to calculate the model this way too because + // field .relation is not set for a reference field. + model = many2oneRecord.model; + }); + + if (!ids.length) { + return Promise.resolve(); + } + return this._rpc({ + model: model, + method: 'name_get', + args: [_.uniq(ids)], + context: list.context, + }) + .then(function (name_gets) { + _.each(records, function (record) { + var nameGet = _.find(name_gets, function (nameGet) { + return nameGet[0] === record.data.id; + }); + record.data.display_name = nameGet[1]; + }); + }); + }, + /** + * For a given resource of type 'record', fetch all data. + * + * @param {Object} record local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the list of fields to fetch. If + * not given, fetch all the fields in record.fieldNames (+ display_name) + * @param {string} [options.viewType] the type of view for which the record + * is fetched (usefull to load the adequate fields), by defaults, uses + * record.viewType + * @returns {Promise<Object>} resolves to the record or is rejected in + * case no id given were valid ids + */ + _fetchRecord: function (record, options) { + var self = this; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + fieldNames = _.uniq(fieldNames.concat(['display_name'])); + return this._rpc({ + model: record.model, + method: 'read', + args: [[record.res_id], fieldNames], + context: _.extend({bin_size: true}, record.getContext()), + }) + .then(function (result) { + if (result.length === 0) { + return Promise.reject(); + } + result = result[0]; + record.data = _.extend({}, record.data, result); + }) + .then(function () { + self._parseServerData(fieldNames, record, record.data); + }) + .then(function () { + return Promise.all([ + self._fetchX2Manys(record, options), + self._fetchReferences(record, options) + ]).then(function () { + return self._postprocess(record, options); + }); + }); + }, + /** + * Fetch the `name_get` for a reference field. + * + * @private + * @param {Object} record + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReference: function (record, fieldName) { + var self = this; + var def; + var value = record._changes && record._changes[fieldName] || record.data[fieldName]; + var model = value && value.split(',')[0]; + var resID = value && parseInt(value.split(',')[1]); + if (model && model !== 'False' && resID) { + def = self._rpc({ + model: model, + method: 'name_get', + args: [resID], + context: record.getContext({fieldName: fieldName}), + }).then(function (result) { + return self._makeDataPoint({ + data: { + id: result[0][0], + display_name: result[0][1], + }, + modelName: model, + parentID: record.id, + }); + }); + } + return Promise.resolve(def); + }, + /** + * Fetches data for reference fields and assigns these data to newly + * created datapoint. + * Then places datapoint reference into parent record. + * + * @param {Object} datapoints a collection of ids classed by model, + * @see _getDataToFetchByModel + * @param {string} model + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceData: function (datapoints, model, fieldName) { + var self = this; + var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); }); + // we need one parent for the context (they all have the same) + var parent = datapoints[ids[0]][0]; + var def = self._rpc({ + model: model, + method: 'name_get', + args: [ids], + context: self.localData[parent].getContext({fieldName: fieldName}), + }).then(function (result) { + _.each(result, function (el) { + var parentIDs = datapoints[el[0]]; + _.each(parentIDs, function (parentID) { + var parent = self.localData[parentID]; + var referenceDp = self._makeDataPoint({ + data: { + id: el[0], + display_name: el[1], + }, + modelName: model, + parentID: parent.id, + }); + parent.data[fieldName] = referenceDp.id; + }); + }); + }); + return def; + }, + /** + * Fetch the extra data (`name_get`) for the reference fields of the record + * model. + * + * @private + * @param {Object} record + * @returns {Promise} + */ + _fetchReferences: function (record, options) { + var self = this; + var defs = []; + var fieldNames = options && options.fieldNames || record.getFieldNames(); + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'reference') { + var def = self._fetchReference(record, fieldName).then(function (dataPoint) { + if (dataPoint) { + record.data[fieldName] = dataPoint.id; + } + }); + defs.push(def); + } + }); + return Promise.all(defs); + }, + /** + * Batch requests for one reference field in list (one request by different + * model in the field values). + * + * @see _fetchReferencesBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceBatched: function (list, fieldName) { + var self = this; + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetchByModel(list, fieldName); + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return Promise.all(defs); + }, + /** + * Batch requests for references for datapoint of type list. + * + * @param {Object} list + * @returns {Promise} + */ + _fetchReferencesBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceBatched(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * Batch reference requests for all records in list. + * + * @see _fetchReferencesSingleBatch + * @param {Object} list a valid resource object + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceSingleBatch: function (list, fieldName) { + var self = this; + + // collect ids by model + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + self._getDataToFetchByModel(group, fieldName, toFetch); + }); + + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return Promise.all(defs); + }, + /** + * Batch requests for all reference field in list's children. + * Called by _readGroup to make only one 'name_get' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Promise} + */ + _fetchReferencesSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var fIndex in fieldNames) { + var field = list.fields[fieldNames[fIndex]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex])); + } + } + return Promise.all(defs); + }, + /** + * Fetch model data from server, relationally to fieldName and resulted + * field relation. For example, if fieldName is "tag_ids" and referred to + * project.tags, it will fetch project.tags' related fields where its id is + * contained in toFetch.ids array. + * + * @param {Object} list a valid resource object + * @param {Object} toFetch a list of records and res_ids, + * @see _getDataToFetch + * @param {string} fieldName + * @returns {Promise} + */ + _fetchRelatedData: function (list, toFetch, fieldName) { + var self = this; + var ids = _.keys(toFetch); + for (var i = 0; i < ids.length; i++) { + ids[i] = Number(ids[i]); + } + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + + if (!ids.length || fieldInfo.__no_fetch) { + return Promise.resolve(); + } + + var def; + var fieldNames = _.keys(fieldInfo.relatedFields); + if (fieldNames.length) { + var field = list.fields[fieldName]; + def = this._rpc({ + model: field.relation, + method: 'read', + args: [ids, fieldNames], + context: list.getContext() || {}, + }); + } else { + def = Promise.resolve(_.map(ids, function (id) { + return {id:id}; + })); + } + return def.then(function (result) { + var records = _.uniq(_.flatten(_.values(toFetch))); + self._updateRecordsData(records, fieldName, result); + }); + }, + /** + * Check the AbstractField specializations that are (will be) used by the + * given record and fetch the special data they will need. Special data are + * data that the rendering of the record won't need if it was not using + * particular widgets (example of these can be found at the methods which + * start with _fetchSpecial). + * + * @param {Object} record - an element from the localData + * @param {Object} options + * @returns {Promise<Array>} + * The promise is resolved with an array containing the names of + * the field whose special data has been changed. + */ + _fetchSpecialData: function (record, options) { + var self = this; + var specialFieldNames = []; + var fieldNames = (options && options.fieldNames) || record.getFieldNames(); + return Promise.all(_.map(fieldNames, function (name) { + var viewType = (options && options.viewType) || record.viewType; + var fieldInfo = record.fieldsInfo[viewType][name] || {}; + var Widget = fieldInfo.Widget; + if (Widget && Widget.prototype.specialData) { + return self[Widget.prototype.specialData](record, name, fieldInfo).then(function (data) { + if (data === undefined) { + return; + } + record.specialData[name] = data; + specialFieldNames.push(name); + }); + } + })).then(function () { + return specialFieldNames; + }); + }, + /** + * Fetches all the m2o records associated to the given fieldName. If the + * given fieldName is not a m2o field, nothing is done. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @param {string[]} [fieldsToRead] - the m2os fields to read (id and + * display_name are automatic). + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialMany2ones: function (record, fieldName, fieldInfo, fieldsToRead) { + var field = record.fields[fieldName]; + if (field.type !== "many2one") { + return Promise.resolve(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + if (domain.length) { + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + if (localID) { + var element = this.localData[localID]; + domain = ["|", ["id", "=", element.data.id]].concat(domain); + } + } + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domain: domain, + }); + if (!hasChanged) { + return Promise.resolve(); + } + + var self = this; + return this._rpc({ + model: field.relation, + method: 'search_read', + fields: ["id"].concat(fieldsToRead || []), + context: context, + domain: domain, + }) + .then(function (records) { + var ids = _.pluck(records, 'id'); + return self._rpc({ + model: field.relation, + method: 'name_get', + args: [ids], + context: context, + }) + .then(function (name_gets) { + _.each(records, function (rec) { + var name_get = _.find(name_gets, function (n) { + return n[0] === rec.id; + }); + rec.display_name = name_get[1]; + }); + return records; + }); + }); + }, + /** + * Fetches all the relation records associated to the given fieldName. If + * the given fieldName is not a relational field, nothing is done. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialRelation: function (record, fieldName) { + var field = record.fields[fieldName]; + if (!_.contains(["many2one", "many2many", "one2many"], field.type)) { + return Promise.resolve(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domain: domain, + }); + if (!hasChanged) { + return Promise.resolve(); + } + + return this._rpc({ + model: field.relation, + method: 'name_search', + args: ["", domain], + context: context + }); + }, + /** + * Fetches the `name_get` associated to the reference widget if the field is + * a `char` (which is a supported case). + * + * @private + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Promise} + */ + _fetchSpecialReference: function (record, fieldName) { + var def; + var field = record.fields[fieldName]; + if (field.type === 'char') { + // if the widget reference is set on a char field, the name_get + // needs to be fetched a posteriori + def = this._fetchReference(record, fieldName); + } + return Promise.resolve(def); + }, + /** + * Fetches all the m2o records associated to the given fieldName. If the + * given fieldName is not a m2o field, nothing is done. The difference with + * _fetchSpecialMany2ones is that the field given by options.fold_field is + * also fetched. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialStatus: function (record, fieldName, fieldInfo) { + var foldField = fieldInfo.options.fold_field; + var fieldsToRead = foldField ? [foldField] : []; + return this._fetchSpecialMany2ones(record, fieldName, fieldInfo, fieldsToRead).then(function (m2os) { + _.each(m2os, function (m2o) { + m2o.fold = foldField ? m2o[foldField] : false; + }); + return m2os; + }); + }, + /** + * Fetches the number of records associated to the domain the value of the + * given field represents. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialDomain: function (record, fieldName, fieldInfo) { + var self = this; + var context = record.getContext({fieldName: fieldName}); + + var domainModel = fieldInfo.options.model; + if (record.data.hasOwnProperty(domainModel)) { + domainModel = record._changes && record._changes[domainModel] || record.data[domainModel]; + } + var domainValue = record._changes && record._changes[fieldName] || record.data[fieldName] || []; + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domainModel: domainModel, + domainValue: domainValue, + }); + if (!hasChanged) { + return Promise.resolve(); + } else if (!domainModel) { + return Promise.resolve({ + model: domainModel, + nbRecords: 0, + }); + } + + return new Promise(function (resolve) { + var evalContext = self._getEvalContext(record); + self._rpc({ + model: domainModel, + method: 'search_count', + args: [Domain.prototype.stringToArray(domainValue, evalContext)], + context: context + }) + .then(function (nbRecords) { + resolve({ + model: domainModel, + nbRecords: nbRecords, + }); + }) + .guardedCatch(function (reason) { + var e = reason.event; + e.preventDefault(); // prevent traceback (the search_count might be intended to break) + resolve({ + model: domainModel, + nbRecords: 0, + }); + }); + }); + }, + /** + * Fetch all data in a ungrouped list + * + * @param {Object} list a valid resource object + * @param {Object} [options] + * @param {boolean} [options.enableRelationalFetch=true] if false, will not + * fetch x2m and relational data (that will be done by _readGroup in this + * case). + * @returns {Promise<Object>} resolves to the fecthed list + */ + _fetchUngroupedList: function (list, options) { + options = _.defaults(options || {}, {enableRelationalFetch: true}); + var self = this; + var def; + if (list.static) { + def = this._readUngroupedList(list).then(function () { + if (list.parentID && self.isNew(list.parentID)) { + // list from a default_get, so fetch display_name for many2one fields + var many2ones = self._getMany2OneFieldNames(list); + var defs = _.map(many2ones, function (name) { + return self._fetchNameGets(list, name); + }); + return Promise.all(defs); + } + }); + } else { + def = this._searchReadUngroupedList(list); + } + return def.then(function () { + if (options.enableRelationalFetch) { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + } + }).then(function () { + return list; + }); + }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Promise} + */ + _fetchX2ManyBatched: function (list, fieldName) { + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetch(list, fieldName); + return this._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * X2Manys have to be fetched by separate rpcs (their data are stored on + * different models). This method takes a record, look at its x2many fields, + * then, if necessary, create a local resource and fetch the corresponding + * data. + * + * It also tries to reuse data, if it can find an existing list, to prevent + * useless rpcs. + * + * @param {Object} record local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the list of fields to fetch. + * If not given, fetch all the fields in record.fieldNames + * @param {string} [options.viewType] the type of view for which the main + * record is fetched (useful to load the adequate fields), by defaults, + * uses record.viewType + * @returns {Promise} + */ + _fetchX2Manys: function (record, options) { + var self = this; + var defs = []; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + var viewType = options.viewType || record.viewType; + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + var fieldInfo = record.fieldsInfo[viewType][fieldName]; + var rawContext = fieldInfo && fieldInfo.context; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : (fieldInfo.fieldsInfo || {}); + var ids = record.data[fieldName] || []; + var list = self._makeDataPoint({ + count: ids.length, + context: _.extend({}, record.context, field.context), + fieldsInfo: fieldsInfo, + fields: view ? view.fields : fieldInfo.relatedFields, + limit: fieldInfo.limit, + modelName: field.relation, + res_ids: ids, + static: true, + type: 'list', + orderedBy: fieldInfo.orderedBy, + parentID: record.id, + rawContext: rawContext, + relationField: field.relation_field, + viewType: view ? view.type : fieldInfo.viewType, + }); + record.data[fieldName] = list.id; + if (!fieldInfo.__no_fetch) { + var def = self._readUngroupedList(list).then(function () { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + }); + defs.push(def); + } + } + }); + return Promise.all(defs); + }, + /** + * batch request for x2ms for datapoint of type list + * + * @param {Object} list + * @returns {Promise} + */ + _fetchX2ManysBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many') { + defs.push(this._fetchX2ManyBatched(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * For a non-static list, batches requests for all its sublists' records. + * Make only one rpc for all records on the concerned field. + * + * @see _fetchX2ManysSingleBatch + * @param {Object} list a valid resource object, its data must be another + * list containing records + * @param {string} fieldName + * @returns {Promise} + */ + _fetchX2ManySingleBatch: function (list, fieldName) { + var self = this; + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + var nextDataToFetch = self._getDataToFetch(group, fieldName); + _.each(_.keys(nextDataToFetch), function (id) { + if (toFetch[id]) { + toFetch[id] = toFetch[id].concat(nextDataToFetch[id]); + } else { + toFetch[id] = nextDataToFetch[id]; + } + }); + }); + return self._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * Batch requests for all x2m in list's children. + * Called by _readGroup to make only one 'read' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Promise} + */ + _fetchX2ManysSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many'){ + defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * Generates an object mapping field names to their changed value in a given + * record (i.e. maps to the new value for basic fields, to the res_id for + * many2ones and to commands for x2manys). + * + * @private + * @param {Object} record + * @param {Object} [options] + * @param {boolean} [options.changesOnly=true] if true, only generates + * commands for fields that have changed (concerns x2many fields only) + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record. Note that if an editionViewType is + * specified for a field, it will take the priority over the viewType arg. + * @returns {Object} a map from changed fields to their new value + */ + _generateChanges: function (record, options) { + options = options || {}; + var viewType = options.viewType || record.viewType; + var changes; + const changesOnly = 'changesOnly' in options ? !!options.changesOnly : true; + if (!changesOnly) { + changes = _.extend({}, record.data, record._changes); + } else { + changes = _.extend({}, record._changes); + } + var withReadonly = options.withReadonly || false; + var commands = this._generateX2ManyCommands(record, { + changesOnly: changesOnly, + withReadonly: withReadonly, + }); + for (var fieldName in record.fields) { + // remove readonly fields from the list of changes + if (!withReadonly && fieldName in changes || fieldName in commands) { + var editionViewType = record._editionViewType[fieldName] || viewType; + if (this._isFieldProtected(record, fieldName, editionViewType)) { + delete changes[fieldName]; + continue; + } + } + + // process relational fields and handle the null case + var type = record.fields[fieldName].type; + var value; + if (type === 'one2many' || type === 'many2many') { + if (!changesOnly || (commands[fieldName] && commands[fieldName].length)) { // replace localId by commands + changes[fieldName] = commands[fieldName]; + } else { // no command -> no change for that field + delete changes[fieldName]; + } + } else if (type === 'many2one' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? this.localData[value].res_id : false; + } else if (type === 'reference' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? + this.localData[value].model + ',' + this.localData[value].res_id : + false; + } else if (type === 'char' && changes[fieldName] === '') { + changes[fieldName] = false; + } else if (changes[fieldName] === null) { + changes[fieldName] = false; + } + } + return changes; + }, + /** + * Generates an object mapping field names to their current value in a given + * record. If the record is inside a one2many, the returned object contains + * an additional key (the corresponding many2one field name) mapping to the + * current value of the parent record. + * + * @param {Object} record + * @param {Object} [options] This option object will be given to the private + * method _generateX2ManyCommands. In particular, it is useful to be able + * to send changesOnly:true to get all data, not only the current changes. + * @returns {Object} the data + */ + _generateOnChangeData: function (record, options) { + options = _.extend({}, options || {}, {withReadonly: true}); + var data = {}; + if (!options.firstOnChange) { + var commands = this._generateX2ManyCommands(record, options); + data = _.extend(this.get(record.id, {raw: true}).data, commands); + // 'display_name' is automatically added to the list of fields to fetch, + // when fetching a record, even if it doesn't appear in the view. However, + // only the fields in the view must be passed to the onchange RPC, so we + // remove it from the data sent by RPC if it isn't in the view. + var hasDisplayName = _.some(record.fieldsInfo, function (fieldsInfo) { + return 'display_name' in fieldsInfo; + }); + if (!hasDisplayName) { + delete data.display_name; + } + } + + // one2many records have a parentID + if (record.parentID) { + var parent = this.localData[record.parentID]; + // parent is the list element containing all the records in the + // one2many and parent.parentID is the ID of the main record + // if there is a relation field, this means that record is an elem + // in a one2many. The relation field is the corresponding many2one + if (parent.parentID && parent.relationField) { + var parentRecord = this.localData[parent.parentID]; + data[parent.relationField] = this._generateOnChangeData(parentRecord); + } + } + + return data; + }, + /** + * Read all x2many fields and generate the commands for the server to create + * or write them... + * + * @param {Object} record + * @param {Object} [options] + * @param {string} [options.fieldNames] if given, generates the commands for + * these fields only + * @param {boolean} [changesOnly=false] if true, only generates commands for + * fields that have changed + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields in commands + * @returns {Object} a map from some field names to commands + */ + _generateX2ManyCommands: function (record, options) { + var self = this; + options = options || {}; + var fields = record.fields; + if (options.fieldNames) { + fields = _.pick(fields, options.fieldNames); + } + var commands = {}; + var data = _.extend({}, record.data, record._changes); + var type; + for (var fieldName in fields) { + type = fields[fieldName].type; + + if (type === 'many2many' || type === 'one2many') { + if (!data[fieldName]) { + // skip if this field is empty + continue; + } + commands[fieldName] = []; + var list = this.localData[data[fieldName]]; + if (options.changesOnly && (!list._changes || !list._changes.length)) { + // if only changes are requested, skip if there is no change + continue; + } + var oldResIDs = list.res_ids.slice(0); + var relRecordAdded = []; + var relRecordUpdated = []; + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && change.id) { + relRecordAdded.push(self.localData[change.id]); + } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) { + // ignore new records that would have been updated + // afterwards, as all their changes would already + // be aggregated in the CREATE command + relRecordUpdated.push(self.localData[change.id]); + } + }); + list = this._applyX2ManyOperations(list); + this._sortList(list); + if (type === 'many2many' || list._forceM2MLink) { + var relRecordCreated = _.filter(relRecordAdded, function (rec) { + return typeof rec.res_id === 'string'; + }); + var realIDs = _.difference(list.res_ids, _.pluck(relRecordCreated, 'res_id')); + // deliberately generate a single 'replace' command instead + // of a 'delete' and a 'link' commands with the exact diff + // because 1) performance-wise it doesn't change anything + // and 2) to guard against concurrent updates (policy: force + // a complete override of the actual value of the m2m) + commands[fieldName].push(x2ManyCommands.replace_with(realIDs)); + _.each(relRecordCreated, function (relRecord) { + var changes = self._generateChanges(relRecord, options); + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + }); + // generate update commands for records that have been + // updated (it may happen with editable lists) + _.each(relRecordUpdated, function (relRecord) { + var changes = self._generateChanges(relRecord, options); + if (!_.isEmpty(changes)) { + var command = x2ManyCommands.update(relRecord.res_id, changes); + commands[fieldName].push(command); + } + }); + } else if (type === 'one2many') { + var removedIds = _.difference(oldResIDs, list.res_ids); + var addedIds = _.difference(list.res_ids, oldResIDs); + var keptIds = _.intersection(oldResIDs, list.res_ids); + + // the didChange variable keeps track of the fact that at + // least one id was updated + var didChange = false; + var changes, command, relRecord; + for (var i = 0; i < list.res_ids.length; i++) { + if (_.contains(keptIds, list.res_ids[i])) { + // this is an id that already existed + relRecord = _.findWhere(relRecordUpdated, {res_id: list.res_ids[i]}); + changes = relRecord ? this._generateChanges(relRecord, options) : {}; + if (!_.isEmpty(changes)) { + command = x2ManyCommands.update(relRecord.res_id, changes); + didChange = true; + } else { + command = x2ManyCommands.link_to(list.res_ids[i]); + } + commands[fieldName].push(command); + } else if (_.contains(addedIds, list.res_ids[i])) { + // this is a new id (maybe existing in DB, but new in JS) + relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); + if (!relRecord) { + commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i])); + continue; + } + changes = this._generateChanges(relRecord, options); + if (!this.isNew(relRecord.id)) { + // the subrecord already exists in db + commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id)); + if (this.isDirty(relRecord.id)) { + delete changes.id; + commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes)); + } + } else { + // the subrecord is new, so create it + + // we may have received values from an onchange for fields that are + // not in the view, and that we don't even know, as we don't have the + // fields_get of models of related fields. We save those values + // anyway, but for many2ones, we have to extract the id from the pair + // [id, display_name] + const rawChangesEntries = Object.entries(relRecord._rawChanges); + for (const [fieldName, value] of rawChangesEntries) { + const isMany2OneValue = Array.isArray(value) && + value.length === 2 && + Number.isInteger(value[0]) && + typeof value[1] === 'string'; + changes[fieldName] = isMany2OneValue ? value[0] : value; + } + + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + } + } + } + if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { + // in this situation, we have no changed ids, no added + // ids and no removed ids, so we can safely ignore the + // last changes + commands[fieldName] = []; + } + // add delete commands + for (i = 0; i < removedIds.length; i++) { + if (list._forceM2MUnlink) { + commands[fieldName].push(x2ManyCommands.forget(removedIds[i])); + } else { + commands[fieldName].push(x2ManyCommands.delete(removedIds[i])); + } + } + } + } + } + return commands; + }, + /** + * Every RPC done by the model need to add some context, which is a + * combination of the context of the session, of the record/list, and/or of + * the concerned field. This method combines all these contexts and evaluate + * them with the proper evalcontext. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string|Object} [options.additionalContext] + * another context to evaluate and merge to the returned context + * @param {string} [options.fieldName] + * if given, this field's context is added to the context, instead of + * the element's context (except if options.full is true) + * @param {boolean} [options.full=false] + * if true or nor fieldName or additionalContext given in options, + * the element's context is added to the context + * @returns {Object} the evaluated context + */ + _getContext: function (element, options) { + options = options || {}; + var context = new Context(session.user_context); + context.set_eval_context(this._getEvalContext(element)); + + if (options.full || !(options.fieldName || options.additionalContext)) { + context.add(element.context); + } + if (options.fieldName) { + var viewType = options.viewType || element.viewType; + var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; + if (fieldInfo && fieldInfo.context) { + context.add(fieldInfo.context); + } else { + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.context) { + context.add(fieldParams.context); + } + } + } + if (options.additionalContext) { + context.add(options.additionalContext); + } + if (element.rawContext) { + var rawContext = new Context(element.rawContext); + var evalContext = this._getEvalContext(this.localData[element.parentID]); + evalContext.id = evalContext.id || false; + rawContext.set_eval_context(evalContext); + context.add(rawContext); + } + + return context.eval(); + }, + /** + * Collects from a record a list of ids to fetch, according to fieldName, + * and a list of records where to set the result of the fetch. + * + * @param {Object} list a list containing records we want to get the ids, + * it assumes _applyX2ManyOperations and _sort have been already called on + * this list + * @param {string} fieldName + * @return {Object} a list of records and res_ids + */ + _getDataToFetch: function (list, fieldName) { + var self = this; + var field = list.fields[fieldName]; + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + var toFetch = {}; + + // flattens the list.data ids in a grouped case + let dataPointIds = list.data; + for (let i = 0; i < list.groupedBy.length; i++) { + dataPointIds = dataPointIds.reduce((acc, groupId) => + acc.concat(this.localData[groupId].data), []); + } + + dataPointIds.forEach(function (dataPoint) { + var record = self.localData[dataPoint]; + if (typeof record.data[fieldName] === 'string'){ + // in this case, the value is a local ID, which means that the + // record has already been processed. It can happen for example + // when a user adds a record in a m2m relation, or loads more + // records in a kanban column + return; + } + + _.each(record.data[fieldName], function (id) { + toFetch[id] = toFetch[id] || []; + toFetch[id].push(record); + }); + + var m2mList = self._makeDataPoint({ + fieldsInfo: fieldsInfo, + fields: fields, + modelName: field.relation, + parentID: record.id, + res_ids: record.data[fieldName], + static: true, + type: 'list', + viewType: viewType, + }); + record.data[fieldName] = m2mList.id; + }); + + return toFetch; + }, + /** + * Determines and returns from a list a collection of ids classed by + * their model. + * + * @param {Object} list a valid resource object + * @param {string} fieldName + * @param {Object} [toFetchAcc] an object to store fetching data. Used when + * batching reference across multiple groups. + * [modelName: string]: { + * [recordId: number]: datapointId[] + * } + * @returns {Object} each key represent a model and contain a sub-object + * where each key represent an id (res_id) containing an array of + * webclient id (referred to a datapoint, so not a res_id). + */ + _getDataToFetchByModel: function (list, fieldName, toFetchAcc) { + var self = this; + var toFetch = toFetchAcc || {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var value = record.data[fieldName]; + // if the reference field has already been fetched, the value is a + // datapoint ID, and in this case there's nothing to do + if (value && !self.localData[value]) { + var model = value.split(',')[0]; + var resID = value.split(',')[1]; + if (!(model in toFetch)) { + toFetch[model] = {}; + } + // there could be multiple datapoints with the same model/resID + if (toFetch[model][resID]) { + toFetch[model][resID].push(dataPoint); + } else { + toFetch[model][resID] = [dataPoint]; + } + } + }); + return toFetch; + }, + /** + * Given a dataPoint of type list (that may be a group), returns an object + * with 'default_' keys to be used to create new records in that group. + * + * @private + * @param {Object} dataPoint + * @returns {Object} + */ + _getDefaultContext: function (dataPoint) { + var defaultContext = {}; + while (dataPoint.parentID) { + var parent = this.localData[dataPoint.parentID]; + var groupByField = parent.groupedBy[0].split(':')[0]; + var value = viewUtils.getGroupValue(dataPoint, groupByField); + if (value) { + defaultContext['default_' + groupByField] = value; + } + dataPoint = parent; + } + return defaultContext; + }, + /** + * Some records are associated to a/some domain(s). This method allows to + * retrieve them, evaluated. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string} [options.fieldName] + * the name of the field whose domain needs to be returned + * @returns {Array} the evaluated domain + */ + _getDomain: function (element, options) { + if (options && options.fieldName) { + if (element._domains[options.fieldName]) { + return Domain.prototype.stringToArray( + element._domains[options.fieldName], + this._getEvalContext(element, true) + ); + } + var viewType = options.viewType || element.viewType; + var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; + if (fieldInfo && fieldInfo.domain) { + return Domain.prototype.stringToArray( + fieldInfo.domain, + this._getEvalContext(element, true) + ); + } + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.domain) { + return Domain.prototype.stringToArray( + fieldParams.domain, + this._getEvalContext(element, true) + ); + } + return []; + } + + return Domain.prototype.stringToArray( + element.domain, + this._getEvalContext(element, true) + ); + }, + /** + * Returns the evaluation context that should be used when evaluating the + * context/domain associated to a given element from the localData. + * + * It is actually quite subtle. We need to add some magic keys: active_id + * and active_ids. Also, the session user context is added in the mix to be + * sure. This allows some domains to use the uid key for example + * + * @param {Object} element - an element from the localData + * @param {boolean} [forDomain=false] if true, evaluates x2manys as a list of + * ids instead of a list of commands + * @returns {Object} + */ + _getEvalContext: function (element, forDomain) { + var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {}; + + if (element.parentID) { + var parent = this.localData[element.parentID]; + if (parent.type === 'list' && parent.parentID) { + parent = this.localData[parent.parentID]; + } + if (parent.type === 'record') { + evalContext.parent = this._getRecordEvalContext(parent, forDomain); + } + } + // Uses "current_company_id" because "company_id" would conflict with all the company_id fields + // in general, the actual "company_id" field of the form should be used for m2o domains, not this fallback + let current_company_id; + if (session.user_context.allowed_company_ids) { + current_company_id = session.user_context.allowed_company_ids[0]; + } else { + current_company_id = session.user_companies ? + session.user_companies.current_company[0] : + false; + } + return Object.assign( + { + active_id: evalContext.id || false, + active_ids: evalContext.id ? [evalContext.id] : [], + active_model: element.model, + current_company_id, + id: evalContext.id || false, + }, + pyUtils.context(), + session.user_context, + element.context, + evalContext, + ); + }, + /** + * Returns the list of field names of the given element according to its + * default view type. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {string[]} the list of field names + */ + _getFieldNames: function (element, options) { + var fieldsInfo = element.fieldsInfo; + var viewType = options && options.viewType || element.viewType; + return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); + }, + /** + * Get many2one fields names in a datapoint. This is useful in order to + * fetch their names in the case of a default_get. + * + * @private + * @param {Object} datapoint a valid resource object + * @returns {string[]} list of field names that are many2one + */ + _getMany2OneFieldNames: function (datapoint) { + var many2ones = []; + _.each(datapoint.fields, function (field, name) { + if (field.type === 'many2one') { + many2ones.push(name); + } + }); + return many2ones; + }, + /** + * Evaluate the record evaluation context. This method is supposed to be + * called by _getEvalContext. It basically only generates a dictionary of + * current values for the record, with commands for x2manys fields. + * + * @param {Object} record an element of type 'record' + * @param {boolean} [forDomain=false] if true, x2many values are a list of + * ids instead of a list of commands + * @returns Object + */ + _getRecordEvalContext: function (record, forDomain) { + var self = this; + var relDataPoint; + var context = _.extend({}, record.data, record._changes); + + // calls _generateX2ManyCommands for a given field, and returns the array of commands + function _generateX2ManyCommands(fieldName) { + var commands = self._generateX2ManyCommands(record, {fieldNames: [fieldName]}); + return commands[fieldName]; + } + + for (var fieldName in context) { + var field = record.fields[fieldName]; + if (context[fieldName] === null) { + context[fieldName] = false; + } + if (!field || field.name === 'id') { + continue; + } + if (field.type === 'date' || field.type === 'datetime') { + if (context[fieldName]) { + context[fieldName] = JSON.parse(JSON.stringify(context[fieldName])); + } + continue; + } + if (field.type === 'many2one') { + relDataPoint = this.localData[context[fieldName]]; + context[fieldName] = relDataPoint ? relDataPoint.res_id : false; + continue; + } + if (field.type === 'one2many' || field.type === 'many2many') { + var ids; + if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet + ids = context[fieldName] ? context[fieldName].slice(0) : []; + } else { + relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); + ids = relDataPoint.res_ids.slice(0); + } + if (!forDomain) { + // when sent to the server, the x2manys values must be a list + // of commands in a context, but the list of ids in a domain + ids.toJSON = _generateX2ManyCommands.bind(null, fieldName); + } else if (field.type === 'one2many') { // Ids are evaluated as a list of ids + /* Filtering out virtual ids from the ids list + * The server will crash if there are virtual ids in there + * The webClient doesn't do literal id list comparison like ids == list + * Only relevant in o2m: m2m does create actual records in db + */ + ids = _.filter(ids, function (id) { + return typeof id !== 'string'; + }); + } + context[fieldName] = ids; + } + + } + return context; + }, + /** + * Invalidates the DataManager's cache if the main model (i.e. the model of + * its root parent) of the given dataPoint is a model in 'noCacheModels'. + * + * Reloads the currencies if the main model is 'res.currency'. + * Reloads the webclient if we modify a res.company, to (un)activate the + * multi-company environment if we are not in a tour test. + * + * @private + * @param {Object} dataPoint + */ + _invalidateCache: function (dataPoint) { + while (dataPoint.parentID) { + dataPoint = this.localData[dataPoint.parentID]; + } + if (dataPoint.model === 'res.currency') { + session.reloadCurrencies(); + } + if (dataPoint.model === 'res.company' && !localStorage.getItem('running_tour')) { + this.do_action('reload_context'); + } + if (_.contains(this.noCacheModels, dataPoint.model)) { + core.bus.trigger('clear_cache'); + } + }, + /** + * Returns true if the field is protected against changes, looking for a + * readonly modifier unless there is a force_save modifier (checking first + * in the modifiers, and if there is no readonly modifier, checking the + * readonly attribute of the field). + * + * @private + * @param {Object} record an element from the localData + * @param {string} fieldName + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {boolean} + */ + _isFieldProtected: function (record, fieldName, viewType) { + viewType = viewType || record.viewType; + var fieldInfo = viewType && record.fieldsInfo && record.fieldsInfo[viewType][fieldName]; + if (fieldInfo) { + var rawModifiers = fieldInfo.modifiers || {}; + var modifiers = this._evalModifiers(record, _.pick(rawModifiers, 'readonly')); + return modifiers.readonly && !fieldInfo.force_save; + } else { + return false; + } + }, + /** + * Returns true iff value is considered to be set for the given field's type. + * + * @private + * @param {any} value a value for the field + * @param {string} fieldType a type of field + * @returns {boolean} + */ + _isFieldSet: function (value, fieldType) { + switch (fieldType) { + case 'boolean': + return true; + case 'one2many': + case 'many2many': + return value.length > 0; + default: + return value !== false; + } + }, + /** + * return true if a list element is 'valid'. Such an element is valid if it + * has no sub record with an unset required field. + * + * This method is meant to be used to check if a x2many change will trigger + * an onchange. + * + * @param {string} id id for a local resource of type 'list'. This is + * assumed to be a list element for an x2many + * @returns {boolean} + */ + _isX2ManyValid: function (id) { + var self = this; + var isValid = true; + var element = this.localData[id]; + _.each(element._changes, function (command) { + if (command.operation === 'DELETE' || + command.operation === 'FORGET' || + (command.operation === 'ADD' && !command.isNew)|| + command.operation === 'REMOVE_ALL') { + return; + } + var recordData = self.get(command.id, {raw: true}).data; + var record = self.localData[command.id]; + _.each(element.getFieldNames(), function (fieldName) { + var field = element.fields[fieldName]; + var fieldInfo = element.fieldsInfo[element.viewType][fieldName]; + var rawModifiers = fieldInfo.modifiers || {}; + var modifiers = self._evalModifiers(record, _.pick(rawModifiers, 'required')); + if (modifiers.required && !self._isFieldSet(recordData[fieldName], field.type)) { + isValid = false; + } + }); + }); + return isValid; + }, + /** + * Helper method for the load entry point. + * + * @see load + * + * @param {Object} dataPoint some local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the fields to fetch for a record + * @param {boolean} [options.onlyGroups=false] + * @param {boolean} [options.keepEmptyGroups=false] if set, the groups not + * present in the read_group anymore (empty groups) will stay in the + * datapoint (used to mimic the kanban renderer behaviour for example) + * @returns {Promise} + */ + _load: function (dataPoint, options) { + if (options && options.onlyGroups && + !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) { + return Promise.resolve(dataPoint); + } + + if (dataPoint.type === 'record') { + return this._fetchRecord(dataPoint, options); + } + if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { + return this._readGroup(dataPoint, options); + } + if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { + return this._fetchUngroupedList(dataPoint, options); + } + }, + /** + * Turns a bag of properties into a valid local resource. Also, register + * the resource in the localData object. + * + * @param {Object} params + * @param {Object} [params.aggregateValues={}] + * @param {Object} [params.context={}] context of the action + * @param {integer} [params.count=0] number of record being manipulated + * @param {Object|Object[]} [params.data={}|[]] data of the record + * @param {*[]} [params.domain=[]] + * @param {Object} params.fields contains the description of each field + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Object[]} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {string[]} [params.groupedBy=[]] + * @param {boolean} [params.isOpen] + * @param {integer} params.limit max number of records shown on screen (pager size) + * @param {string} params.modelName + * @param {integer} [params.offset] + * @param {boolean} [params.openGroupByDefault] + * @param {Object[]} [params.orderedBy=[]] + * @param {integer[]} [params.orderedResIDs] + * @param {string} [params.parentID] model name ID of the parent model + * @param {Object} [params.rawContext] + * @param {[type]} [params.ref] + * @param {string} [params.relationField] + * @param {integer|null} [params.res_id] actual id of record in the server + * @param {integer[]} [params.res_ids] context in which the data point is used, from a list of res_id + * @param {boolean} [params.static=false] + * @param {string} [params.type='record'|'list'] + * @param {[type]} [params.value] + * @param {string} [params.viewType] the type of the view, e.g. 'list' or 'form' + * @returns {Object} the resource created + */ + _makeDataPoint: function (params) { + var type = params.type || ('domain' in params && 'list') || 'record'; + var res_id, value; + var res_ids = params.res_ids || []; + var data = params.data || (type === 'record' ? {} : []); + var context = params.context; + if (type === 'record') { + res_id = params.res_id || (params.data && params.data.id); + if (res_id) { + data.id = res_id; + } else { + res_id = _.uniqueId('virtual_'); + } + // it doesn't make sense for a record datapoint to have those keys + // besides, it will mess up x2m and actions down the line + context = _.omit(context, ['orderedBy', 'group_by']); + } else { + var isValueArray = params.value instanceof Array; + res_id = isValueArray ? params.value[0] : undefined; + value = isValueArray ? params.value[1] : params.value; + } + + var fields = _.extend({ + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, params.fields); + + var dataPoint = { + _cache: type === 'list' ? {} : undefined, + _changes: null, + _domains: {}, + _rawChanges: {}, + aggregateValues: params.aggregateValues || {}, + context: context, + count: params.count || res_ids.length, + data: data, + domain: params.domain || [], + fields: fields, + fieldsInfo: params.fieldsInfo, + groupedBy: params.groupedBy || [], + groupsCount: 0, + groupsLimit: type === 'list' && params.groupsLimit || null, + groupsOffset: 0, + id: `${params.modelName}_${++this.__id}`, + isOpen: params.isOpen, + limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER), + loadMoreOffset: 0, + model: params.modelName, + offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0), + openGroupByDefault: params.openGroupByDefault, + orderedBy: params.orderedBy || [], + orderedResIDs: params.orderedResIDs, + parentID: params.parentID, + rawContext: params.rawContext, + ref: params.ref || res_id, + relationField: params.relationField, + res_id: res_id, + res_ids: res_ids, + specialData: {}, + _specialDataCache: {}, + static: params.static || false, + type: type, // 'record' | 'list' + value: value, + viewType: params.viewType, + }; + + // _editionViewType is a dict whose keys are field names and which is populated when a field + // is edited with the viewType as value. This is useful for one2manys to determine whether + // or not a field is readonly (using the readonly modifiers of the view in which the field + // has been edited) + dataPoint._editionViewType = {}; + + dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint); + dataPoint.getContext = this._getContext.bind(this, dataPoint); + dataPoint.getDomain = this._getDomain.bind(this, dataPoint); + dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint); + dataPoint.isDirty = this.isDirty.bind(this, dataPoint.id); + + this.localData[dataPoint.id] = dataPoint; + + return dataPoint; + }, + /** + * When one needs to create a record from scratch, a not so simple process + * needs to be done: + * - call the /default_get route to get default values + * - fetch all relational data + * - apply all onchanges if necessary + * - fetch all relational data + * + * This method tries to optimize the process as much as possible. Also, + * it is quite horrible and should be refactored at some point. + * + * @private + * @param {any} params + * @param {string} modelName model name + * @param {boolean} [params.allowWarning=false] if true, the default record + * operation can complete, even if a warning is raised + * @param {Object} params.context the context for the new record + * @param {Object} params.fieldsInfo contains the fieldInfo of each view, + * for each field + * @param {Object} params.fields contains the description of each field + * @param {Object} params.context the context for the new record + * @param {string} params.viewType the key in fieldsInfo of the fields to load + * @returns {Promise<string>} resolves to the id for the created resource + */ + async _makeDefaultRecord(modelName, params) { + var targetView = params.viewType; + var fields = params.fields; + var fieldsInfo = params.fieldsInfo; + var fieldNames = Object.keys(fieldsInfo[targetView]); + + // Fields that are present in the originating view, that need to be initialized + // Hence preventing their value to crash when getting back to the originating view + var parentRecord = params.parentID && this.localData[params.parentID].type === 'list' ? this.localData[params.parentID] : null; + + if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) { + var originView = parentRecord.viewType; + fieldNames = _.union(fieldNames, Object.keys(parentRecord.fieldsInfo[originView])); + fieldsInfo[targetView] = _.defaults({}, fieldsInfo[targetView], parentRecord.fieldsInfo[originView]); + fields = _.defaults({}, fields, parentRecord.fields); + } + + var record = this._makeDataPoint({ + modelName: modelName, + fields: fields, + fieldsInfo: fieldsInfo, + context: params.context, + parentID: params.parentID, + res_ids: params.res_ids, + viewType: targetView, + }); + + await this.generateDefaultValues(record.id, {}, { fieldNames }); + try { + await this._performOnChange(record, [], { firstOnChange: true }); + } finally { + if (record._warning && params.allowWarning) { + delete record._warning; + } + } + if (record._warning) { + return Promise.reject(); + } + + // We want to overwrite the default value of the handle field (if any), + // in order for new lines to be added at the correct position. + // -> This is a rare case where the defaul_get from the server + // will be ignored by the view for a certain field (usually "sequence"). + var overrideDefaultFields = this._computeOverrideDefaultFields(params.parentID, params.position); + if (overrideDefaultFields.field) { + record._changes[overrideDefaultFields.field] = overrideDefaultFields.value; + } + + // fetch additional data (special data and many2one namegets for "always_reload" fields) + await this._postprocess(record); + // save initial changes, so they can be restored later, if we need to discard + this.save(record.id, { savePoint: true }); + return record.id; + }, + /** + * parse the server values to javascript framwork + * + * @param {[string]} fieldNames + * @param {Object} element the dataPoint used as parent for the created + * dataPoints + * @param {Object} data the server data to parse + */ + _parseServerData: function (fieldNames, element, data) { + var self = this; + _.each(fieldNames, function (fieldName) { + var field = element.fields[fieldName]; + var val = data[fieldName]; + if (field.type === 'many2one') { + // process many2one: split [id, nameget] and create corresponding record + if (val !== false) { + // the many2one value is of the form [id, display_name] + var r = self._makeDataPoint({ + modelName: field.relation, + fields: { + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, + data: { + display_name: val[1], + id: val[0], + }, + parentID: element.id, + }); + data[fieldName] = r.id; + } else { + // no value for the many2one + data[fieldName] = false; + } + } else { + data[fieldName] = self._parseServerValue(field, val); + } + }); + }, + /** + * This method is quite important: it is supposed to perform the /onchange + * rpc and apply the result. + * + * The changes that triggered the onchange are assumed to have already been + * applied to the record. + * + * @param {Object} record + * @param {string[]} fields changed fields (empty list in the case of first + * onchange) + * @param {Object} [options={}] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.firstOnChange=false] set to true if this is the + * first onchange + * @returns {Promise} + */ + async _performOnChange(record, fields, options = {}) { + const firstOnChange = options.firstOnChange; + let { hasOnchange, onchangeSpec } = this._buildOnchangeSpecs(record, options.viewType); + if (!firstOnChange && !hasOnchange) { + return; + } + var idList = record.data.id ? [record.data.id] : []; + const ctxOptions = { + full: true, + }; + if (fields.length === 1) { + fields = fields[0]; + // if only one field changed, add its context to the RPC context + ctxOptions.fieldName = fields; + } + var context = this._getContext(record, ctxOptions); + var currentData = this._generateOnChangeData(record, { + changesOnly: false, + firstOnChange, + }); + + const result = await this._rpc({ + model: record.model, + method: 'onchange', + args: [idList, currentData, fields, onchangeSpec], + context: context, + }); + if (!record._changes) { + // if the _changes key does not exist anymore, it means that + // it was removed by discarding the changes after the rpc + // to onchange. So, in that case, the proper response is to + // ignore the onchange. + return; + } + if (result.warning) { + this.trigger_up('warning', result.warning); + record._warning = true; + } + if (result.domain) { + record._domains = Object.assign(record._domains, result.domain); + } + await this._applyOnChange(result.value, record, { firstOnChange }); + return result; + }, + /** + * This function accumulates RPC requests done in the same call stack, and + * performs them in the next micro task tick so that similar requests can be + * batched in a single RPC. + * + * For now, only 'read' calls are supported. + * + * @private + * @param {Object} params + * @returns {Promise} + */ + _performRPC: function (params) { + var self = this; + + // save the RPC request + var request = _.extend({}, params); + var prom = new Promise(function (resolve, reject) { + request.resolve = resolve; + request.reject = reject; + }); + this.batchedRPCsRequests.push(request); + + // empty the pool of RPC requests in the next micro tick + Promise.resolve().then(function () { + if (!self.batchedRPCsRequests.length) { + // pool has already been processed + return; + } + + // reset pool of RPC requests + var batchedRPCsRequests = self.batchedRPCsRequests; + self.batchedRPCsRequests = []; + + // batch similar requests + var batches = {}; + var key; + for (var i = 0; i < batchedRPCsRequests.length; i++) { + var request = batchedRPCsRequests[i]; + key = request.model + ',' + JSON.stringify(request.context); + if (!batches[key]) { + batches[key] = _.extend({}, request, {requests: [request]}); + } else { + batches[key].ids = _.uniq(batches[key].ids.concat(request.ids)); + batches[key].fieldNames = _.uniq(batches[key].fieldNames.concat(request.fieldNames)); + batches[key].requests.push(request); + } + } + + // perform batched RPCs + function onSuccess(batch, results) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + var fieldNames = request.fieldNames.concat(['id']); + var filteredResults = results.filter(function (record) { + return request.ids.indexOf(record.id) >= 0; + }).map(function (record) { + return _.pick(record, fieldNames); + }); + request.resolve(filteredResults); + } + } + function onFailure(batch, error) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + request.reject(error); + } + } + for (key in batches) { + var batch = batches[key]; + self._rpc({ + model: batch.model, + method: 'read', + args: [batch.ids, batch.fieldNames], + context: batch.context, + }).then(onSuccess.bind(null, batch)).guardedCatch(onFailure.bind(null, batch)); + } + }); + + return prom; + }, + /** + * Once a record is created and some data has been fetched, we need to do + * quite a lot of computations to determine what needs to be fetched. This + * method is doing that. + * + * @see _fetchRecord @see _makeDefaultRecord + * + * @param {Object} record + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise<Object>} resolves to the finished resource + */ + _postprocess: function (record, options) { + var self = this; + var viewType = options && options.viewType || record.viewType; + var defs = []; + + _.each(record.getFieldNames(options), function (name) { + var field = record.fields[name]; + var fieldInfo = record.fieldsInfo[viewType][name] || {}; + var options = fieldInfo.options || {}; + if (options.always_reload) { + if (record.fields[name].type === 'many2one') { + const _changes = record._changes || {}; + const relRecordId = _changes[name] || record.data[name]; + if (!relRecordId) { + return; // field is unset, no need to do the name_get + } + var relRecord = self.localData[relRecordId]; + defs.push(self._rpc({ + model: field.relation, + method: 'name_get', + args: [relRecord.data.id], + context: self._getContext(record, {fieldName: name, viewType: viewType}), + }) + .then(function (result) { + relRecord.data.display_name = result[0][1]; + })); + } + } + }); + + defs.push(this._fetchSpecialData(record, options)); + + return Promise.all(defs).then(function () { + return record; + }); + }, + /** + * Process x2many commands in a default record by transforming the list of + * commands in operations (pushed in _changes) and fetch the related + * records fields. + * + * Note that this method can be called recursively. + * + * @todo in master: factorize this code with the postprocessing of x2many in + * _applyOnChange + * + * @private + * @param {Object} record + * @param {string} fieldName + * @param {Array[Array]} commands + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + */ + _processX2ManyCommands: function (record, fieldName, commands, options) { + var self = this; + options = options || {}; + var defs = []; + var field = record.fields[fieldName]; + var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName] || {}; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + // remove default_* keys from parent context to avoid issue of same field name in x2m + var parentContext = _.omit(record.context, function (val, key) { + return _.str.startsWith(key, 'default_'); + }); + var x2manyList = self._makeDataPoint({ + context: parentContext, + fieldsInfo: fieldsInfo, + fields: fields, + limit: fieldInfo.limit, + modelName: field.relation, + parentID: record.id, + rawContext: fieldInfo && fieldInfo.context, + relationField: field.relation_field, + res_ids: [], + static: true, + type: 'list', + viewType: viewType, + }); + record._changes[fieldName] = x2manyList.id; + x2manyList._changes = []; + var many2ones = {}; + var r; + commands = commands || []; // handle false value + var isCommandList = commands.length && _.isArray(commands[0]); + if (!isCommandList) { + commands = [[6, false, commands]]; + } + _.each(commands, function (value) { + // value is a command + if (value[0] === 0) { + // CREATE + r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fieldsInfo: fieldsInfo, + fields: fields, + parentID: x2manyList.id, + viewType: viewType, + }); + r._noAbandon = true; + x2manyList._changes.push({operation: 'ADD', id: r.id}); + x2manyList._cache[r.res_id] = r.id; + + // this is necessary so the fields are initialized + _.each(r.getFieldNames(), function (fieldName) { + r.data[fieldName] = null; + }); + + r._changes = _.defaults(value[2], r.data); + for (var fieldName in r._changes) { + if (!r._changes[fieldName]) { + continue; + } + var isFieldInView = fieldName in r.fields; + if (isFieldInView) { + var field = r.fields[fieldName]; + var fieldType = field.type; + var rec; + if (fieldType === 'many2one') { + rec = self._makeDataPoint({ + context: r.context, + modelName: field.relation, + data: {id: r._changes[fieldName]}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; + } else if (fieldType === 'reference') { + var reference = r._changes[fieldName].split(','); + rec = self._makeDataPoint({ + context: r.context, + modelName: reference[0], + data: {id: parseInt(reference[1])}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; + } else if (_.contains(['one2many', 'many2many'], fieldType)) { + var x2mCommands = value[2][fieldName]; + defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); + } else { + r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]); + } + } + } + } + if (value[0] === 6) { + // REPLACE_WITH + _.each(value[2], function (res_id) { + x2manyList._changes.push({operation: 'ADD', resID: res_id}); + }); + var def = self._readUngroupedList(x2manyList).then(function () { + return Promise.all([ + self._fetchX2ManysBatched(x2manyList), + self._fetchReferencesBatched(x2manyList) + ]); + }); + defs.push(def); + } + }); + + // fetch many2ones display_name + _.each(_.keys(many2ones), function (name) { + defs.push(self._fetchNameGets(x2manyList, name)); + }); + + return Promise.all(defs); + }, + /** + * Reads data from server for all missing fields. + * + * @private + * @param {Object} list a valid resource object + * @param {interger[]} resIDs + * @param {string[]} fieldNames to check and read if missing + * @returns {Promise<Object>} + */ + _readMissingFields: function (list, resIDs, fieldNames) { + var self = this; + + var missingIDs = []; + for (var i = 0, len = resIDs.length; i < len; i++) { + var resId = resIDs[i]; + var dataPointID = list._cache[resId]; + if (!dataPointID) { + missingIDs.push(resId); + continue; + } + var record = self.localData[dataPointID]; + var data = _.extend({}, record.data, record._changes); + if (_.difference(fieldNames, _.keys(data)).length) { + missingIDs.push(resId); + } + } + + var def; + if (missingIDs.length && fieldNames.length) { + def = self._performRPC({ + context: list.getContext(), + fieldNames: fieldNames, + ids: missingIDs, + method: 'read', + model: list.model, + }); + } else { + def = Promise.resolve(_.map(missingIDs, function (id) { + return {id:id}; + })); + } + return def.then(function (records) { + _.each(resIDs, function (id) { + var dataPoint; + var data = _.findWhere(records, {id: id}); + if (id in list._cache) { + dataPoint = self.localData[list._cache[id]]; + if (data) { + self._parseServerData(fieldNames, dataPoint, data); + _.extend(dataPoint.data, data); + } + } else { + dataPoint = self._makeDataPoint({ + context: list.getContext(), + data: data, + fieldsInfo: list.fieldsInfo, + fields: list.fields, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + self._parseServerData(fieldNames, dataPoint, dataPoint.data); + + // add many2one records + list._cache[id] = dataPoint.id; + } + // set the dataPoint id in potential 'ADD' operation adding the current record + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && !change.id && change.resID === id) { + change.id = dataPoint.id; + } + }); + }); + return list; + }); + }, + /** + * For a grouped list resource, this method fetches all group data by + * performing a /read_group. It also tries to read open subgroups if they + * were open before. + * + * @param {Object} list valid resource object + * @param {Object} [options] @see _load + * @returns {Promise<Object>} resolves to the fetched group object + */ + _readGroup: function (list, options) { + var self = this; + options = options || {}; + var groupByField = list.groupedBy[0]; + var rawGroupBy = groupByField.split(':')[0]; + var fields = _.uniq(list.getFieldNames().concat(rawGroupBy)); + var orderedBy = _.filter(list.orderedBy, function (order) { + return order.name === rawGroupBy || list.fields[order.name].group_operator !== undefined; + }); + var openGroupsLimit = list.groupsLimit || self.OPEN_GROUP_LIMIT; + var expand = list.openGroupByDefault && options.fetchRecordsWithGroups; + return this._rpc({ + model: list.model, + method: 'web_read_group', + fields: fields, + domain: list.domain, + context: list.context, + groupBy: list.groupedBy, + limit: list.groupsLimit, + offset: list.groupsOffset, + orderBy: orderedBy, + lazy: true, + expand: expand, + expand_limit: expand ? list.limit : null, + expand_orderby: expand ? list.orderedBy : null, + }) + .then(function (result) { + var groups = result.groups; + list.groupsCount = result.length; + var previousGroups = _.map(list.data, function (groupID) { + return self.localData[groupID]; + }); + list.data = []; + list.count = 0; + var defs = []; + var openGroupCount = 0; + + _.each(groups, function (group) { + var aggregateValues = {}; + _.each(group, function (value, key) { + if (_.contains(fields, key) && key !== groupByField && + AGGREGATABLE_TYPES.includes(list.fields[key].type)) { + aggregateValues[key] = value; + } + }); + // When a view is grouped, we need to display the name of each group in + // the 'title'. + var value = group[groupByField]; + if (list.fields[rawGroupBy].type === "selection") { + var choice = _.find(list.fields[rawGroupBy].selection, function (c) { + return c[0] === value; + }); + value = choice ? choice[1] : false; + } + var newGroup = self._makeDataPoint({ + modelName: list.model, + count: group[rawGroupBy + '_count'], + domain: group.__domain, + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + value: value, + aggregateValues: aggregateValues, + groupedBy: list.groupedBy.slice(1), + orderedBy: list.orderedBy, + orderedResIDs: list.orderedResIDs, + limit: list.limit, + openGroupByDefault: list.openGroupByDefault, + parentID: list.id, + type: 'list', + viewType: list.viewType, + }); + var oldGroup = _.find(previousGroups, function (g) { + return g.res_id === newGroup.res_id && g.value === newGroup.value; + }); + if (oldGroup) { + delete self.localData[newGroup.id]; + // restore the internal state of the group + var updatedProps = _.pick(oldGroup, 'isOpen', 'offset', 'id'); + if (options.onlyGroups || oldGroup.isOpen && newGroup.groupedBy.length) { + // If the group is opened and contains subgroups, + // also keep its data to keep internal state of + // sub-groups + // Also keep data if we only reload groups' own data + updatedProps.data = oldGroup.data; + if (options.onlyGroups) { + // keep count and res_ids as in this case the group + // won't be search_read again. This situation happens + // when using kanban quick_create where the record is manually + // added to the datapoint before getting here. + updatedProps.res_ids = oldGroup.res_ids; + updatedProps.count = oldGroup.count; + } + } + _.extend(newGroup, updatedProps); + // set the limit such that all previously loaded records + // (e.g. if we are coming back to the kanban view from a + // form view) are reloaded + newGroup.limit = oldGroup.limit + oldGroup.loadMoreOffset; + self.localData[newGroup.id] = newGroup; + } else if (!newGroup.openGroupByDefault || openGroupCount >= openGroupsLimit) { + newGroup.isOpen = false; + } else if ('__fold' in group) { + newGroup.isOpen = !group.__fold; + } else { + // open the group iff it is a first level group + newGroup.isOpen = !self.localData[newGroup.parentID].parentID; + } + list.data.push(newGroup.id); + list.count += newGroup.count; + if (newGroup.isOpen && newGroup.count > 0) { + openGroupCount++; + if (group.__data) { + // bypass the search_read when the group's records have been obtained + // by the call to 'web_read_group' (see @_searchReadUngroupedList) + newGroup.__data = group.__data; + } + options = _.defaults({enableRelationalFetch: false}, options); + defs.push(self._load(newGroup, options)); + } + }); + if (options.keepEmptyGroups) { + // Find the groups that were available in a previous + // readGroup but are not there anymore. + // Note that these groups are put after existing groups so + // the order is not conserved. A sort *might* be useful. + var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data); + _.each(emptyGroupsIDs, function (groupID) { + list.data.push(groupID); + var emptyGroup = self.localData[groupID]; + // this attribute hasn't been updated in the previous + // loop for empty groups + emptyGroup.aggregateValues = {}; + }); + } + + return Promise.all(defs).then(function (groups) { + if (!options.onlyGroups) { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(groups, function (group) { + return group ? group.res_ids : []; + })); + } + return list; + }).then(function () { + return Promise.all([ + self._fetchX2ManysSingleBatch(list), + self._fetchReferencesSingleBatch(list) + ]).then(function () { + return list; + }); + }); + }); + }, + /** + * For 'static' list, such as one2manys in a form view, we can do a /read + * instead of a /search_read. + * + * @param {Object} list a valid resource object + * @returns {Promise<Object>} resolves to the fetched list object + */ + _readUngroupedList: function (list) { + var self = this; + var def = Promise.resolve(); + + // generate the current count and res_ids list by applying the changes + list = this._applyX2ManyOperations(list); + + // for multi-pages list datapoints, we might need to read the + // order field first to apply the order on all pages + if (list.res_ids.length > list.limit && list.orderedBy.length) { + if (!list.orderedResIDs) { + var fieldNames = _.pluck(list.orderedBy, 'name'); + def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames); + } + def.then(function () { + self._sortList(list); + }); + } + return def.then(function () { + var resIDs = []; + var currentResIDs = list.res_ids; + // if new records have been added to the list, their virtual ids have + // been pushed at the end of res_ids (or at the beginning, depending + // on the editable property), ignoring completely the current page + // where the records have actually been created ; for that reason, + // we use orderedResIDs which is a freezed order with the virtual ids + // at the correct position where they were actually inserted ; however, + // when we use orderedResIDs, we must filter out ids that are not in + // res_ids, which correspond to records that have been removed from + // the relation (this information being taken into account in res_ids + // but not in orderedResIDs) + if (list.orderedResIDs) { + currentResIDs = list.orderedResIDs.filter(function (resID) { + return list.res_ids.indexOf(resID) >= 0; + }); + } + var currentCount = currentResIDs.length; + var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount; + var fieldNames = list.getFieldNames(); + for (var i = list.offset; i < upperBound; i++) { + var resId = currentResIDs[i]; + if (_.isNumber(resId)) { + resIDs.push(resId); + } + } + return self._readMissingFields(list, resIDs, fieldNames).then(function () { + if (list.res_ids.length <= list.limit) { + self._sortList(list); + } else { + // sortList has already been applied after first the read + self._setDataInRange(list); + } + return list; + }); + }); + }, + /** + * Reload all data for a given resource + * + * @private + * @param {string} id local id for a resource + * @param {Object} [options] + * @param {boolean} [options.keepChanges=false] if true, doesn't discard the + * changes on the record before reloading it + * @returns {Promise<string>} resolves to the id of the resource + */ + _reload: function (id, options) { + options = options || {}; + var element = this.localData[id]; + + if (element.type === 'record') { + if (!options.currentId && (('currentId' in options) || this.isNew(id))) { + var params = { + context: element.context, + fieldsInfo: element.fieldsInfo, + fields: element.fields, + viewType: element.viewType, + allowWarning: true, + }; + return this._makeDefaultRecord(element.model, params); + } + if (!options.keepChanges) { + this.discardChanges(id, {rollback: false}); + } + } else if (element._changes) { + delete element.tempLimitIncrement; + _.each(element._changes, function (change) { + delete change.isNew; + }); + } + + if (options.context !== undefined) { + element.context = options.context; + } + if (options.orderedBy !== undefined) { + element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy; + } + if (options.domain !== undefined) { + element.domain = options.domain; + } + if (options.groupBy !== undefined) { + element.groupedBy = options.groupBy; + } + if (options.limit !== undefined) { + element.limit = options.limit; + } + if (options.offset !== undefined) { + this._setOffset(element.id, options.offset); + } + if (options.groupsLimit !== undefined) { + element.groupsLimit = options.groupsLimit; + } + if (options.groupsOffset !== undefined) { + element.groupsOffset = options.groupsOffset; + } + if (options.loadMoreOffset !== undefined) { + element.loadMoreOffset = options.loadMoreOffset; + } else { + // reset if not specified + element.loadMoreOffset = 0; + } + if (options.currentId !== undefined) { + element.res_id = options.currentId; + } + if (options.ids !== undefined) { + element.res_ids = options.ids; + element.count = element.res_ids.length; + } + if (element.type === 'record') { + element.offset = _.indexOf(element.res_ids, element.res_id); + } + var loadOptions = _.pick(options, 'fieldNames', 'viewType'); + return this._load(element, loadOptions).then(function (result) { + return result.id; + }); + }, + /** + * Override to handle the case where we want sample data, and we are in a + * grouped kanban or list view with real groups, but all groups are empty. + * In this case, we use the result of the web_read_group rpc to tweak the + * data in the SampleServer instance of the sampleModel (so that calls to + * that server will return the same groups). + * + * @override + */ + async _rpc(params) { + const result = await this._super(...arguments); + if (this.sampleModel && params.method === 'web_read_group' && result.length) { + const sampleServer = this.sampleModel.sampleServer; + sampleServer.setExistingGroups(result.groups); + } + return result; + }, + /** + * Allows to save a value in the specialData cache associated to a given + * record and fieldName. If the value in the cache was already the given + * one, nothing is done and the method indicates it by returning false + * instead of true. + * + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName - the name of the field + * @param {*} value - the cache value to save + * @returns {boolean} false if the value was already the given one + */ + _saveSpecialDataCache: function (record, fieldName, value) { + if (_.isEqual(record._specialDataCache[fieldName], value)) { + return false; + } + record._specialDataCache[fieldName] = value; + return true; + }, + /** + * Do a /search_read to get data for a list resource. This does a + * /search_read because the data may not be static (for ex, a list view). + * + * @param {Object} list + * @returns {Promise} + */ + _searchReadUngroupedList: function (list) { + var self = this; + var fieldNames = list.getFieldNames(); + var prom; + if (list.__data) { + // the data have already been fetched (alonside the groups by the + // call to 'web_read_group'), so we can bypass the search_read + prom = Promise.resolve(list.__data); + } else { + prom = this._rpc({ + route: '/web/dataset/search_read', + model: list.model, + fields: fieldNames, + context: _.extend({}, list.getContext(), {bin_size: true}), + domain: list.domain || [], + limit: list.limit, + offset: list.loadMoreOffset + list.offset, + orderBy: list.orderedBy, + }); + } + return prom.then(function (result) { + delete list.__data; + list.count = result.length; + var ids = _.pluck(result.records, 'id'); + var data = _.map(result.records, function (record) { + var dataPoint = self._makeDataPoint({ + context: list.context, + data: record, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + + // add many2one records + self._parseServerData(fieldNames, dataPoint, dataPoint.data); + return dataPoint.id; + }); + if (list.loadMoreOffset) { + list.data = list.data.concat(data); + list.res_ids = list.res_ids.concat(ids); + } else { + list.data = data; + list.res_ids = ids; + } + self._updateParentResIDs(list); + return list; + }); + }, + /** + * Set data in range, i.e. according to the list offset and limit. + * + * @param {Object} list + */ + _setDataInRange: function (list) { + var idsInRange; + if (list.limit) { + idsInRange = list.res_ids.slice(list.offset, list.offset + list.limit); + } else { + idsInRange = list.res_ids; + } + list.data = []; + _.each(idsInRange, function (id) { + if (list._cache[id]) { + list.data.push(list._cache[id]); + } + }); + + // display newly created record in addition to the displayed records + if (list.limit) { + for (var i = list.offset + list.limit; i < list.res_ids.length; i++) { + var id = list.res_ids[i]; + var dataPointID = list._cache[id]; + if (_.findWhere(list._changes, {isNew: true, id: dataPointID})) { + list.data.push(dataPointID); + } else { + break; + } + } + } + }, + /** + * Change the offset of a record. Note that this does not reload the data. + * The offset is used to load a different record in a list of record (for + * example, a form view with a pager. Clicking on next/previous actually + * changes the offset through this method). + * + * @param {string} elementId local id for the resource + * @param {number} offset + */ + _setOffset: function (elementId, offset) { + var element = this.localData[elementId]; + element.offset = offset; + if (element.type === 'record' && element.res_ids.length) { + element.res_id = element.res_ids[offset]; + } + }, + /** + * Do a in-memory sort of a list resource data points. This method assumes + * that the list data has already been fetched, and that the changes that + * need to be sorted have already been applied. Its intended use is for + * static datasets, such as a one2many in a form view. + * + * @param {Object} list list dataPoint on which (some) changes might have + * been applied; it is a copy of an internal dataPoint, not the result of + * get + */ + _sortList: function (list) { + if (!list.static) { + // only sort x2many lists + return; + } + var self = this; + + if (list.orderedResIDs) { + var orderedResIDs = {}; + for (var k = 0; k < list.orderedResIDs.length; k++) { + orderedResIDs[list.orderedResIDs[k]] = k; + } + utils.stableSort(list.res_ids, function compareResIdIndexes (resId1, resId2) { + if (!(resId1 in orderedResIDs) && !(resId2 in orderedResIDs)) { + return 0; + } + if (!(resId1 in orderedResIDs)) { + return Infinity; + } + if (!(resId2 in orderedResIDs)) { + return -Infinity; + } + return orderedResIDs[resId1] - orderedResIDs[resId2]; + }); + } else if (list.orderedBy.length) { + // sort records according to ordered_by[0] + var compareRecords = function (resId1, resId2, level) { + if(!level) { + level = 0; + } + if(list.orderedBy.length < level + 1) { + return 0; + } + var order = list.orderedBy[level]; + var record1ID = list._cache[resId1]; + var record2ID = list._cache[resId2]; + if (!record1ID && !record2ID) { + return 0; + } + if (!record1ID) { + return Infinity; + } + if (!record2ID) { + return -Infinity; + } + var r1 = self.localData[record1ID]; + var r2 = self.localData[record2ID]; + var data1 = _.extend({}, r1.data, r1._changes); + var data2 = _.extend({}, r2.data, r2._changes); + + // Default value to sort against: the value of the field + var orderData1 = data1[order.name]; + var orderData2 = data2[order.name]; + + // If the field is a relation, sort on the display_name of those records + if (list.fields[order.name].type === 'many2one') { + orderData1 = orderData1 ? self.localData[orderData1].data.display_name : ""; + orderData2 = orderData2 ? self.localData[orderData2].data.display_name : ""; + } + if (orderData1 < orderData2) { + return order.asc ? -1 : 1; + } + if (orderData1 > orderData2) { + return order.asc ? 1 : -1; + } + return compareRecords(resId1, resId2, level + 1); + }; + utils.stableSort(list.res_ids, compareRecords); + } + this._setDataInRange(list); + }, + /** + * Updates the res_ids of the parent of a given element of type list. + * + * After some operations (e.g. loading more records, folding/unfolding a + * group), the res_ids list of an element may be updated. When this happens, + * the res_ids of its ancestors need to be updated as well. This is the + * purpose of this function. + * + * @param {Object} element + */ + _updateParentResIDs: function (element) { + var self = this; + if (element.parentID) { + var parent = this.localData[element.parentID]; + parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) { + return self.localData[dataPointID].res_ids; + })); + this._updateParentResIDs(parent); + } + }, + /** + * Helper method to create datapoints and assign them values, then link + * those datapoints into records' data. + * + * @param {Object[]} records a list of record where datapoints will be + * assigned, it assumes _applyX2ManyOperations and _sort have been + * already called on this list + * @param {string} fieldName concerned field in records + * @param {Object[]} values typically a list of values got from a rpc + */ + _updateRecordsData: function (records, fieldName, values) { + if (!records.length || !values) { + return; + } + var self = this; + var field = records[0].fields[fieldName]; + var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + _.each(records, function (record) { + var x2mList = self.localData[record.data[fieldName]]; + x2mList.data = []; + _.each(x2mList.res_ids, function (res_id) { + var dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: _.findWhere(values, {id: res_id}), + fields: fields, + fieldsInfo: fieldsInfo, + parentID: x2mList.id, + viewType: viewType, + }); + x2mList.data.push(dataPoint.id); + x2mList._cache[res_id] = dataPoint.id; + }); + }); + }, + /** + * Helper method. Recursively traverses the data, starting from the element + * record (or list), then following all relations. This is useful when one + * want to determine a property for the current record. + * + * For example, isDirty need to check all relations to find out if something + * has been modified, or not. + * + * Note that this method follows all the changes, so if a record has + * relational sub data, it will visit the new sub records and not the old + * ones. + * + * @param {Object} element a valid local resource + * @param {callback} fn a function to be called on each visited element + */ + _visitChildren: function (element, fn) { + var self = this; + fn(element); + if (element.type === 'record') { + for (var fieldName in element.data) { + var field = element.fields[fieldName]; + if (!field) { + continue; + } + if (_.contains(['one2many', 'many2one', 'many2many'], field.type)) { + var hasChange = element._changes && fieldName in element._changes; + var value = hasChange ? element._changes[fieldName] : element.data[fieldName]; + var relationalElement = this.localData[value]; + // relationalElement could be empty in the case of a many2one + if (relationalElement) { + self._visitChildren(relationalElement, fn); + } + } + } + } + if (element.type === 'list') { + element = this._applyX2ManyOperations(element); + _.each(element.data, function (elemId) { + var elem = self.localData[elemId]; + self._visitChildren(elem, fn); + }); + } + }, +}); + +return BasicModel; +}); diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js new file mode 100644 index 00000000..a238ab2e --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -0,0 +1,926 @@ +odoo.define('web.BasicRenderer', function (require) { +"use strict"; + +/** + * The BasicRenderer is an abstract class designed to share code between all + * views that uses a BasicModel. The main goal is to keep track of all field + * widgets, and properly destroy them whenever a rerender is done. The widgets + * and modifiers updates mechanism is also shared in the BasicRenderer. + */ +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +const session = require('web.session'); +const utils = require('web.utils'); +var widgetRegistry = require('web.widget_registry'); + +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); +const FieldWrapper = require('web.FieldWrapper'); + +var qweb = core.qweb; +const _t = core._t; + +var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { + custom_events: { + navigation_move: '_onNavigationMove', + }, + /** + * Basic renderers implements the concept of "mode", they can either be in + * readonly mode or editable mode. + * + * @override + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.activeActions = params.activeActions; + this.viewType = params.viewType; + this.mode = params.mode || 'readonly'; + this.widgets = []; + // This attribute lets us know if there is a handle widget on a field, + // and on which field it is set. + this.handleField = null; + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () { + this._isInDom = true; + // call on_attach_callback on field widgets + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } + // call on_attach_callback on widgets + this.widgets.forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + // call on_attach_callback on child components (including field components) + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () { + this._isInDom = false; + // call on_detach_callback on field widgets + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + } + // call on_detach_callback on widgets + this.widgets.forEach(widget => { + if (widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + // call on_detach_callback on child components (including field components) + WidgetAdapterMixin.on_detach_callback.call(this); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method has two responsabilities: find every invalid fields in the + * current view, and making sure that they are displayed as invalid, by + * toggling the o_form_invalid css class. It has to be done both on the + * widget, and on the label, if any. + * + * @param {string} recordID + * @returns {string[]} the list of invalid field names + */ + canBeSaved: function (recordID) { + var self = this; + var invalidFields = []; + _.each(this.allFieldWidgets[recordID], function (widget) { + var canBeSaved = self._canWidgetBeSaved(widget); + if (!canBeSaved) { + invalidFields.push(widget.name); + } + if (widget.el) { // widget may not be started yet + widget.$el.toggleClass('o_field_invalid', !canBeSaved); + widget.$el.attr('aria-invalid', !canBeSaved); + } + }); + return invalidFields; + }, + /** + * Calls 'commitChanges' on all field widgets, so that they can notify the + * environment with their current value (useful for widgets that can't + * detect when their value changes or that have to validate their changes + * before notifying them). + * + * @param {string} recordID + * @return {Promise} + */ + commitChanges: function (recordID) { + var defs = _.map(this.allFieldWidgets[recordID], function (widget) { + return widget.commitChanges(); + }); + return Promise.all(defs); + }, + /** + * Updates the internal state of the renderer to the new state. By default, + * this also implements the recomputation of the modifiers and their + * application to the DOM and the reset of the field widgets if needed. + * + * In case the given record is not found anymore, a whole re-rendering is + * completed (possible if a change in a record caused an onchange which + * erased the current record). + * + * We could always rerender the view from scratch, but then it would not be + * as efficient, and we might lose some local state, such as the input focus + * cursor, or the scrolling position. + * + * @param {Object} state + * @param {string} id + * @param {string[]} fields + * @param {OdooEvent} ev + * @returns {Promise<AbstractField[]>} resolved with the list of widgets + * that have been reset + */ + confirmChange: function (state, id, fields, ev) { + var self = this; + this._setState(state); + var record = this._getRecord(id); + if (!record) { + return this._render().then(_.constant([])); + } + + // reset all widgets (from the <widget> tag) if any: + _.invoke(this.widgets, 'updateState', state); + + var defs = []; + + // Reset all the field widgets that are marked as changed and the ones + // which are configured to always be reset on any change + _.each(this.allFieldWidgets[id], function (widget) { + var fieldChanged = _.contains(fields, widget.name); + if (fieldChanged || widget.resetOnAnyFieldChange) { + defs.push(widget.reset(record, ev, fieldChanged)); + } + }); + + // The modifiers update is done after widget resets as modifiers + // associated callbacks need to have all the widgets with the proper + // state before evaluation + defs.push(this._updateAllModifiers(record)); + + return Promise.all(defs).then(function () { + return _.filter(self.allFieldWidgets[id], function (widget) { + var fieldChanged = _.contains(fields, widget.name); + return fieldChanged || widget.resetOnAnyFieldChange; + }); + }); + }, + /** + * Activates the widget and move the cursor to the given offset + * + * @param {string} id + * @param {string} fieldName + * @param {integer} offset + */ + focusField: function (id, fieldName, offset) { + this.editRecord(id); + if (typeof offset === "number") { + var field = _.findWhere(this.allFieldWidgets[id], {name: fieldName}); + dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset}); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activates the widget at the given index for the given record if possible + * or the "next" possible one. Usually, a widget can be activated if it is + * in edit mode, and if it is visible. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {Object} [options={}] + * @param {integer} [options.inc=1] - the increment to use when searching for the + * "next" possible one + * @param {boolean} [options.noAutomaticCreate=false] + * @param {boolean} [options.wrap=false] if true, when we arrive at the end of the + * list of widget, we wrap around and try to activate widgets starting at + * the beginning. Otherwise, we just stop trying and return -1 + * @returns {integer} the index of the widget that was activated or -1 if + * none was possible to activate + */ + _activateFieldWidget: function (record, currentIndex, options) { + options = options || {}; + _.defaults(options, {inc: 1, wrap: false}); + currentIndex = Math.max(0,currentIndex); // do not allow negative currentIndex + + var recordWidgets = this.allFieldWidgets[record.id] || []; + for (var i = 0 ; i < recordWidgets.length ; i++) { + var activated = recordWidgets[currentIndex].activate( + { + event: options.event, + noAutomaticCreate: options.noAutomaticCreate || false + }); + if (activated) { + return currentIndex; + } + + currentIndex += options.inc; + if (currentIndex >= recordWidgets.length) { + if (options.wrap) { + currentIndex -= recordWidgets.length; + } else { + return -1; + } + } else if (currentIndex < 0) { + if (options.wrap) { + currentIndex += recordWidgets.length; + } else { + return -1; + } + } + } + return -1; + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the next possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {Object|undefined} options + * @return {integer} + */ + _activateNextFieldWidget: function (record, currentIndex, options) { + currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length; + return this._activateFieldWidget(record, currentIndex, _.extend({inc: 1}, options)); + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the previous possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @return {integer} + */ + _activatePreviousFieldWidget: function (record, currentIndex) { + currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1); + return this._activateFieldWidget(record, currentIndex, {inc:-1}); + }, + /** + * Add a tooltip on a $node, depending on a field description + * + * @param {FieldWidget} widget + * @param {$node} $node + */ + _addFieldTooltip: function (widget, $node) { + // optional argument $node, the jQuery element on which the tooltip + // should be attached if not given, the tooltip is attached on the + // widget's $el + $node = $node.length ? $node : widget.$el; + $node.tooltip(this._getTooltipOptions(widget)); + }, + /** + * Does the necessary DOM updates to match the given modifiers data. The + * modifiers data is supposed to contain the properly evaluated modifiers + * associated to the given records and elements. + * + * @param {Object} modifiersData + * @param {Object} record + * @param {Object} [element] - do the update only on this element if given + */ + _applyModifiers: function (modifiersData, record, element) { + var self = this; + var modifiers = modifiersData.evaluatedModifiers[record.id] || {}; + + if (element) { + _apply(element); + } else { + // Clone is necessary as the list might change during _.each + _.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply); + } + + function _apply(element) { + // If the view is in edit mode and that a widget have to switch + // its "readonly" state, we have to re-render it completely + if ('readonly' in modifiers && element.widget) { + var mode = modifiers.readonly ? 'readonly' : modifiersData.baseModeByRecord[record.id]; + if (mode !== element.widget.mode) { + self._rerenderFieldWidget(element.widget, record, { + keepBaseMode: true, + mode: mode, + }); + return; // Rerendering already applied the modifiers, no need to go further + } + } + + // Toggle modifiers CSS classes if necessary + element.$el.toggleClass("o_invisible_modifier", !!modifiers.invisible); + element.$el.toggleClass("o_readonly_modifier", !!modifiers.readonly); + element.$el.toggleClass("o_required_modifier", !!modifiers.required); + + if (element.widget && element.widget.updateModifiersValue) { + element.widget.updateModifiersValue(modifiers); + } + + // Call associated callback + if (element.callback) { + element.callback(element, modifiers, record); + } + } + }, + /** + * Determines if a given field widget value can be saved. For this to be + * true, the widget must be valid (properly parsed value) and have a value + * if the associated view field is required. + * + * @private + * @param {AbstractField} widget + * @returns {boolean|Promise<boolean>} @see AbstractField.isValid + */ + _canWidgetBeSaved: function (widget) { + var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record); + return widget.isValid() && (widget.isSet() || !modifiers.required); + }, + /** + * Destroys a given widget associated to the given record and removes it + * from internal referencing. + * + * @private + * @param {string} recordID id of the local resource + * @param {AbstractField} widget + * @returns {integer} the index of the removed widget + */ + _destroyFieldWidget: function (recordID, widget) { + var recordWidgets = this.allFieldWidgets[recordID]; + var index = recordWidgets.indexOf(widget); + if (index >= 0) { + recordWidgets.splice(index, 1); + } + this._unregisterModifiersElement(widget.__node, recordID, widget); + widget.destroy(); + return index; + }, + /** + * Searches for the last evaluation of the modifiers associated to the given + * data (modifiers evaluation are supposed to always be up-to-date as soon + * as possible). + * + * @private + * @param {Object} node + * @param {Object} record + * @returns {Object} the evaluated modifiers associated to the given node + * and record (not recomputed by the call) + */ + _getEvaluatedModifiers: function (node, record) { + var element = this._getModifiersData(node); + if (!element) { + return {}; + } + return element.evaluatedModifiers[record.id] || {}; + }, + /** + * Searches through the registered modifiers data for the one which is + * related to the given node. + * + * @private + * @param {Object} node + * @returns {Object|undefined} related modifiers data if any + * undefined otherwise + */ + _getModifiersData: function (node) { + return _.findWhere(this.allModifiersData, {node: node}); + }, + /** + * This function is meant to be overridden in renderers. It takes a dataPoint + * id (for a dataPoint of type record), and should return the corresponding + * dataPoint. + * + * @abstract + * @private + * @param {string} [recordId] + * @returns {Object|null} + */ + _getRecord: function (recordId) { + return null; + }, + /** + * Get the options for the tooltip. This allow to change this options in another module. + * @param widget + * @return {{}} + * @private + */ + _getTooltipOptions: function (widget) { + return { + title: function () { + let help = widget.attrs.help || widget.field.help || ''; + if (session.display_switch_company_menu && widget.field.company_dependent) { + help += (help ? '\n\n' : '') + _t('Values set here are company-specific.'); + } + const debug = config.isDebug(); + if (help || debug) { + return qweb.render('WidgetLabel.tooltip', { debug, help, widget }); + } + } + }; + }, + /** + * @private + * @param {jQueryElement} $el + * @param {Object} node + */ + _handleAttributes: function ($el, node) { + if ($el.is('button')) { + return; + } + if (node.attrs.class) { + $el.addClass(node.attrs.class); + } + if (node.attrs.style) { + $el.attr('style', node.attrs.style); + } + if (node.attrs.placeholder) { + $el.attr('placeholder', node.attrs.placeholder); + } + }, + /** + * Used by list and kanban renderers to determine whether or not to display + * the no content helper (if there is no data in the state to display) + * + * @private + * @returns {boolean} + */ + _hasContent: function () { + return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample); + }, + /** + * Force the resequencing of the records after moving one of them to a given + * index. + * + * @private + * @param {string} recordId datapoint id of the moved record + * @param {integer} toIndex new index of the moved record + */ + _moveRecord: function (recordId, toIndex) { + var self = this; + var records = this.state.data; + var record = _.findWhere(records, {id: recordId}); + var fromIndex = records.indexOf(record); + var lowerIndex = Math.min(fromIndex, toIndex); + var upperIndex = Math.max(fromIndex, toIndex) + 1; + var order = _.findWhere(this.state.orderedBy, {name: this.handleField}); + var asc = !order || order.asc; + var reorderAll = false; + var sequence = (asc ? -1 : 1) * Infinity; + + // determine if we need to reorder all records + _.each(records, function (record, index) { + if (((index < lowerIndex || index >= upperIndex) && + ((asc && sequence >= record.data[self.handleField]) || + (!asc && sequence <= record.data[self.handleField]))) || + (index >= lowerIndex && index < upperIndex && sequence === record.data[self.handleField])) { + reorderAll = true; + } + sequence = record.data[self.handleField]; + }); + + if (reorderAll) { + records = _.without(records, record); + records.splice(toIndex, 0, record); + } else { + records = records.slice(lowerIndex, upperIndex); + records = _.without(records, record); + if (fromIndex > toIndex) { + records.unshift(record); + } else { + records.push(record); + } + } + + var sequences = _.pluck(_.pluck(records, 'data'), this.handleField); + var recordIds = _.pluck(records, 'id'); + if (!asc) { + recordIds.reverse(); + } + + this.trigger_up('resequence_records', { + handleField: this.handleField, + offset: _.min(sequences), + recordIds: recordIds, + }); + }, + /** + * This function is called each time a field widget is created, when it is + * ready (after its willStart and Start methods are complete). This is the + * place where work having to do with $el should be done. + * + * @private + * @param {Widget} widget the field widget instance + * @param {Object} node the attrs coming from the arch + */ + _postProcessField: function (widget, node) { + this._handleAttributes(widget.$el, node); + }, + /** + * Registers or updates the modifiers data associated to the given node. + * This method is quiet complex as it handles all the needs of the basic + * renderers: + * + * - On first registration, the modifiers are evaluated thanks to the given + * record. This allows nodes that will produce an AbstractField instance + * to have their modifiers registered before this field creation as we + * need the readonly modifier to be able to instantiate the AbstractField. + * + * - On additional registrations, if the node was already registered but the + * record is different, we evaluate the modifiers for this record and + * saves them in the same object (without reparsing the modifiers). + * + * - On additional registrations, the modifiers are not reparsed (or + * reevaluated for an already seen record) but the given widget or DOM + * element is associated to the node modifiers. + * + * - The new elements are immediately adapted to match the modifiers and the + * given associated callback is called even if there is no modifiers on + * the node (@see _applyModifiers). This is indeed necessary as the + * callback is a description of what to do when a modifier changes. Even + * if there is no modifiers, this action must be performed on first + * rendering to avoid code duplication. If there is no modifiers, they + * will however not be registered for modifiers updates. + * + * - When a new element is given, it does not replace the old one, it is + * added as an additional element. This is indeed useful for nodes that + * will produce multiple DOM (as a list cell and its internal widget or + * a form field and its associated label). + * (@see _unregisterModifiersElement for removing an associated element.) + * + * Note: also on view rerendering, all the modifiers are forgotten so that + * the renderer only keeps the ones associated to the current DOM state. + * + * @private + * @param {Object} node + * @param {Object} record + * @param {jQuery|AbstractField} [element] + * @param {Object} [options] + * @param {Object} [options.callback] the callback to call on registration + * and on modifiers updates + * @param {boolean} [options.keepBaseMode=false] this function registers the + * 'baseMode' of the node on a per record basis; + * this is a field widget specific settings which + * represents the generic mode of the widget, regardless of its modifiers + * (the interesting case is the list view: all widgets are supposed to be + * in the baseMode 'readonly', except the ones that are in the line that + * is currently being edited). + * With option 'keepBaseMode' set to true, the baseMode of the record's + * node isn't overridden (this is particularily useful when a field widget + * is re-rendered because its readonly modifier changed, as in this case, + * we don't want to change its base mode). + * @param {string} [options.mode] the 'baseMode' of the record's node is set to this + * value (if not given, it is set to this.mode, the mode of the renderer) + * @returns {Object} for code efficiency, returns the last evaluated + * modifiers for the given node and record. + * @throws {Error} if one of the modifier domains is not valid + */ + _registerModifiers: function (node, record, element, options) { + options = options || {}; + // Check if we already registered the modifiers for the given node + // If yes, this is simply an update of the related element + // If not, check the modifiers to see if it needs registration + var modifiersData = this._getModifiersData(node); + if (!modifiersData) { + var modifiers = node.attrs.modifiers || {}; + modifiersData = { + node: node, + modifiers: modifiers, + evaluatedModifiers: {}, + elementsByRecord: {}, + baseModeByRecord : {}, + }; + if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here) + this.allModifiersData.push(modifiersData); + } + } + + // Compute the record's base mode + if (!modifiersData.baseModeByRecord[record.id] || !options.keepBaseMode) { + modifiersData.baseModeByRecord[record.id] = options.mode || this.mode; + } + + // Evaluate if necessary + if (!modifiersData.evaluatedModifiers[record.id]) { + try { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + } catch (e) { + throw new Error(_.str.sprintf( + "While parsing modifiers for %s%s: %s", + node.tag, node.tag === 'field' ? ' ' + node.attrs.name : '', + e.message + )); + } + } + + // Element might not be given yet (a second call to the function can + // update the registration with the element) + if (element) { + var newElement = {}; + if (element instanceof jQuery) { + newElement.$el = element; + } else { + newElement.widget = element; + newElement.$el = element.$el; + } + if (options && options.callback) { + newElement.callback = options.callback; + } + + if (!modifiersData.elementsByRecord[record.id]) { + modifiersData.elementsByRecord[record.id] = []; + } + modifiersData.elementsByRecord[record.id].push(newElement); + + this._applyModifiers(modifiersData, record, newElement); + } + + return modifiersData.evaluatedModifiers[record.id]; + }, + /** + * @override + */ + async _render() { + const oldAllFieldWidgets = this.allFieldWidgets; + this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great + this.allModifiersData = []; + const oldWidgets = this.widgets; + this.widgets = []; + + await this._super(...arguments); + + for (const id in oldAllFieldWidgets) { + for (const widget of oldAllFieldWidgets[id]) { + widget.destroy(); + } + } + for (const widget of oldWidgets) { + widget.destroy(); + } + if (this._isInDom) { + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } + this.widgets.forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + // call on_attach_callback on child components (including field components) + WidgetAdapterMixin.on_attach_callback.call(this); + } + }, + /** + * Instantiates the appropriate AbstractField specialization for the given + * node and prepares its rendering and addition to the DOM. Indeed, the + * rendering of the widget will be started and the associated promise will + * be added to the 'defs' attribute. This is supposed to be created and + * deleted by the calling code if necessary. + * + * Note: we always return a $el. If the field widget is asynchronous, this + * $el will be replaced by the real $el, whenever the widget is ready (start + * method is done). This means that this is not the correct place to make + * changes on the widget $el. For this, @see _postProcessField method + * + * @private + * @param {Object} node + * @param {Object} record + * @param {Object} [options] passed to @_registerModifiers + * @param {string} [options.mode] either 'edit' or 'readonly' (defaults to + * this.mode, the mode of the renderer) + * @returns {jQueryElement} + */ + _renderFieldWidget: function (node, record, options) { + options = options || {}; + var fieldName = node.attrs.name; + // Register the node-associated modifiers + var mode = options.mode || this.mode; + var modifiers = this._registerModifiers(node, record, null, options); + // Initialize and register the widget + // Readonly status is known as the modifiers have just been registered + var Widget = record.fieldsInfo[this.viewType][fieldName].Widget; + const legacy = !(Widget.prototype instanceof owl.Component); + const widgetOptions = { + mode: modifiers.readonly ? 'readonly' : mode, + viewType: this.viewType, + }; + let widget; + if (legacy) { + widget = new Widget(this, fieldName, record, widgetOptions); + } else { + widget = new FieldWrapper(this, Widget, { + fieldName, + record, + options: widgetOptions, + }); + } + + // Register the widget so that it can easily be found again + if (this.allFieldWidgets[record.id] === undefined) { + this.allFieldWidgets[record.id] = []; + } + this.allFieldWidgets[record.id].push(widget); + + widget.__node = node; // TODO get rid of this if possible one day + + // Prepare widget rendering and save the related promise + var $el = $('<div>'); + let def; + if (legacy) { + def = widget._widgetRenderAndInsert(function () {}); + } else { + def = widget.mount(document.createDocumentFragment()); + } + + this.defs.push(def); + + // Update the modifiers registration by associating the widget and by + // giving the modifiers options now (as the potential callback is + // associated to new widget) + var self = this; + def.then(function () { + // when the caller of renderFieldWidget uses something like + // this.renderFieldWidget(...).addClass(...), the class is added on + // the temporary div and not on the actual element that will be + // rendered. As we do not return a promise and some callers cannot + // wait for this.defs, we copy those classnames to the final element. + widget.$el.addClass($el.attr('class')); + + $el.replaceWith(widget.$el); + self._registerModifiers(node, record, widget, { + callback: function (element, modifiers, record) { + element.$el.toggleClass('o_field_empty', !!( + record.data.id && + (modifiers.readonly || mode === 'readonly') && + element.widget.isEmpty() + )); + }, + keepBaseMode: !!options.keepBaseMode, + mode: mode, + }); + self._postProcessField(widget, node); + }); + + return $el; + }, + /** + * Instantiate custom widgets + * + * @private + * @param {Object} record + * @param {Object} node + * @returns {jQueryElement} + */ + _renderWidget: function (record, node) { + var Widget = widgetRegistry.get(node.attrs.name); + var widget = new Widget(this, record, node); + + this.widgets.push(widget); + + // Prepare widget rendering and save the related promise + var def = widget._widgetRenderAndInsert(function () {}); + this.defs.push(def); + var $el = $('<div>'); + + var self = this; + def.then(function () { + self._handleAttributes(widget.$el, node); + self._registerModifiers(node, record, widget); + widget.$el.addClass('o_widget'); + $el.replaceWith(widget.$el); + }); + + return $el; + }, + /** + * Rerenders a given widget and make sure the associated data which + * referenced the old one is updated. + * + * @private + * @param {Widget} widget + * @param {Object} record + * @param {Object} [options] options passed to @_renderFieldWidget + */ + _rerenderFieldWidget: function (widget, record, options) { + // Render the new field widget + var $el = this._renderFieldWidget(widget.__node, record, options); + // get the new widget that has just been pushed in allFieldWidgets + const recordWidgets = this.allFieldWidgets[record.id]; + const newWidget = recordWidgets[recordWidgets.length - 1]; + const def = this.defs[this.defs.length - 1]; // this is the widget's def, resolved when it is ready + const $div = $('<div>'); + $div.append($el); // $el will be replaced when widget is ready (see _renderFieldWidget) + def.then(() => { + widget.$el.replaceWith($div.children()); + + // Destroy the old widget and position the new one at the old one's + // (it has been temporarily inserted at the end of the list) + recordWidgets.splice(recordWidgets.indexOf(newWidget), 1); + var oldIndex = this._destroyFieldWidget(record.id, widget); + recordWidgets.splice(oldIndex, 0, newWidget); + + // Mount new widget if necessary (mainly for Owl components) + if (this._isInDom && newWidget.on_attach_callback) { + newWidget.on_attach_callback(); + } + }); + }, + /** + * Unregisters an element of the modifiers data associated to the given + * node and record. + * + * @param {Object} node + * @param {string} recordID id of the local resource + * @param {jQuery|AbstractField} element + */ + _unregisterModifiersElement: function (node, recordID, element) { + var modifiersData = this._getModifiersData(node); + if (modifiersData) { + var elements = modifiersData.elementsByRecord[recordID]; + var index = _.findIndex(elements, function (oldElement) { + return oldElement.widget === element + || oldElement.$el[0] === element[0]; + }); + if (index >= 0) { + elements.splice(index, 1); + } + } + }, + /** + * Does two actions, for each registered modifiers: + * 1) Recomputes the modifiers associated to the given record and saves them + * (as boolean values) in the appropriate modifiers data. + * 2) Updates the rendering of the view elements associated to the given + * record to match the new modifiers. + * + * @see _applyModifiers + * + * @private + * @param {Object} record + * @returns {Promise} resolved once finished + */ + _updateAllModifiers: function (record) { + var self = this; + + var defs = []; + this.defs = defs; // Potentially filled by widget rerendering + _.each(this.allModifiersData, function (modifiersData) { + // `allModifiersData` might contain modifiers registered for other + // records than the given record (e.g. <groupby> in list) + if (record.id in modifiersData.evaluatedModifiers) { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + self._applyModifiers(modifiersData, record); + } + }); + delete this.defs; + + return Promise.all(defs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to + * be able to navigate in the view (default browser behaviors are disabled + * by Odoo). + * + * @abstract + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) {}, +}); + +return BasicRenderer; +}); diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js new file mode 100644 index 00000000..5e3938fb --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -0,0 +1,454 @@ +odoo.define('web.BasicView', function (require) { +"use strict"; + +/** + * The BasicView is an abstract class designed to share code between views that + * want to use a basicModel. As of now, it is the form view, the list view and + * the kanban view. + * + * The main focus of this class is to process the arch and extract field + * attributes, as well as some other useful informations. + */ + +var AbstractView = require('web.AbstractView'); +var BasicController = require('web.BasicController'); +var BasicModel = require('web.BasicModel'); +var config = require('web.config'); +var fieldRegistry = require('web.field_registry'); +var fieldRegistryOwl = require('web.field_registry_owl'); +var pyUtils = require('web.py_utils'); +var utils = require('web.utils'); + +var BasicView = AbstractView.extend({ + config: _.extend({}, AbstractView.prototype.config, { + Model: BasicModel, + Controller: BasicController, + }), + viewType: undefined, + /** + * process the fields_view to find all fields appearing in the views. + * list those fields' name in this.fields_name, which will be the list + * of fields read when data is fetched. + * this.fields is the list of all field's description (the result of + * the fields_get), where the fields appearing in the fields_view are + * augmented with their attrs and some flags if they require a + * particular handling. + * + * @param {Object} viewInfo + * @param {Object} params + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + this.fieldsInfo = {}; + this.fieldsInfo[this.viewType] = this.fieldsView.fieldsInfo[this.viewType]; + + this.rendererParams.viewType = this.viewType; + + this.controllerParams.confirmOnDelete = true; + this.controllerParams.archiveEnabled = 'active' in this.fields || 'x_active' in this.fields; + this.controllerParams.hasButtons = + 'action_buttons' in params ? params.action_buttons : true; + this.controllerParams.viewId = viewInfo.view_id; + + this.loadParams.fieldsInfo = this.fieldsInfo; + this.loadParams.fields = this.fields; + this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit; + this.loadParams.parentID = params.parentID; + this.loadParams.viewType = this.viewType; + this.recordID = params.recordID; + + this.model = params.model; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the AbstractField specialization that should be used for the + * given field informations. If there is no mentioned specific widget to + * use, determines one according the field type. + * + * @private + * @param {string} viewType + * @param {Object} field + * @param {Object} attrs + * @returns {function|null} AbstractField specialization Class + */ + _getFieldWidgetClass: function (viewType, field, attrs) { + var FieldWidget; + if (attrs.widget) { + FieldWidget = fieldRegistryOwl.getAny([viewType + "." + attrs.widget, attrs.widget]) || + fieldRegistry.getAny([viewType + "." + attrs.widget, attrs.widget]); + if (!FieldWidget) { + console.warn("Missing widget: ", attrs.widget, " for field", attrs.name, "of type", field.type); + } + } else if (viewType === 'kanban' && field.type === 'many2many') { + // we want to display the widget many2manytags in kanban even if it + // is not specified in the view + FieldWidget = fieldRegistry.get('kanban.many2many_tags'); + } + return FieldWidget || + fieldRegistryOwl.getAny([viewType + "." + field.type, field.type, "abstract"]) || + fieldRegistry.getAny([viewType + "." + field.type, field.type, "abstract"]); + }, + /** + * In some cases, we already have a preloaded record + * + * @override + * @private + * @returns {Promise} + */ + _loadData: async function (model) { + if (this.recordID) { + // Add the fieldsInfo of the current view to the given recordID, + // as it will be shared between two views, and it must be able to + // handle changes on fields that are only on this view. + await model.addFieldsInfo(this.recordID, { + fields: this.fields, + fieldInfo: this.fieldsInfo[this.viewType], + viewType: this.viewType, + }); + + var record = model.get(this.recordID); + var viewType = this.viewType; + var viewFields = Object.keys(record.fieldsInfo[viewType]); + var fieldNames = _.difference(viewFields, Object.keys(record.data)); + var fieldsInfo = record.fieldsInfo[viewType]; + + // Suppose that in a form view, there is an x2many list view with + // a field F, and that F is also displayed in the x2many form view. + // In this case, F is represented in record.data (as it is known by + // the x2many list view), but the loaded information may not suffice + // in the form view (e.g. if field is a many2many list in the form + // view, or if it is displayed by a widget requiring specialData). + // So when this happens, F is added to the list of fieldNames to fetch. + _.each(viewFields, (name) => { + if (!_.contains(fieldNames, name)) { + var fieldType = record.fields[name].type; + var fieldInfo = fieldsInfo[name]; + + // SpecialData case: field requires specialData that haven't + // been fetched yet. + if (fieldInfo.Widget) { + var requiresSpecialData = fieldInfo.Widget.prototype.specialData; + if (requiresSpecialData && !(name in record.specialData)) { + fieldNames.push(name); + return; + } + } + + // X2Many case: field is an x2many displayed as a list or + // kanban view, but the related fields haven't been loaded yet. + if ((fieldType === 'one2many' || fieldType === 'many2many')) { + if (!('fieldsInfo' in record.data[name])) { + fieldNames.push(name); + } else { + var x2mFieldInfo = record.fieldsInfo[this.viewType][name]; + var viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode; + var knownFields = Object.keys(record.data[name].fieldsInfo[record.data[name].viewType] || {}); + var newFields = Object.keys(record.data[name].fieldsInfo[viewType] || {}); + if (_.difference(newFields, knownFields).length) { + fieldNames.push(name); + } + + if (record.data[name].viewType === 'default') { + // Use case: x2many (tags) in x2many list views + // When opening the x2many record form view, the + // x2many will be reloaded but it may not have + // the same fields (ex: tags in list and list in + // form) so we need to merge the fieldsInfo to + // avoid losing the initial fields (display_name) + var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {}; + var defaultFieldInfo = record.data[name].fieldsInfo.default; + _.each(fieldViews, function (fieldView) { + _.each(fieldView.fieldsInfo, function (x2mFieldInfo) { + _.defaults(x2mFieldInfo, defaultFieldInfo); + }); + }); + } + } + } + // Many2one: context is not the same between the different views + // this means the result of a name_get could differ + if (fieldType === 'many2one') { + if (JSON.stringify(record.data[name].context) !== + JSON.stringify(fieldInfo.context)) { + fieldNames.push(name); + } + } + } + }); + + var def; + if (fieldNames.length) { + if (model.isNew(record.id)) { + def = model.generateDefaultValues(record.id, { + fieldNames: fieldNames, + viewType: viewType, + }); + } else { + def = model.reload(record.id, { + fieldNames: fieldNames, + keepChanges: true, + viewType: viewType, + }); + } + } + return Promise.resolve(def).then(function () { + const handle = record.id; + return { state: model.get(handle), handle }; + }); + } + return this._super.apply(this, arguments); + }, + /** + * Traverses the arch and calls '_processNode' on each of its nodes. + * + * @private + * @param {Object} arch a parsed arch + * @param {Object} fv the fieldsView Object, in which _processNode can + * access and add information (like the fields' attributes in the arch) + */ + _processArch: function (arch, fv) { + var self = this; + utils.traverse(arch, function (node) { + return self._processNode(node, fv); + }); + }, + /** + * Processes a field node, in particular, put a flag on the field to give + * special directives to the BasicModel. + * + * @private + * @param {string} viewType + * @param {Object} field - the field properties + * @param {Object} attrs - the field attributes (from the xml) + * @returns {Object} attrs + */ + _processField: function (viewType, field, attrs) { + var self = this; + attrs.Widget = this._getFieldWidgetClass(viewType, field, attrs); + + // process decoration attributes + _.each(attrs, function (value, key) { + if (key.startsWith('decoration-')) { + attrs.decorations = attrs.decorations || []; + attrs.decorations.push({ + name: key, + expression: pyUtils._getPyJSAST(value), + }); + } + }); + + if (!_.isObject(attrs.options)) { // parent arch could have already been processed (TODO this should not happen) + attrs.options = attrs.options ? pyUtils.py_eval(attrs.options) : {}; + } + + if (attrs.on_change && attrs.on_change !== "0" && !field.onChange) { + field.onChange = "1"; + } + + // the relational data of invisible relational fields should not be + // fetched (e.g. name_gets of invisible many2ones), at least those that + // are always invisible. + // the invisible attribute of a field is supposed to be static ("1" in + // general), but not totally as it may use keys of the context + // ("context.get('some_key')"). It is evaluated server-side, and the + // result is put inside the modifiers as a value of the '(column_)invisible' + // key, and the raw value is left in the invisible attribute (it is used + // in debug mode for informational purposes). + // this should change, for instance the server might set the evaluated + // value in invisible, which could then be seen as static by the client, + // and add another key in debug mode containing the raw value. + // for now, we look inside the modifiers and consider the value only if + // it is static (=== true), + if (attrs.modifiers.invisible === true || attrs.modifiers.column_invisible === true) { + attrs.__no_fetch = true; + } + + if (!_.isEmpty(field.views)) { + // process the inner fields_view as well to find the fields they use. + // register those fields' description directly on the view. + // for those inner views, the list of all fields isn't necessary, so + // basically the field_names will be the keys of the fields obj. + // don't use _ to iterate on fields in case there is a 'length' field, + // as _ doesn't behave correctly when there is a length key in the object + attrs.views = {}; + _.each(field.views, function (innerFieldsView, viewType) { + viewType = viewType === 'tree' ? 'list' : viewType; + attrs.views[viewType] = self._processFieldsView(innerFieldsView, viewType); + }); + } + + attrs.views = attrs.views || {}; + + // Keep compatibility with 'tree' syntax + attrs.mode = attrs.mode === 'tree' ? 'list' : attrs.mode; + if (!attrs.views.list && attrs.views.tree) { + attrs.views.list = attrs.views.tree; + } + + if (field.type === 'one2many' || field.type === 'many2many') { + if (attrs.Widget.prototype.useSubview) { + var mode = attrs.mode; + if (!mode) { + if (attrs.views.list && !attrs.views.kanban) { + mode = 'list'; + } else if (!attrs.views.list && attrs.views.kanban) { + mode = 'kanban'; + } else { + mode = 'list,kanban'; + } + } + if (mode.indexOf(',') !== -1) { + mode = config.device.isMobile ? 'kanban' : 'list'; + } + attrs.mode = mode; + if (mode in attrs.views) { + var view = attrs.views[mode]; + this._processSubViewAttrs(view, attrs); + } + } + if (attrs.Widget.prototype.fieldsToFetch) { + attrs.viewType = 'default'; + attrs.relatedFields = _.extend({}, attrs.Widget.prototype.fieldsToFetch); + attrs.fieldsInfo = { + default: _.mapObject(attrs.Widget.prototype.fieldsToFetch, function () { + return {}; + }), + }; + if (attrs.options.color_field) { + // used by m2m tags + attrs.relatedFields[attrs.options.color_field] = { type: 'integer' }; + attrs.fieldsInfo.default[attrs.options.color_field] = {}; + } + } + } + + if (attrs.Widget.prototype.fieldDependencies) { + attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies; + } + + return attrs; + }, + /** + * Overrides to process the fields, and generate fieldsInfo which contains + * the description of the fields in view, with their attrs in the arch. + * + * @override + * @private + * @param {Object} fieldsView + * @param {string} fieldsView.arch + * @param {Object} fieldsView.fields + * @param {string} [viewType] by default, this.viewType + * @returns {Object} the processed fieldsView with extra key 'fieldsInfo' + */ + _processFieldsView: function (fieldsView, viewType) { + var fv = this._super.apply(this, arguments); + + viewType = viewType || this.viewType; + fv.type = viewType; + fv.fieldsInfo = Object.create(null); + fv.fieldsInfo[viewType] = Object.create(null); + + this._processArch(fv.arch, fv); + + return fv; + }, + /** + * Processes a node of the arch (mainly nodes with tagname 'field'). Can + * be overridden to handle other tagnames. + * + * @private + * @param {Object} node + * @param {Object} fv the fieldsView + * @param {Object} fv.fieldsInfo + * @param {Object} fv.fieldsInfo[viewType] fieldsInfo of the current viewType + * @param {Object} fv.viewFields the result of a fields_get extend with the + * fields returned with the fields_view_get for the current viewType + * @param {string} fv.viewType + * @returns {boolean} false iff subnodes must not be visited. + */ + _processNode: function (node, fv) { + if (typeof node === 'string') { + return false; + } + if (!_.isObject(node.attrs.modifiers)) { + node.attrs.modifiers = node.attrs.modifiers ? JSON.parse(node.attrs.modifiers) : {}; + } + if (!_.isObject(node.attrs.options) && node.tag === 'button') { + node.attrs.options = node.attrs.options ? JSON.parse(node.attrs.options) : {}; + } + if (node.tag === 'field') { + var viewType = fv.type; + var fieldsInfo = fv.fieldsInfo[viewType]; + var fields = fv.viewFields; + fieldsInfo[node.attrs.name] = this._processField(viewType, + fields[node.attrs.name], node.attrs ? _.clone(node.attrs) : {}); + + if (fieldsInfo[node.attrs.name].fieldDependencies) { + var deps = fieldsInfo[node.attrs.name].fieldDependencies; + for (var dependency_name in deps) { + var dependency_dict = {name: dependency_name, type: deps[dependency_name].type}; + if (!(dependency_name in fieldsInfo)) { + fieldsInfo[dependency_name] = _.extend({}, dependency_dict, { + options: deps[dependency_name].options || {}, + }); + } + if (!(dependency_name in fields)) { + fields[dependency_name] = dependency_dict; + } + + if (fv.fields && !(dependency_name in fv.fields)) { + fv.fields[dependency_name] = dependency_dict; + } + } + } + return false; + } + return node.tag !== 'arch'; + }, + /** + * Processes in place the subview attributes (in particular, + * `default_order``and `column_invisible`). + * + * @private + * @param {Object} view - the field subview + * @param {Object} attrs - the field attributes (from the xml) + */ + _processSubViewAttrs: function (view, attrs) { + var defaultOrder = view.arch.attrs.default_order; + if (defaultOrder) { + // process the default_order, which is like 'name,id desc' + // but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}] + attrs.orderedBy = _.map(defaultOrder.split(','), function (order) { + order = order.trim().split(' '); + return {name: order[0], asc: order[1] !== 'desc'}; + }); + } else { + // if there is a field with widget `handle`, the x2many + // needs to be ordered by this field to correctly display + // the records + var handleField = _.find(view.arch.children, function (child) { + return child.attrs && child.attrs.widget === 'handle'; + }); + if (handleField) { + attrs.orderedBy = [{name: handleField.attrs.name, asc: true}]; + } + } + + attrs.columnInvisibleFields = {}; + _.each(view.arch.children, function (child) { + if (child.attrs && child.attrs.modifiers) { + attrs.columnInvisibleFields[child.attrs.name] = + child.attrs.modifiers.column_invisible || false; + } + }); + }, +}); + +return BasicView; + +}); diff --git a/addons/web/static/src/js/views/basic/widget_registry.js b/addons/web/static/src/js/views/basic/widget_registry.js new file mode 100644 index 00000000..470127bd --- /dev/null +++ b/addons/web/static/src/js/views/basic/widget_registry.js @@ -0,0 +1,27 @@ +odoo.define('web.widget_registry', function (require) { + "use strict"; + + // This registry is supposed to contain all custom widgets that will be + // available in the basic views, with the tag <widget/>. There are + // currently no such widget in the web client, but the functionality is + // certainly useful to be able to cleanly add custom behaviour in basic + // views (and most notably, the form view) + // + // The way custom widgets work is that they register themselves to this + // registry: + // + // widgetRegistry.add('some_name', MyWidget); + // + // Then, they are available with the <widget/> tag (in the arch): + // + // <widget name="some_name"/> + // + // Widgets will be then properly instantiated, rendered and destroyed at the + // appropriate time, with the current state in second argument. + // + // For more examples, look at the tests (grep '<widget' in the test folder) + + var Registry = require('web.Registry'); + + return new Registry(); +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js new file mode 100644 index 00000000..0ab74b34 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -0,0 +1,477 @@ +odoo.define('web.CalendarController', function (require) { +"use strict"; + +/** + * Calendar Controller + * + * This is the controller in the Model-Renderer-Controller architecture of the + * calendar view. Its role is to coordinate the data from the calendar model + * with the renderer, and with the outside world (such as a search view input) + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); +var QuickCreate = require('web.CalendarQuickCreate'); + +var _t = core._t; +var QWeb = core.qweb; + +function dateToServer (date, fieldType) { + date = date.clone().locale('en'); + if (fieldType === "date") { + return date.local().format('YYYY-MM-DD'); + } + return date.utc().format('YYYY-MM-DD HH:mm:ss'); +} + +var CalendarController = AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + changeDate: '_onChangeDate', + changeFilter: '_onChangeFilter', + deleteRecord: '_onDeleteRecord', + dropRecord: '_onDropRecord', + next: '_onNext', + openCreate: '_onOpenCreate', + openEvent: '_onOpenEvent', + prev: '_onPrev', + quickCreate: '_onQuickCreate', + updateRecord: '_onUpdateRecord', + viewUpdated: '_onViewUpdated', + }), + events: _.extend({}, AbstractController.prototype.events, { + 'click button.o_calendar_button_new': '_onButtonNew', + 'click button.o_calendar_button_prev': '_onButtonNavigation', + 'click button.o_calendar_button_today': '_onButtonNavigation', + 'click button.o_calendar_button_next': '_onButtonNavigation', + 'click button.o_calendar_button_day': '_onButtonScale', + 'click button.o_calendar_button_week': '_onButtonScale', + 'click button.o_calendar_button_month': '_onButtonScale', + 'click button.o_calendar_button_year': '_onButtonScale', + }), + /** + * @override + * @param {Widget} parent + * @param {AbstractModel} model + * @param {AbstractRenderer} renderer + * @param {Object} params + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.current_start = null; + this.displayName = params.displayName; + this.quickAddPop = params.quickAddPop; + this.disableQuickCreate = params.disableQuickCreate; + this.eventOpenPopup = params.eventOpenPopup; + this.showUnusualDays = params.showUnusualDays; + this.formViewId = params.formViewId; + this.readonlyFormViewId = params.readonlyFormViewId; + this.mapping = params.mapping; + this.context = params.context; + this.previousOpen = null; + // The quickCreating attribute ensures that we don't do several create + this.quickCreating = false; + this.scales = params.scales; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Render the buttons according to the CalendarView.buttons template and + * add listeners on it. Set this.$buttons with the produced jQuery element + * + * @param {jQuery} [$node] a jQuery node where the rendered buttons + * should be inserted. $node may be undefined, in which case the Calendar + * inserts them into this.options.$buttons or into a div of its template + */ + renderButtons: function ($node) { + this.$buttons = $(QWeb.render('CalendarView.buttons', this._renderButtonsParameters())); + + this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); + + if ($node) { + this.$buttons.appendTo($node); + } else { + this.$('.o_calendar_buttons').replaceWith(this.$buttons); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Find a className in an array using the start of this class and + * return the last part of a string + * @private + * @param {string} startClassName start of string to find in the "array" + * @param {array|DOMTokenList} classList array of all class + * @return {string|undefined} + */ + _extractLastPartOfClassName(startClassName, classList) { + var result; + classList.forEach(function (value) { + if (value && value.indexOf(startClassName) === 0) { + result = value.substring(startClassName.length); + } + }); + return result; + }, + /** + * Move to the requested direction and reload the view + * + * @private + * @param {string} to either 'prev', 'next' or 'today' + * @returns {Promise} + */ + _move: function (to) { + this.model[to](); + return this.reload(); + }, + /** + * Parameter send to QWeb to render the template of Buttons + * + * @private + * @return {{}} + */ + _renderButtonsParameters() { + return { + scales: this.scales, + }; + }, + /** + * @override + * @private + */ + _update: function () { + var self = this; + if (!this.showUnusualDays) { + return this._super.apply(this, arguments); + } + return this._super.apply(this, arguments).then(function () { + self._rpc({ + model: self.modelName, + method: 'get_unusual_days', + args: [dateToServer(self.model.data.start_date, 'date'), dateToServer(self.model.data.end_date, 'date')], + context: self.context, + }).then(function (data) { + _.each(self.$el.find('td.fc-day'), function (td) { + var $td = $(td); + if (data[$td.data('date')]) { + $td.addClass('o_calendar_disabled'); + } + }); + }); + }); + }, + /** + * @private + * @param {Object} record + * @param {integer} record.id + * @returns {Promise} + */ + _updateRecord: function (record) { + var reload = this.reload.bind(this, {}); + return this.model.updateRecord(record).then(reload, reload); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handler when a user clicks on button to create event + * + * @private + */ + _onButtonNew() { + this.trigger_up('switch_view', {view_type: 'form'}); + }, + /** + * Handler when a user click on navigation button like prev, next, ... + * + * @private + * @param {Event|jQueryEvent} jsEvent + */ + _onButtonNavigation(jsEvent) { + const action = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList); + if (action) { + this._move(action); + } + }, + /** + * Handler when a user click on scale button like day, month, ... + * + * @private + * @param {Event|jQueryEvent} jsEvent + */ + _onButtonScale(jsEvent) { + const scale = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList); + if (scale) { + this.model.setScale(scale); + this.reload(); + } + }, + + /** + * @private + * @param {OdooEvent} event + */ + _onChangeDate: function (event) { + var modelData = this.model.get(); + if (modelData.target_date.format('YYYY-MM-DD') === event.data.date.format('YYYY-MM-DD')) { + // When clicking on same date, toggle between the two views + switch (modelData.scale) { + case 'month': this.model.setScale('week'); break; + case 'week': this.model.setScale('day'); break; + case 'day': this.model.setScale('month'); break; + } + } else if (modelData.target_date.week() === event.data.date.week()) { + // When clicking on a date in the same week, switch to day view + this.model.setScale('day'); + } else { + // When clicking on a random day of a random other week, switch to week view + this.model.setScale('week'); + } + this.model.setDate(event.data.date); + this.reload(); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onChangeFilter: function (event) { + if (this.model.changeFilter(event.data) && !event.data.no_reload) { + this.reload(); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onDeleteRecord: function (event) { + var self = this; + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: function () { + self.model.deleteRecords([event.data.id], self.modelName).then(function () { + self.reload(); + }); + } + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onDropRecord: function (event) { + this._updateRecord(_.extend({}, event.data, { + 'drop': true, + })); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onNext: function (event) { + event.stopPropagation(); + this._move('next'); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onOpenCreate: function (event) { + var self = this; + if (["year", "month"].includes(this.model.get().scale)) { + event.data.allDay = true; + } + var data = this.model.calendarEventToRecord(event.data); + + var context = _.extend({}, this.context, event.options && event.options.context); + // context default has more priority in default_get so if data.name is false then it may + // lead to error/warning while saving record in form view as name field can be required + if (data.name) { + context.default_name = data.name; + } + context['default_' + this.mapping.date_start] = data[this.mapping.date_start] || null; + if (this.mapping.date_stop) { + context['default_' + this.mapping.date_stop] = data[this.mapping.date_stop] || null; + } + if (this.mapping.date_delay) { + context['default_' + this.mapping.date_delay] = data[this.mapping.date_delay] || null; + } + if (this.mapping.all_day) { + context['default_' + this.mapping.all_day] = data[this.mapping.all_day] || null; + } + + for (var k in context) { + if (context[k] && context[k]._isAMomentObject) { + context[k] = dateToServer(context[k]); + } + } + + var options = _.extend({}, this.options, event.options, { + context: context, + title: _.str.sprintf(_t('Create: %s'), (this.displayName || this.renderer.arch.attrs.string)) + }); + + if (this.quick != null) { + this.quick.destroy(); + this.quick = null; + } + + if (!options.disableQuickCreate && !event.data.disableQuickCreate && this.quickAddPop) { + this.quick = new QuickCreate(this, true, options, data, event.data); + this.quick.open(); + this.quick.opened(function () { + self.quick.focus(); + }); + return; + } + + var title = _t("Create"); + if (this.renderer.arch.attrs.string) { + title += ': ' + this.renderer.arch.attrs.string; + } + if (this.eventOpenPopup) { + if (this.previousOpen) { this.previousOpen.close(); } + this.previousOpen = new dialogs.FormViewDialog(self, { + res_model: this.modelName, + context: context, + title: title, + view_id: this.formViewId || false, + disable_multiple_selection: true, + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }); + this.previousOpen.open(); + } else { + this.do_action({ + type: 'ir.actions.act_window', + res_model: this.modelName, + views: [[this.formViewId || false, 'form']], + target: 'current', + context: context, + }); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onOpenEvent: function (event) { + var self = this; + var id = event.data._id; + id = id && parseInt(id).toString() === id ? parseInt(id) : id; + + if (!this.eventOpenPopup) { + this._rpc({ + model: self.modelName, + method: 'get_formview_id', + //The event can be called by a view that can have another context than the default one. + args: [[id]], + context: event.context || self.context, + }).then(function (viewId) { + self.do_action({ + type:'ir.actions.act_window', + res_id: id, + res_model: self.modelName, + views: [[viewId || false, 'form']], + target: 'current', + context: event.context || self.context, + }); + }); + return; + } + + var options = { + res_model: self.modelName, + res_id: id || null, + context: event.context || self.context, + title: _t("Open: ") + _.escape(event.data.title), + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }; + if (this.formViewId) { + options.view_id = parseInt(this.formViewId); + } + new dialogs.FormViewDialog(this, options).open(); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onPrev: function () { + event.stopPropagation(); + this._move('prev'); + }, + + /** + * Handles saving data coming from quick create box + * + * @private + * @param {OdooEvent} event + */ + _onQuickCreate: function (event) { + var self = this; + if (this.quickCreating) { + return; + } + this.quickCreating = true; + this.model.createRecord(event) + .then(function () { + self.quick.destroy(); + self.quick = null; + self.reload(); + self.quickCreating = false; + }) + .guardedCatch(function (result) { + var errorEvent = result.event; + // This will occurs if there are some more fields required + // Preventdefaulting the error event will prevent the traceback window + errorEvent.preventDefault(); + event.data.options.disableQuickCreate = true; + event.data.data.on_save = self.quick.destroy.bind(self.quick); + self._onOpenCreate(event.data); + self.quickCreating = false; + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onUpdateRecord: function (event) { + this._updateRecord(event.data); + }, + /** + * The internal state of the calendar (mode, period displayed) has changed, + * so update the control panel buttons and breadcrumbs accordingly. + * + * @private + * @param {OdooEvent} event + */ + _onViewUpdated: function (event) { + this.mode = event.data.mode; + if (this.$buttons) { + this.$buttons.find('.active').removeClass('active'); + this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); + } + const title = `${this.displayName} (${event.data.title})`; + return this.updateControlPanel({ title }); + }, +}); + +return CalendarController; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js new file mode 100644 index 00000000..93999bee --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -0,0 +1,777 @@ +odoo.define('web.CalendarModel', function (require) { +"use strict"; + +var AbstractModel = require('web.AbstractModel'); +var Context = require('web.Context'); +var core = require('web.core'); +var fieldUtils = require('web.field_utils'); +var session = require('web.session'); + +var _t = core._t; + +function dateToServer (date) { + return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss'); +} + +return AbstractModel.extend({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.end_date = null; + var week_start = _t.database.parameters.week_start; + // calendar uses index 0 for Sunday but Odoo stores it as 7 + this.week_start = week_start !== undefined && week_start !== false ? week_start % 7 : moment().startOf('week').day(); + this.week_stop = this.week_start + 6; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Transform fullcalendar event object to OpenERP Data object + */ + calendarEventToRecord: function (event) { + // Normalize event_end without changing fullcalendars event. + var data = {'name': event.title}; + var start = event.start.clone(); + var end = event.end && event.end.clone(); + + // Set end date if not existing + if (!end || end.diff(start) < 0) { // undefined or invalid end date + if (event.allDay) { + end = start.clone(); + } else { + // in week mode or day mode, convert allday event to event + end = start.clone().add(2, 'h'); + } + } else if (event.allDay) { + // For an "allDay", FullCalendar gives the end day as the + // next day at midnight (instead of 23h59). + end.add(-1, 'days'); + } + + var isDateEvent = this.fields[this.mapping.date_start].type === 'date'; + // An "allDay" event without the "all_day" option is not considered + // as a 24h day. It's just a part of the day (by default: 7h-19h). + if (event.allDay) { + if (!this.mapping.all_day && !isDateEvent) { + if (event.r_start) { + start.hours(event.r_start.hours()) + .minutes(event.r_start.minutes()) + .seconds(event.r_start.seconds()) + .utc(); + end.hours(event.r_end.hours()) + .minutes(event.r_end.minutes()) + .seconds(event.r_end.seconds()) + .utc(); + } else { + // default hours in the user's timezone + start.hours(7); + end.hours(19); + } + start.add(-this.getSession().getTZOffset(start), 'minutes'); + end.add(-this.getSession().getTZOffset(end), 'minutes'); + } + } else { + start.add(-this.getSession().getTZOffset(start), 'minutes'); + end.add(-this.getSession().getTZOffset(end), 'minutes'); + } + + if (this.mapping.all_day) { + if (event.record) { + data[this.mapping.all_day] = + (this.data.scale !== 'month' && event.allDay) || + event.record[this.mapping.all_day] && + end.diff(start) < 10 || + false; + } else { + data[this.mapping.all_day] = event.allDay; + } + } + + data[this.mapping.date_start] = start; + if (this.mapping.date_stop) { + data[this.mapping.date_stop] = end; + } + + if (this.mapping.date_delay) { + if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } + } + + return data; + }, + /** + * @param {Object} filter + * @returns {boolean} + */ + changeFilter: function (filter) { + var Filter = this.data.filters[filter.fieldName]; + if (filter.value === 'all') { + Filter.all = filter.active; + } + var f = _.find(Filter.filters, function (f) { + return f.value === filter.value; + }); + if (f) { + if (f.active !== filter.active) { + f.active = filter.active; + } else { + return false; + } + } else if (filter.active) { + Filter.filters.push({ + value: filter.value, + active: true, + }); + } + return true; + }, + /** + * @param {OdooEvent} event + */ + createRecord: function (event) { + var data = this.calendarEventToRecord(event.data.data); + for (var k in data) { + if (data[k] && data[k]._isAMomentObject) { + data[k] = dateToServer(data[k]); + } + } + return this._rpc({ + model: this.modelName, + method: 'create', + args: [data], + context: event.data.options.context, + }); + }, + /** + * @todo I think this is dead code + * + * @param {any} ids + * @param {any} model + * @returns + */ + deleteRecords: function (ids, model) { + return this._rpc({ + model: model, + method: 'unlink', + args: [ids], + context: session.user_context, // todo: combine with view context + }); + }, + /** + * @override + * @returns {Object} + */ + __get: function () { + return _.extend({}, this.data, { + fields: this.fields + }); + }, + /** + * @override + * @param {any} params + * @returns {Promise} + */ + __load: function (params) { + var self = this; + this.modelName = params.modelName; + this.fields = params.fields; + this.fieldNames = params.fieldNames; + this.fieldsInfo = params.fieldsInfo; + this.mapping = params.mapping; + this.mode = params.mode; // one of month, week or day + this.scales = params.scales; // one of month, week or day + this.scalesInfo = params.scalesInfo; + + // Check whether the date field is editable (i.e. if the events can be + // dragged and dropped) + this.editable = params.editable; + this.creatable = params.creatable; + + // display more button when there are too much event on one day + this.eventLimit = params.eventLimit; + + // fields to display color, e.g.: user_id.partner_id + this.fieldColor = params.fieldColor; + if (!this.preloadPromise) { + this.preloadPromise = new Promise(function (resolve, reject) { + Promise.all([ + self._rpc({model: self.modelName, method: 'check_access_rights', args: ["write", false]}), + self._rpc({model: self.modelName, method: 'check_access_rights', args: ["create", false]}) + ]).then(function (result) { + var write = result[0]; + var create = result[1]; + self.write_right = write; + self.create_right = create; + resolve(); + }).guardedCatch(reject); + }); + } + + this.data = { + domain: params.domain, + context: params.context, + // get in arch the filter to display in the sidebar and the field to read + filters: params.filters, + }; + + this.setDate(params.initialDate); + // Use mode attribute in xml file to specify zoom timeline (day,week,month) + // by default month. + this.setScale(params.mode); + + _.each(this.data.filters, function (filter) { + if (filter.avatar_field && !filter.avatar_model) { + filter.avatar_model = self.modelName; + } + }); + + return this.preloadPromise.then(this._loadCalendar.bind(this)); + }, + /** + * Move the current date range to the next period + */ + next: function () { + this.setDate(this.data.target_date.clone().add(1, this.data.scale)); + }, + /** + * Move the current date range to the previous period + */ + prev: function () { + this.setDate(this.data.target_date.clone().add(-1, this.data.scale)); + }, + /** + * @override + * @param {Object} [params.context] + * @param {Array} [params.domain] + * @returns {Promise} + */ + __reload: function (handle, params) { + if (params.domain) { + this.data.domain = params.domain; + } + if (params.context) { + this.data.context = params.context; + } + return this._loadCalendar(); + }, + /** + * @param {Moment} start. in local TZ + */ + setDate: function (start) { + // keep highlight/target_date in localtime + this.data.highlight_date = this.data.target_date = start.clone(); + this.data.start_date = this.data.end_date = start; + switch (this.data.scale) { + case 'year': { + const yearStart = this.data.start_date.clone().startOf('year'); + let yearStartDay = this.week_start; + if (yearStart.day() < yearStartDay) { + // the 1st of January is before our week start (e.g. week start is Monday, and + // 01/01 is Sunday), so we go one week back + yearStartDay -= 7; + } + this.data.start_date = yearStart.day(yearStartDay).startOf('day'); + this.data.end_date = this.data.end_date.clone() + .endOf('year').day(this.week_stop).endOf('day'); + break; + } + case 'month': + var monthStart = this.data.start_date.clone().startOf('month'); + + var monthStartDay; + if (monthStart.day() >= this.week_start) { + // the month's first day is after our week start + // Then we are in the right week + monthStartDay = this.week_start; + } else { + // The month's first day is before our week start + // Then we should go back to the the previous week + monthStartDay = this.week_start - 7; + } + + this.data.start_date = monthStart.day(monthStartDay).startOf('day'); + this.data.end_date = this.data.start_date.clone().add(5, 'week').day(this.week_stop).endOf('day'); + break; + case 'week': + var weekStart = this.data.start_date.clone().startOf('week'); + var weekStartDay = this.week_start; + if (this.data.start_date.day() < this.week_start) { + // The week's first day is after our current day + // Then we should go back to the previous week + weekStartDay -= 7; + } + this.data.start_date = this.data.start_date.clone().day(weekStartDay).startOf('day'); + this.data.end_date = this.data.end_date.clone().day(weekStartDay + 6).endOf('day'); + break; + default: + this.data.start_date = this.data.start_date.clone().startOf('day'); + this.data.end_date = this.data.end_date.clone().endOf('day'); + } + // We have set start/stop datetime as definite begin/end boundaries of a period (month, week, day) + // in local TZ (what is the begining of the week *I am* in ?) + // The following code: + // - converts those to UTC using our homemade method (testable) + // - sets the moment UTC flag to true, to ensure compatibility with third party libs + var manualUtcDateStart = this.data.start_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes'); + var formattedUtcDateStart = manualUtcDateStart.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; + this.data.start_date = moment.utc(formattedUtcDateStart); + + var manualUtcDateEnd = this.data.end_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes'); + var formattedUtcDateEnd = manualUtcDateEnd.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; + this.data.end_date = moment.utc(formattedUtcDateEnd); + }, + /** + * @param {string} scale the scale to set + */ + setScale: function (scale) { + if (!_.contains(this.scales, scale)) { + scale = "week"; + } + this.data.scale = scale; + this.setDate(this.data.target_date); + }, + /** + * Move the current date range to the period containing today + */ + today: function () { + this.setDate(moment(new Date())); + }, + /** + * @param {Object} record + * @param {integer} record.id + * @returns {Promise} + */ + updateRecord: function (record) { + // Cannot modify actual name yet + var data = _.omit(this.calendarEventToRecord(record), 'name'); + for (var k in data) { + if (data[k] && data[k]._isAMomentObject) { + data[k] = dateToServer(data[k]); + } + } + var context = new Context(this.data.context, {from_ui: true}); + return this._rpc({ + model: this.modelName, + method: 'write', + args: [[parseInt(record.id, 10)], data], + context: context + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Converts this.data.filters into a domain + * + * @private + * @returns {Array} + */ + _getFilterDomain: function () { + // List authorized values for every field + // fields with an active 'all' filter are skipped + var authorizedValues = {}; + var avoidValues = {}; + + _.each(this.data.filters, function (filter) { + // Skip 'all' filters because they do not affect the domain + if (filter.all) return; + + // Loop over subfilters to complete authorizedValues + _.each(filter.filters, function (f) { + if (filter.write_model) { + if (!authorizedValues[filter.fieldName]) + authorizedValues[filter.fieldName] = []; + + if (f.active) { + authorizedValues[filter.fieldName].push(f.value); + } + } else { + if (!f.active) { + if (!avoidValues[filter.fieldName]) + avoidValues[filter.fieldName] = []; + + avoidValues[filter.fieldName].push(f.value); + } + } + }); + }); + + // Compute the domain + var domain = []; + for (var field in authorizedValues) { + domain.push([field, 'in', authorizedValues[field]]); + } + for (var field in avoidValues) { + if (avoidValues[field].length > 0) { + domain.push([field, 'not in', avoidValues[field]]); + } + } + + return domain; + }, + /** + * @private + * @returns {Object} + */ + _getFullCalendarOptions: function () { + var format12Hour = { + hour: 'numeric', + minute: '2-digit', + omitZeroMinute: true, + meridiem: 'short' + }; + var format24Hour = { + hour: 'numeric', + minute: '2-digit', + hour12: false, + }; + return { + defaultView: this.scalesInfo[this.mode || 'week'], + header: false, + selectable: this.creatable && this.create_right, + selectMirror: true, + editable: this.editable, + droppable: true, + navLinks: false, + eventLimit: this.eventLimit, // allow "more" link when too many events + snapMinutes: 15, + longPressDelay: 500, + eventResizableFromStart: true, + nowIndicator: true, + weekNumbers: true, + weekNumbersWithinDays: true, + weekNumberCalculation: function (date) { + // Since FullCalendar v4 ISO 8601 week date is preferred so we force the old system + return moment(date).week(); + }, + weekLabel: _t("Week"), + allDayText: _t("All day"), + monthNames: moment.months(), + monthNamesShort: moment.monthsShort(), + dayNames: moment.weekdays(), + dayNamesShort: moment.weekdaysShort(), + firstDay: this.week_start, + slotLabelFormat: _t.database.parameters.time_format.search("%H") !== -1 ? format24Hour : format12Hour, + allDaySlot: this.mapping.all_day || this.fields[this.mapping.date_start].type === 'date', + }; + }, + /** + * Return a domain from the date range + * + * @private + * @returns {Array} A domain containing datetimes start and stop in UTC + * those datetimes are formatted according to server's standards + */ + _getRangeDomain: function () { + // Build OpenERP Domain to filter object by this.mapping.date_start field + // between given start, end dates. + var domain = [[this.mapping.date_start, '<=', dateToServer(this.data.end_date)]]; + if (this.mapping.date_stop) { + domain.push([this.mapping.date_stop, '>=', dateToServer(this.data.start_date)]); + } else if (!this.mapping.date_delay) { + domain.push([this.mapping.date_start, '>=', dateToServer(this.data.start_date)]); + } + return domain; + }, + /** + * @private + * @returns {Promise} + */ + _loadCalendar: function () { + var self = this; + this.data.fc_options = this._getFullCalendarOptions(); + + var defs = _.map(this.data.filters, this._loadFilter.bind(this)); + + return Promise.all(defs).then(function () { + return self._rpc({ + model: self.modelName, + method: 'search_read', + context: self.data.context, + fields: self.fieldNames, + domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()) + }) + .then(function (events) { + self._parseServerData(events); + self.data.data = _.map(events, self._recordToCalendarEvent.bind(self)); + return Promise.all([ + self._loadColors(self.data, self.data.data), + self._loadRecordsToFilters(self.data, self.data.data) + ]); + }); + }); + }, + /** + * @private + * @param {any} element + * @param {any} events + * @returns {Promise} + */ + _loadColors: function (element, events) { + if (this.fieldColor) { + var fieldName = this.fieldColor; + _.each(events, function (event) { + var value = event.record[fieldName]; + event.color_index = _.isArray(value) ? value[0] % 30 : value % 30; + }); + this.model_color = this.fields[fieldName].relation || element.model; + } + return Promise.resolve(); + }, + /** + * @private + * @param {any} filter + * @returns {Promise} + */ + _loadFilter: function (filter) { + if (!filter.write_model) { + return Promise.resolve(); + } + + var field = this.fields[filter.fieldName]; + return this._rpc({ + model: filter.write_model, + method: 'search_read', + domain: [["user_id", "=", session.uid]], + fields: [filter.write_field], + }) + .then(function (res) { + var records = _.map(res, function (record) { + var _value = record[filter.write_field]; + var value = _.isArray(_value) ? _value[0] : _value; + var f = _.find(filter.filters, function (f) {return f.value === value;}); + var formater = fieldUtils.format[_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type]; + return { + 'id': record.id, + 'value': value, + 'label': formater(_value, field), + 'active': !f || f.active, + }; + }); + records.sort(function (f1,f2) { + return _.string.naturalCmp(f2.label, f1.label); + }); + + // add my profile + if (field.relation === 'res.partner' || field.relation === 'res.users') { + var value = field.relation === 'res.partner' ? session.partner_id : session.uid; + var me = _.find(records, function (record) { + return record.value === value; + }); + if (me) { + records.splice(records.indexOf(me), 1); + } else { + var f = _.find(filter.filters, function (f) {return f.value === value;}); + me = { + 'value': value, + 'label': session.name + _t(" [Me]"), + 'active': !f || f.active, + }; + } + records.unshift(me); + } + // add all selection + records.push({ + 'value': 'all', + 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"), + 'active': filter.all, + }); + + filter.filters = records; + }); + }, + /** + * @private + * @param {any} element + * @param {any} events + * @returns {Promise} + */ + _loadRecordsToFilters: function (element, events) { + var self = this; + var new_filters = {}; + var to_read = {}; + var defs = []; + var color_filter = {}; + + _.each(this.data.filters, function (filter, fieldName) { + var field = self.fields[fieldName]; + + new_filters[fieldName] = filter; + if (filter.write_model) { + if (field.relation === self.model_color) { + _.each(filter.filters, function (f) { + f.color_index = f.value; + }); + } + return; + } + + _.each(filter.filters, function (filter) { + filter.display = !filter.active; + }); + + var fs = []; + var undefined_fs = []; + _.each(events, function (event) { + var data = event.record[fieldName]; + if (!_.contains(['many2many', 'one2many'], field.type)) { + data = [data]; + } else { + to_read[field.relation] = (to_read[field.relation] || []).concat(data); + } + _.each(data, function (_value) { + var value = _.isArray(_value) ? _value[0] : _value; + var f = { + 'color_index': self.model_color === (field.relation || element.model) ? value % 30 : false, + 'value': value, + 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"), + 'avatar_model': field.relation || element.model, + }; + // if field used as color does not have value then push filter in undefined_fs, + // such filters should come last in filter list with Undefined string, later merge it with fs + value ? fs.push(f) : undefined_fs.push(f); + }); + }); + _.each(_.union(fs, undefined_fs), function (f) { + var f1 = _.findWhere(filter.filters, _.omit(f, 'color_index')); + if (f1) { + f1.display = true; + } else { + f.display = f.active = true; + filter.filters.push(f); + } + }); + + if (filter.color_model && filter.field_color) { + var ids = filter.filters.reduce((acc, f) => { + if (!f.color_index && f.value) { + acc.push(f.value); + } + return acc; + }, []); + if (!color_filter[filter.color_model]) { + color_filter[filter.color_model] = {}; + } + if (ids.length) { + defs.push(self._rpc({ + model: filter.color_model, + method: 'read', + args: [_.uniq(ids), [filter.field_color]], + }) + .then(function (res) { + _.each(res, function (c) { + color_filter[filter.color_model][c.id] = c[filter.field_color]; + }); + })); + } + } + }); + + _.each(to_read, function (ids, model) { + defs.push(self._rpc({ + model: model, + method: 'name_get', + args: [_.uniq(ids)], + }) + .then(function (res) { + to_read[model] = _.object(res); + })); + }); + return Promise.all(defs).then(function () { + _.each(self.data.filters, function (filter) { + if (filter.write_model) { + return; + } + if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) { + _.each(filter.filters, function (f) { + f.label = to_read[f.avatar_model][f.value]; + }); + } + if (filter.color_model && filter.field_color) { + _.each(filter.filters, function (f) { + if (!f.color_index) { + f.color_index = color_filter[filter.color_model] && color_filter[filter.color_model][f.value]; + } + }); + } + }); + }); + }, + /** + * parse the server values to javascript framwork + * + * @private + * @param {Object} data the server data to parse + */ + _parseServerData: function (data) { + var self = this; + _.each(data, function(event) { + _.each(self.fieldNames, function (fieldName) { + event[fieldName] = self._parseServerValue(self.fields[fieldName], event[fieldName]); + }); + }); + }, + /** + * Transform OpenERP event object to fullcalendar event object + * + * @private + * @param {Object} evt + */ + _recordToCalendarEvent: function (evt) { + var date_start; + var date_stop; + var date_delay = evt[this.mapping.date_delay] || 1.0, + all_day = this.fields[this.mapping.date_start].type === 'date' || + this.mapping.all_day && evt[this.mapping.all_day] || false, + the_title = '', + attendees = []; + + if (!all_day) { + date_start = evt[this.mapping.date_start].clone(); + date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone() : null; + } else { + date_start = evt[this.mapping.date_start].clone().startOf('day'); + date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone().startOf('day') : null; + } + + if (!date_stop && date_delay) { + date_stop = date_start.clone().add(date_delay,'hours'); + } + + if (!all_day) { + date_start.add(this.getSession().getTZOffset(date_start), 'minutes'); + date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes'); + } + + if (this.mapping.all_day && evt[this.mapping.all_day]) { + date_stop.add(1, 'days'); + } + var r = { + 'record': evt, + 'start': date_start.local(true).toDate(), + 'end': date_stop.local(true).toDate(), + 'r_start': date_start.clone().local(true).toDate(), + 'r_end': date_stop.clone().local(true).toDate(), + 'title': the_title, + 'allDay': all_day, + 'id': evt.id, + 'attendees':attendees, + }; + + if (!(this.mapping.all_day && evt[this.mapping.all_day]) && this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { + r.showTime = true; + } + + return r; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_popover.js b/addons/web/static/src/js/views/calendar/calendar_popover.js new file mode 100644 index 00000000..18a3d1c2 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_popover.js @@ -0,0 +1,220 @@ +odoo.define('web.CalendarPopover', function (require) { +"use strict"; + +var fieldRegistry = require('web.field_registry'); +const fieldRegistryOwl = require('web.field_registry_owl'); +const FieldWrapper = require('web.FieldWrapper'); +var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin'); +var Widget = require('web.Widget'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); + +var CalendarPopover = Widget.extend(WidgetAdapterMixin, StandaloneFieldManagerMixin, { + template: 'CalendarView.event.popover', + events: { + 'click .o_cw_popover_edit': '_onClickPopoverEdit', + 'click .o_cw_popover_delete': '_onClickPopoverDelete', + }, + /** + * @constructor + * @param {Widget} parent + * @param {Object} eventInfo + */ + init: function (parent, eventInfo) { + this._super.apply(this, arguments); + StandaloneFieldManagerMixin.init.call(this); + this.hideDate = eventInfo.hideDate; + this.hideTime = eventInfo.hideTime; + this.eventTime = eventInfo.eventTime; + this.eventDate = eventInfo.eventDate; + this.displayFields = eventInfo.displayFields; + this.fields = eventInfo.fields; + this.event = eventInfo.event; + this.modelName = eventInfo.modelName; + this._canDelete = eventInfo.canDelete; + }, + /** + * @override + */ + willStart: function () { + return Promise.all([this._super.apply(this, arguments), this._processFields()]); + }, + /** + * @override + */ + start: function () { + var self = this; + _.each(this.$fieldsList, function ($field) { + $field.appendTo(self.$('.o_cw_popover_fields_secondary')); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the widget is attached into the DOM. + */ + on_attach_callback: function () { + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the widget is detached from the DOM. + */ + on_detach_callback: function () { + WidgetAdapterMixin.on_detach_callback.call(this); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {boolean} + */ + isEventDeletable() { + return this._canDelete;; + }, + /** + * @return {boolean} + */ + isEventDetailsVisible() { + return true; + }, + /** + * @return {boolean} + */ + isEventEditable() { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Generate fields to render into popover + * + * @private + * @returns {Promise} + */ + _processFields: function () { + var self = this; + var fieldsToGenerate = []; + var fields = _.keys(this.displayFields); + for (var i=0; i<fields.length; i++) { + var fieldName = fields[i]; + var displayFieldInfo = self.displayFields[fieldName] || {attrs: {invisible: 1}}; + var fieldInfo = self.fields[fieldName]; + var field = { + name: fieldName, + string: displayFieldInfo.attrs.string || fieldInfo.string, + value: self.event.extendedProps.record[fieldName], + type: fieldInfo.type, + invisible: displayFieldInfo.attrs.invisible, + }; + if (field.type === 'selection') { + field.selection = fieldInfo.selection; + } + if (field.type === 'monetary') { + var currencyField = field.currency_field || 'currency_id'; + if (!fields.includes(currencyField) && _.has(self.event.extendedProps.record, currencyField)) { + fields.push(currencyField); + } + } + if (fieldInfo.relation) { + field.relation = fieldInfo.relation; + } + if (displayFieldInfo.attrs.widget) { + field.widget = displayFieldInfo.attrs.widget; + } else if (_.contains(['many2many', 'one2many'], field.type)) { + field.widget = 'many2many_tags'; + } + if (_.contains(['many2many', 'one2many'], field.type)) { + field.fields = [{ + name: 'id', + type: 'integer', + }, { + name: 'display_name', + type: 'char', + }]; + } + fieldsToGenerate.push(field); + }; + + this.$fieldsList = []; + return this.model.makeRecord(this.modelName, fieldsToGenerate).then(function (recordID) { + var defs = []; + + var record = self.model.get(recordID); + _.each(fieldsToGenerate, function (field) { + if (field.invisible) return; + let isLegacy = true; + let fieldWidget; + let FieldClass = fieldRegistryOwl.getAny([field.widget, field.type]); + if (FieldClass) { + isLegacy = false; + fieldWidget = new FieldWrapper(this, FieldClass, { + fieldName: field.name, + record, + options: self.displayFields[field.name], + }); + } else { + FieldClass = fieldRegistry.getAny([field.widget, field.type]); + fieldWidget = new FieldClass(self, field.name, record, self.displayFields[field.name]); + } + if (fieldWidget.attrs && !_.isObject(fieldWidget.attrs.modifiers)) { + fieldWidget.attrs.modifiers = fieldWidget.attrs.modifiers ? JSON.parse(fieldWidget.attrs.modifiers) : {}; + } + self._registerWidget(recordID, field.name, fieldWidget); + + var $field = $('<li>', {class: 'list-group-item flex-shrink-0 d-flex flex-wrap'}); + var $fieldLabel = $('<strong>', {class: 'mr-2', text: _.str.sprintf('%s : ', field.string)}); + $fieldLabel.appendTo($field); + var $fieldContainer = $('<div>', {class: 'flex-grow-1'}); + $fieldContainer.appendTo($field); + + let def; + if (isLegacy) { + def = fieldWidget.appendTo($fieldContainer); + } else { + def = fieldWidget.mount($fieldContainer[0]); + } + self.$fieldsList.push($field); + defs.push(def); + }); + return Promise.all(defs); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQueryEvent} ev + */ + _onClickPopoverEdit: function (ev) { + ev.preventDefault(); + this.trigger_up('edit_event', { + id: this.event.id, + title: this.event.extendedProps.record.display_name, + }); + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onClickPopoverDelete: function (ev) { + ev.preventDefault(); + this.trigger_up('delete_event', {id: this.event.id}); + }, +}); + +return CalendarPopover; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_quick_create.js b/addons/web/static/src/js/views/calendar/calendar_quick_create.js new file mode 100644 index 00000000..0f6f8bd6 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js @@ -0,0 +1,114 @@ +odoo.define('web.CalendarQuickCreate', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +var _t = core._t; +var QWeb = core.qweb; + +/** + * Quick creation view. + * + * Triggers a single event "added" with a single parameter "name", which is the + * name entered by the user + * + * @class + * @type {*} + */ +var QuickCreate = Dialog.extend({ + events: _.extend({}, Dialog.events, { + 'keyup input': '_onkeyup', + }), + + /** + * @constructor + * @param {Widget} parent + * @param {Object} buttons + * @param {Object} options + * @param {Object} dataTemplate + * @param {Object} dataCalendar + */ + init: function (parent, buttons, options, dataTemplate, dataCalendar) { + this._buttons = buttons || false; + this.options = options; + + // Can hold data pre-set from where you clicked on agenda + this.dataTemplate = dataTemplate || {}; + this.dataCalendar = dataCalendar; + + var self = this; + this._super(parent, { + title: options.title, + size: 'small', + buttons: this._buttons ? [ + {text: _t("Create"), classes: 'btn-primary', click: function () { + if (!self._quickAdd(dataCalendar)) { + self.focus(); + } + }}, + {text: _t("Edit"), click: function () { + dataCalendar.disableQuickCreate = true; + dataCalendar.title = self.$('input').val().trim(); + dataCalendar.on_save = self.destroy.bind(self); + self.trigger_up('openCreate', dataCalendar); + }}, + {text: _t("Cancel"), close: true}, + ] : [], + $content: QWeb.render('CalendarView.quick_create', {widget: this}) + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + focus: function () { + this.$('input').focus(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Gathers data from the quick create dialog a launch quick_create(data) method + */ + _quickAdd: function (dataCalendar) { + dataCalendar = $.extend({}, this.dataTemplate, dataCalendar); + var val = this.$('input').val().trim(); + if (!val) { + this.$('label, input').addClass('o_field_invalid'); + var warnings = _.str.sprintf('<ul><li>%s</li></ul>', _t("Summary")); + this.do_warn(_t("Invalid fields:"), warnings); + } + dataCalendar.title = val; + return (val)? this.trigger_up('quickCreate', {data: dataCalendar, options: this.options}) : false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {keyEvent} event + */ + _onkeyup: function (event) { + if (this._flagEnter) { + return; + } + if(event.keyCode === $.ui.keyCode.ENTER) { + this._flagEnter = true; + if (!this._quickAdd(this.dataCalendar)){ + this._flagEnter = false; + } + } else if (event.keyCode === $.ui.keyCode.ESCAPE && this._buttons) { + this.close(); + } + }, +}); + +return QuickCreate; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js new file mode 100644 index 00000000..4ab750f6 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -0,0 +1,1006 @@ +odoo.define('web.CalendarRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var CalendarPopover = require('web.CalendarPopover'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var field_utils = require('web.field_utils'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var relational_fields = require('web.relational_fields'); +var session = require('web.session'); +var Widget = require('web.Widget'); +const { createYearCalendarView } = require('/web/static/src/js/libs/fullcalendar.js'); + +var _t = core._t; +var qweb = core.qweb; + +var SidebarFilterM2O = relational_fields.FieldMany2One.extend({ + _getSearchBlacklist: function () { + return this._super.apply(this, arguments).concat(this.filter_ids || []); + }, +}); + +var SidebarFilter = Widget.extend(FieldManagerMixin, { + template: 'CalendarView.sidebar.filter', + custom_events: _.extend({}, FieldManagerMixin.custom_events, { + field_changed: '_onFieldChanged', + }), + /** + * @constructor + * @param {Widget} parent + * @param {Object} options + * @param {string} options.fieldName + * @param {Object[]} options.filters A filter is an object with the + * following keys: id, value, label, active, avatar_model, color, + * can_be_removed + * @param {Object} [options.favorite] this is an object with the following + * keys: fieldName, model, fieldModel + */ + init: function (parent, options) { + this._super.apply(this, arguments); + FieldManagerMixin.init.call(this); + + this.title = options.title; + this.fields = options.fields; + this.fieldName = options.fieldName; + this.write_model = options.write_model; + this.write_field = options.write_field; + this.avatar_field = options.avatar_field; + this.avatar_model = options.avatar_model; + this.filters = options.filters; + this.label = options.label; + this.getColor = options.getColor; + }, + /** + * @override + */ + willStart: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + + if (this.write_model || this.write_field) { + var def = this.model.makeRecord(this.write_model, [{ + name: this.write_field, + relation: this.fields[this.fieldName].relation, + type: 'many2one', + }]).then(function (recordID) { + self.many2one = new SidebarFilterM2O(self, + self.write_field, + self.model.get(recordID), + { + mode: 'edit', + attrs: { + string: _t(self.fields[self.fieldName].string), + placeholder: "+ " + _.str.sprintf(_t("Add %s"), self.title), + can_create: false + }, + }); + }); + defs.push(def); + } + return Promise.all(defs); + + }, + /** + * @override + */ + start: function () { + this._super(); + if (this.many2one) { + this.many2one.appendTo(this.$el); + this.many2one.filter_ids = _.without(_.pluck(this.filters, 'value'), 'all'); + } + this.$el.on('click', '.o_remove', this._onFilterRemove.bind(this)); + this.$el.on('click', '.o_calendar_filter_items input', this._onFilterActive.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var self = this; + event.stopPropagation(); + var createValues = {'user_id': session.uid}; + var value = event.data.changes[this.write_field].id; + createValues[this.write_field] = value; + this._rpc({ + model: this.write_model, + method: 'create', + args: [createValues], + }) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'value': value, + 'active': true, + }); + }); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onFilterActive: function (e) { + var $input = $(e.currentTarget); + this.trigger_up('changeFilter', { + 'fieldName': this.fieldName, + 'value': $input.closest('.o_calendar_filter_item').data('value'), + 'active': $input.prop('checked'), + }); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onFilterRemove: function (e) { + var self = this; + var $filter = $(e.currentTarget).closest('.o_calendar_filter_item'); + Dialog.confirm(this, _t("Do you really want to delete this filter from favorites ?"), { + confirm_callback: function () { + self._rpc({ + model: self.write_model, + method: 'unlink', + args: [[$filter.data('id')]], + }) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'id': $filter.data('id'), + 'active': false, + 'value': $filter.data('value'), + }); + }); + }, + }); + }, +}); + +return AbstractRenderer.extend({ + template: "CalendarView", + config: { + CalendarPopover: CalendarPopover, + }, + custom_events: _.extend({}, AbstractRenderer.prototype.custom_events || {}, { + edit_event: '_onEditEvent', + delete_event: '_onDeleteEvent', + }), + + /** + * @constructor + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.displayFields = params.displayFields; + this.model = params.model; + this.filters = []; + this.color_map = {}; + this.hideDate = params.hideDate; + this.hideTime = params.hideTime; + this.canDelete = params.canDelete; + this.canCreate = params.canCreate; + this.scalesInfo = params.scalesInfo; + this._isInDOM = false; + }, + /** + * @override + * @returns {Promise} + */ + start: function () { + this._initSidebar(); + this._initCalendar(); + return this._super(); + }, + /** + * @override + */ + on_attach_callback: function () { + this._super(...arguments); + this._isInDOM = true; + // BUG Test ???? + // this.$el.height($(window).height() - this.$el.offset().top); + this.calendar.render(); + this._renderCalendar(); + window.addEventListener('click', this._onWindowClick.bind(this)); + }, + /** + * Called when the field is detached from the DOM. + */ + on_detach_callback: function () { + this._super(...arguments); + this._isInDOM = false; + window.removeEventListener('click', this._onWindowClick); + }, + /** + * @override + */ + destroy: function () { + if (this.calendar) { + this.calendar.destroy(); + } + if (this.$small_calendar) { + this.$small_calendar.datepicker('destroy'); + $('#ui-datepicker-div:empty').remove(); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Note: this is not dead code, it is called by the calendar-box template + * + * @param {any} record + * @param {any} fieldName + * @param {any} imageField + * @returns {string[]} + */ + getAvatars: function (record, fieldName, imageField) { + var field = this.state.fields[fieldName]; + + if (!record[fieldName]) { + return []; + } + if (field.type === 'one2many' || field.type === 'many2many') { + return _.map(record[fieldName], function (id) { + return '<img src="/web/image/'+field.relation+'/'+id+'/'+imageField+'" />'; + }); + } else if (field.type === 'many2one') { + return ['<img src="/web/image/'+field.relation+'/'+record[fieldName][0]+'/'+imageField+'" />']; + } else { + var value = this._format(record, fieldName); + var color = this.getColor(value); + if (isNaN(color)) { + return ['<span class="o_avatar_square" style="background-color:'+color+';"/>']; + } + else { + return ['<span class="o_avatar_square o_calendar_color_'+color+'"/>']; + } + } + }, + /** + * Note: this is not dead code, it is called by two template + * + * @param {any} key + * @returns {integer} + */ + getColor: function (key) { + if (!key) { + return; + } + if (this.color_map[key]) { + return this.color_map[key]; + } + // check if the key is a css color + if (typeof key === 'string' && key.match(/^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i)) { + return this.color_map[key] = key; + } + if (typeof key === 'number' && !(key in this.color_map)) { + return this.color_map[key] = key; + } + var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1; + this.color_map[key] = index; + return index; + }, + /** + * @override + */ + getLocalState: function () { + var fcScroller = this.calendarElement.querySelector('.fc-scroller'); + return { + scrollPosition: fcScroller.scrollTop, + }; + }, + /** + * @override + */ + setLocalState: function (localState) { + if (localState.scrollPosition) { + var fcScroller = this.calendarElement.querySelector('.fc-scroller'); + fcScroller.scrollTop = localState.scrollPosition; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Convert the new format of Event from FullCalendar V4 to a Event FullCalendar V3 + * @param fc4Event + * @return {Object} FullCalendar V3 Object Event + * @private + */ + _convertEventToFC3Event: function (fc4Event) { + var event = fc4Event; + if (!moment.isMoment(fc4Event.start)) { + event = { + id: fc4Event.id, + title: fc4Event.title, + start: moment(fc4Event.start).utcOffset(0, true), + end: fc4Event.end && moment(fc4Event.end).utcOffset(0, true), + allDay: fc4Event.allDay, + color: fc4Event.color, + }; + if (fc4Event.extendedProps) { + event = Object.assign({}, event, { + r_start: fc4Event.extendedProps.r_start && moment(fc4Event.extendedProps.r_start).utcOffset(0, true), + r_end: fc4Event.extendedProps.r_end && moment(fc4Event.extendedProps.r_end).utcOffset(0, true), + record: fc4Event.extendedProps.record, + attendees: fc4Event.extendedProps.attendees, + }); + } + } + return event; + }, + /** + * @param {any} event + * @returns {string} the html for the rendered event + */ + _eventRender: function (event) { + var qweb_context = { + event: event, + record: event.extendedProps.record, + color: this.getColor(event.extendedProps.color_index), + showTime: !self.hideTime && event.extendedProps.showTime, + }; + this.qweb_context = qweb_context; + if (_.isEmpty(qweb_context.record)) { + return ''; + } else { + return qweb.render("calendar-box", qweb_context); + } + }, + /** + * @private + * @param {any} record + * @param {any} fieldName + * @returns {string} + */ + _format: function (record, fieldName) { + var field = this.state.fields[fieldName]; + if (field.type === "one2many" || field.type === "many2many") { + return field_utils.format[field.type]({data: record[fieldName]}, field); + } else { + return field_utils.format[field.type](record[fieldName], field, {forceString: true}); + } + }, + /** + * Return the Object options for FullCalendar + * + * @private + * @param {Object} fcOptions + * @return {Object} + */ + _getFullCalendarOptions: function (fcOptions) { + var self = this; + const options = Object.assign({}, this.state.fc_options, { + plugins: [ + 'moment', + 'interaction', + 'dayGrid', + 'timeGrid' + ], + eventDrop: function (eventDropInfo) { + var event = self._convertEventToFC3Event(eventDropInfo.event); + self.trigger_up('dropRecord', event); + }, + eventResize: function (eventResizeInfo) { + self._unselectEvent(); + var event = self._convertEventToFC3Event(eventResizeInfo.event); + self.trigger_up('updateRecord', event); + }, + eventClick: function (eventClickInfo) { + eventClickInfo.jsEvent.preventDefault(); + eventClickInfo.jsEvent.stopPropagation(); + var eventData = eventClickInfo.event; + self._unselectEvent(); + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', eventData.id)).addClass('o_cw_custom_highlight'); + self._renderEventPopover(eventData, $(eventClickInfo.el)); + }, + yearDateClick: function (info) { + self._unselectEvent(); + info.view.unselect(); + if (!info.events.length) { + if (info.selectable) { + const data = { + start: info.date, + allDay: true, + }; + if (self.state.context.default_name) { + data.title = self.state.context.default_name; + } + self.trigger_up('openCreate', self._convertEventToFC3Event(data)); + } + } else { + self._renderYearEventPopover(info.date, info.events, $(info.dayEl)); + } + }, + select: function (selectionInfo) { + // Clicking on the view, dispose any visible popover. Otherwise create a new event. + if (self.$('.o_cw_popover').length) { + self._unselectEvent(); + } + var data = {start: selectionInfo.start, end: selectionInfo.end, allDay: selectionInfo.allDay}; + if (self.state.context.default_name) { + data.title = self.state.context.default_name; + } + self.trigger_up('openCreate', self._convertEventToFC3Event(data)); + if (self.state.scale === 'year') { + self.calendar.view.unselect(); + } else { + self.calendar.unselect(); + } + }, + eventRender: function (info) { + var event = info.event; + var element = $(info.el); + var view = info.view; + element.attr('data-event-id', event.id); + if (view.type === 'dayGridYear') { + const color = this.getColor(event.extendedProps.color_index); + if (typeof color === 'string') { + element.css({ + backgroundColor: color, + }); + } else if (typeof color === 'number') { + element.addClass(`o_calendar_color_${color}`); + } else { + element.addClass('o_calendar_color_1'); + } + } else { + var $render = $(self._eventRender(event)); + element.find('.fc-content').html($render.html()); + element.addClass($render.attr('class')); + + // Add background if doesn't exist + if (!element.find('.fc-bg').length) { + element.find('.fc-content').after($('<div/>', {class: 'fc-bg'})); + } + + if (view.type === 'dayGridMonth' && event.extendedProps.record) { + var start = event.extendedProps.r_start || event.start; + var end = event.extendedProps.r_end || event.end; + // Detect if the event occurs in just one day + // note: add & remove 1 min to avoid issues with 00:00 + var isSameDayEvent = moment(start).clone().add(1, 'minute').isSame(moment(end).clone().subtract(1, 'minute'), 'day'); + if (!event.extendedProps.record.allday && isSameDayEvent) { + // For month view: do not show background for non allday, single day events + element.addClass('o_cw_nobg'); + if (event.extendedProps.showTime && !self.hideTime) { + const displayTime = moment(start).clone().format(self._getDbTimeFormat()); + element.find('.fc-content .fc-time').text(displayTime); + } + } + } + + // On double click, edit the event + element.on('dblclick', function () { + self.trigger_up('edit_event', {id: event.id}); + }); + } + }, + datesRender: function (info) { + const viewToMode = Object.fromEntries( + Object.entries(self.scalesInfo).map(([k, v]) => [v, k]) + ); + self.trigger_up('viewUpdated', { + mode: viewToMode[info.view.type], + title: info.view.title, + }); + }, + // Add/Remove a class on hover to style multiple days events. + // The css ":hover" selector can't be used because these events + // are rendered using multiple elements. + eventMouseEnter: function (mouseEnterInfo) { + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseEnterInfo.event.id)).addClass('o_cw_custom_hover'); + }, + eventMouseLeave: function (mouseLeaveInfo) { + if (!mouseLeaveInfo.event.id) { + return; + } + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseLeaveInfo.event.id)).removeClass('o_cw_custom_hover'); + }, + eventDragStart: function (mouseDragInfo) { + mouseDragInfo.el.classList.add(mouseDragInfo.view.type); + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseDragInfo.event.id)).addClass('o_cw_custom_hover'); + self._unselectEvent(); + }, + eventResizeStart: function (mouseResizeInfo) { + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseResizeInfo.event.id)).addClass('o_cw_custom_hover'); + self._unselectEvent(); + }, + eventLimitClick: function () { + self._unselectEvent(); + return 'popover'; + }, + windowResize: function () { + self._render(); + }, + views: { + timeGridDay: { + columnHeaderFormat: 'LL' + }, + timeGridWeek: { + columnHeaderFormat: 'ddd D' + }, + dayGridMonth: { + columnHeaderFormat: 'dddd' + } + }, + height: 'parent', + unselectAuto: false, + dir: _t.database.parameters.direction, + events: (info, successCB) => { + successCB(self.state.data); + }, + }, fcOptions); + options.plugins.push(createYearCalendarView(FullCalendar, options)); + return options; + }, + /** + * Initialize the main calendar + * + * @private + */ + _initCalendar: function () { + this.calendarElement = this.$(".o_calendar_widget")[0]; + var locale = moment.locale(); + + var fcOptions = this._getFullCalendarOptions({ + locale: locale, // reset locale when fullcalendar has already been instanciated before now + }); + + this.calendar = new FullCalendar.Calendar(this.calendarElement, fcOptions); + }, + /** + * Initialize the mini calendar in the sidebar + * + * @private + */ + _initCalendarMini: function () { + var self = this; + this.$small_calendar = this.$(".o_calendar_mini"); + this.$small_calendar.datepicker({ + 'onSelect': function (datum, obj) { + self.trigger_up('changeDate', { + date: moment(new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay)) + }); + }, + 'showOtherMonths': true, + 'dayNamesMin' : this.state.fc_options.dayNamesShort.map(x => x[0]), + 'monthNames': this.state.fc_options.monthNamesShort, + 'firstDay': this.state.fc_options.firstDay, + }); + }, + /** + * Initialize the sidebar + * + * @private + */ + _initSidebar: function () { + this.$sidebar = this.$('.o_calendar_sidebar'); + this.$sidebar_container = this.$(".o_calendar_sidebar_container"); + this._initCalendarMini(); + }, + /** + * Finalise the popover + * + * @param {jQueryElement} $popoverElement + * @param {web.CalendarPopover} calendarPopover + * @private + */ + _onPopoverShown: function ($popoverElement, calendarPopover) { + var $popover = $($popoverElement.data('bs.popover').tip); + $popover.find('.o_cw_popover_close').on('click', this._unselectEvent.bind(this)); + $popover.find('.o_cw_body').replaceWith(calendarPopover.$el); + }, + /** + * Render the calendar view, this is the main entry point. + * + * @override + */ + async _renderView() { + this.$('.o_calendar_view')[0].prepend(this.calendarElement); + if (this._isInDOM) { + this._renderCalendar(); + } + this.$small_calendar.datepicker("setDate", this.state.highlight_date.toDate()) + .find('.o_selected_range') + .removeClass('o_color o_selected_range'); + var $a; + switch (this.state.scale) { + case 'year': $a = this.$small_calendar.find('td'); break; + case 'month': $a = this.$small_calendar.find('td'); break; + case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active)'); break; + case 'day': $a = this.$small_calendar.find('a.ui-state-active'); break; + } + $a.addClass('o_selected_range'); + setTimeout(function () { + $a.not('.ui-state-active').addClass('o_color'); + }); + + await this._renderFilters(); + }, + /** + * Render the specific code for the FullCalendar when it's in the DOM + * + * @private + */ + _renderCalendar() { + this.calendar.unselect(); + + if (this.scalesInfo[this.state.scale] !== this.calendar.view.type) { + this.calendar.changeView(this.scalesInfo[this.state.scale]); + } + + if (this.target_date !== this.state.target_date.toString()) { + this.calendar.gotoDate(moment(this.state.target_date).toDate()); + this.target_date = this.state.target_date.toString(); + } else { + // this.calendar.gotoDate already renders events when called + // so render events only when domain changes + this._renderEvents(); + } + + this._unselectEvent(); + // this._scrollToScrollTime(); + }, + /** + * Render all events + * + * @private + */ + _renderEvents: function () { + this.calendar.refetchEvents(); + }, + /** + * Render all filters + * + * @private + * @returns {Promise} resolved when all filters have been rendered + */ + _renderFilters: function () { + // Dispose of filter popover + this.$('.o_calendar_filter_item').popover('dispose'); + _.each(this.filters || (this.filters = []), function (filter) { + filter.destroy(); + }); + if (this.state.fullWidth) { + return Promise.resolve(); + } + return this._renderFiltersOneByOne(); + }, + /** + * Renders each filter one by one, waiting for the first filter finished to + * be rendered and appended to render the next one. + * We need to do like this since render a filter is asynchronous, we don't + * know which one will be appened at first and we want tp force them to be + * rendered in order. + * + * @param {number} filterIndex if not set, 0 by default + * @returns {Promise} resolved when all filters have been rendered + */ + _renderFiltersOneByOne: function (filterIndex) { + filterIndex = filterIndex || 0; + var arrFilters = _.toArray(this.state.filters); + var prom; + if (filterIndex < arrFilters.length) { + var options = arrFilters[filterIndex]; + if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) { + return this._renderFiltersOneByOne(filterIndex + 1); + } + + var self = this; + options.getColor = this.getColor.bind(this); + options.fields = this.state.fields; + var sidebarFilter = new SidebarFilter(self, options); + prom = sidebarFilter.appendTo(this.$sidebar).then(function () { + // Show filter popover + if (options.avatar_field) { + _.each(options.filters, function (filter) { + if (!['all', false].includes(filter.value)) { + var selector = _.str.sprintf('.o_calendar_filter_item[data-value=%s]', filter.value); + sidebarFilter.$el.find(selector).popover({ + animation: false, + trigger: 'hover', + html: true, + placement: 'top', + title: filter.label, + delay: {show: 300, hide: 0}, + content: function () { + return $('<img>', { + src: _.str.sprintf('/web/image/%s/%s/%s', options.avatar_model, filter.value, options.avatar_field), + class: 'mx-auto', + }); + }, + }); + } + }); + } + return self._renderFiltersOneByOne(filterIndex + 1); + }); + this.filters.push(sidebarFilter); + } + return Promise.resolve(prom); + }, + /** + * Returns the time format from database parameters (only hours and minutes). + * FIXME: this looks like a weak heuristic... + * + * @private + * @returns {string} + */ + _getDbTimeFormat: function () { + return _t.database.parameters.time_format.search('%H') !== -1 ? 'HH:mm' : 'hh:mm a'; + }, + /** + * Returns event's formatted date for popovers. + * + * @private + * @param {moment} start + * @param {moment} end + * @param {boolean} showDayName + * @param {boolean} allDay + */ + _getFormattedDate: function (start, end, showDayName, allDay) { + const isSameDayEvent = start.clone().add(1, 'minute') + .isSame(end.clone().subtract(1, 'minute'), 'day'); + if (allDay) { + // cancel correction done in _recordToCalendarEvent + end = end.clone().subtract(1, 'day'); + } + if (!isSameDayEvent && start.isSame(end, 'month')) { + // Simplify date-range if an event occurs into the same month (eg. '4-5 August 2019') + return start.clone().format('MMMM D') + '-' + end.clone().format('D, YYYY'); + } else { + return isSameDayEvent ? + start.clone().format(showDayName ? 'dddd, LL' : 'LL') : + start.clone().format('LL') + ' - ' + end.clone().format('LL'); + } + }, + /** + * Prepare context to display in the popover. + * + * @private + * @param {Object} eventData + * @returns {Object} context + */ + _getPopoverContext: function (eventData) { + var context = { + hideDate: this.hideDate, + hideTime: this.hideTime, + eventTime: {}, + eventDate: {}, + fields: this.state.fields, + displayFields: this.displayFields, + event: eventData, + modelName: this.model, + canDelete: this.canDelete, + }; + + var start = moment((eventData.extendedProps && eventData.extendedProps.r_start) || eventData.start); + var end = moment((eventData.extendedProps && eventData.extendedProps.r_end) || eventData.end); + var isSameDayEvent = start.clone().add(1, 'minute').isSame(end.clone().subtract(1, 'minute'), 'day'); + + // Do not display timing if the event occur across multiple days. Otherwise use user's timing preferences + if (!this.hideTime && !eventData.extendedProps.record.allday && isSameDayEvent) { + var dbTimeFormat = this._getDbTimeFormat(); + + context.eventTime.time = start.clone().format(dbTimeFormat) + ' - ' + end.clone().format(dbTimeFormat); + + // Calculate duration and format text + var durationHours = moment.duration(end.diff(start)).hours(); + var durationHoursKey = (durationHours === 1) ? 'h' : 'hh'; + var durationMinutes = moment.duration(end.diff(start)).minutes(); + var durationMinutesKey = (durationMinutes === 1) ? 'm' : 'mm'; + + var localeData = moment.localeData(); // i18n for 'hours' and "minutes" strings + context.eventTime.duration = (durationHours > 0 ? localeData.relativeTime(durationHours, true, durationHoursKey) : '') + + (durationHours > 0 && durationMinutes > 0 ? ', ' : '') + + (durationMinutes > 0 ? localeData.relativeTime(durationMinutes, true, durationMinutesKey) : ''); + } + + if (!this.hideDate) { + + if (eventData.extendedProps.record.allday && isSameDayEvent) { + context.eventDate.duration = _t("All day"); + } else if (eventData.extendedProps.record.allday && !isSameDayEvent) { + var daysLocaleData = moment.localeData(); + var days = moment.duration(end.diff(start)).days(); + context.eventDate.duration = daysLocaleData.relativeTime(days, true, 'dd'); + } + + context.eventDate.date = this._getFormattedDate(start, end, true, eventData.extendedProps.record.allday); + } + + return context; + }, + /** + * Prepare the parameters for the popover. + * This allow the parameters to be extensible. + * + * @private + * @param {Object} eventData + */ + _getPopoverParams: function (eventData) { + return { + animation: false, + delay: { + show: 50, + hide: 100 + }, + trigger: 'manual', + html: true, + title: eventData.extendedProps.record.display_name, + template: qweb.render('CalendarView.event.popover.placeholder', {color: this.getColor(eventData.extendedProps.color_index)}), + container: eventData.allDay ? '.fc-view' : '.fc-scroller', + } + }, + /** + * Render event popover + * + * @private + * @param {Object} eventData + * @param {jQueryElement} $eventElement + */ + _renderEventPopover: function (eventData, $eventElement) { + var self = this; + + // Initialize popover widget + var calendarPopover = new self.config.CalendarPopover(self, self._getPopoverContext(eventData)); + calendarPopover.appendTo($('<div>')).then(() => { + $eventElement.popover( + self._getPopoverParams(eventData) + ).on('shown.bs.popover', function () { + self._onPopoverShown($(this), calendarPopover); + }).popover('show'); + }); + }, + /** + * Render year event popover + * + * @private + * @param {Date} date + * @param {Object[]} events + * @param {jQueryElement} $el + */ + _renderYearEventPopover: function (date, events, $el) { + const groupKeys = []; + const groupedEvents = {}; + for (const event of events) { + const start = moment(event.extendedProps.r_start); + const end = moment(event.extendedProps.r_end); + const key = this._getFormattedDate(start, end, false, event.extendedProps.record.allday); + if (!(key in groupedEvents)) { + groupedEvents[key] = []; + groupKeys.push({ + key: key, + start: event.extendedProps.r_start, + end: event.extendedProps.r_end, + isSameDayEvent: start.clone().add(1, 'minute') + .isSame(end.clone().subtract(1, 'minute'), 'day'), + }); + } + groupedEvents[key].push(event); + } + + const popoverContent = qweb.render('CalendarView.yearEvent.popover', { + groupedEvents, + groupKeys: groupKeys + .sort((a, b) => { + if (a.isSameDayEvent) { + // if isSameDayEvent then put it before the others + return Number.MIN_SAFE_INTEGER; + } else if (b.isSameDayEvent) { + return Number.MAX_SAFE_INTEGER; + } else if (a.start.getTime() - b.start.getTime() === 0) { + return a.end.getTime() - b.end.getTime(); + } + return a.start.getTime() - b.start.getTime(); + }) + .map(x => x.key), + canCreate: this.canCreate, + }); + + $el.popover({ + animation: false, + delay: { + show: 50, + hide: 100 + }, + trigger: 'manual', + html: true, + content: popoverContent, + template: qweb.render('CalendarView.yearEvent.popover.placeholder'), + container: '.fc-dayGridYear-view', + }).on('shown.bs.popover', () => { + $('.o_cw_popover .o_cw_popover_close').on('click', () => this._unselectEvent()); + $('.o_cw_popover .o_cw_popover_create').on('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._unselectEvent(); + const data = { + start: date, + allDay: true, + }; + if (this.state.context.default_name) { + data.title = this.state.context.default_name; + } + this.trigger_up('openCreate', this._convertEventToFC3Event(data)); + }); + $('.o_cw_popover .o_cw_popover_link').on('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._unselectEvent(); + this.trigger_up('openEvent', { + _id: parseInt(e.target.dataset.id, 10), + title: e.target.dataset.title, + }); + }); + }).popover('show'); + }, + /** + * Scroll to the time set in the FullCalendar parameter + * @private + */ + _scrollToScrollTime: function () { + var scrollTime = this.calendar.getOption('scrollTime'); + this.calendar.scrollToTime(scrollTime); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Remove highlight classes and dispose of popovers + * + * @private + */ + _unselectEvent: function () { + this.$('.fc-event').removeClass('o_cw_custom_highlight'); + this.$('.o_cw_popover').popover('dispose'); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onWindowClick: function (e) { + const popover = this.el.querySelector('.o_cw_popover'); + if (popover && !popover.contains(e.target)) { + this._unselectEvent(); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onEditEvent: function (event) { + this._unselectEvent(); + this.trigger_up('openEvent', { + _id: event.data.id, + title: event.data.title, + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onDeleteEvent: function (event) { + this._unselectEvent(); + this.trigger_up('deleteRecord', {id: parseInt(event.data.id, 10)}); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js new file mode 100644 index 00000000..b83b8072 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -0,0 +1,204 @@ +odoo.define('web.CalendarView', function (require) { +"use strict"; + +var AbstractView = require('web.AbstractView'); +var CalendarModel = require('web.CalendarModel'); +var CalendarController = require('web.CalendarController'); +var CalendarRenderer = require('web.CalendarRenderer'); +var core = require('web.core'); +var pyUtils = require('web.py_utils'); +var utils = require('web.utils'); + +var _lt = core._lt; + +// gather the fields to get +var fieldsToGather = [ + "date_start", + "date_delay", + "date_stop", + "all_day", + "recurrence_update" +]; + +const scalesInfo = { + day: 'timeGridDay', + week: 'timeGridWeek', + month: 'dayGridMonth', + year: 'dayGridYear', +}; + +var CalendarView = AbstractView.extend({ + display_name: _lt('Calendar'), + icon: 'fa-calendar', + jsLibs: [ + '/web/static/lib/fullcalendar/core/main.js', + '/web/static/lib/fullcalendar/interaction/main.js', + '/web/static/lib/fullcalendar/moment/main.js', + '/web/static/lib/fullcalendar/daygrid/main.js', + '/web/static/lib/fullcalendar/timegrid/main.js', + '/web/static/lib/fullcalendar/list/main.js' + ], + cssLibs: [ + '/web/static/lib/fullcalendar/core/main.css', + '/web/static/lib/fullcalendar/daygrid/main.css', + '/web/static/lib/fullcalendar/timegrid/main.css', + '/web/static/lib/fullcalendar/list/main.css' + ], + config: _.extend({}, AbstractView.prototype.config, { + Model: CalendarModel, + Controller: CalendarController, + Renderer: CalendarRenderer, + }), + viewType: 'calendar', + searchMenuTypes: ['filter', 'favorite'], + + /** + * @override + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + var arch = this.arch; + var fields = this.fields; + var attrs = arch.attrs; + + if (!attrs.date_start) { + throw new Error(_lt("Calendar view has not defined 'date_start' attribute.")); + } + + var mapping = {}; + var fieldNames = fields.display_name ? ['display_name'] : []; + var displayFields = {}; + + _.each(fieldsToGather, function (field) { + if (arch.attrs[field]) { + var fieldName = attrs[field]; + mapping[field] = fieldName; + fieldNames.push(fieldName); + } + }); + + var filters = {}; + + var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit); + + var modelFilters = []; + _.each(arch.children, function (child) { + if (child.tag !== 'field') return; + var fieldName = child.attrs.name; + fieldNames.push(fieldName); + if (!child.attrs.invisible || child.attrs.filters) { + child.attrs.options = child.attrs.options ? pyUtils.py_eval(child.attrs.options) : {}; + if (!child.attrs.invisible) { + displayFields[fieldName] = {attrs: child.attrs}; + } + + if (params.sidebar === false) return; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers" + + if (child.attrs.avatar_field) { + filters[fieldName] = filters[fieldName] || { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + filters[fieldName].avatar_field = child.attrs.avatar_field; + filters[fieldName].avatar_model = fields[fieldName].relation; + } + if (child.attrs.write_model) { + filters[fieldName] = filters[fieldName] || { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + filters[fieldName].write_model = child.attrs.write_model; + filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields + + modelFilters.push(fields[fieldName].relation); + } + if (child.attrs.filters) { + filters[fieldName] = filters[fieldName] || { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + if (child.attrs.color) { + filters[fieldName].field_color = child.attrs.color; + filters[fieldName].color_model = fields[fieldName].relation; + } + if (!child.attrs.avatar_field && fields[fieldName].relation) { + if (fields[fieldName].relation.includes(['res.users', 'res.partner', 'hr.employee'])) { + filters[fieldName].avatar_field = 'image_128'; + } + filters[fieldName].avatar_model = fields[fieldName].relation; + } + } + } + }); + + if (attrs.color) { + var fieldName = attrs.color; + fieldNames.push(fieldName); + } + + //if quick_add = False, we don't allow quick_add + //if quick_add = not specified in view, we use the default widgets.QuickCreate + //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate' + this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true)); + this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop; + + // If form_view_id is set, then the calendar view will open a form view + // with this id, when it needs to edit or create an event. + this.controllerParams.formViewId = + attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false; + if (!this.controllerParams.formViewId && params.action) { + var formViewDescr = _.find(params.action.views, function (v) { + return v.type === 'form'; + }); + if (formViewDescr) { + this.controllerParams.formViewId = formViewDescr.viewID; + } + } + + let scales; + const allowedScales = Object.keys(scalesInfo); + if (arch.attrs.scales) { + scales = arch.attrs.scales.split(',') + .filter(x => allowedScales.includes(x)); + } else { + scales = allowedScales; + } + + this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false); + this.controllerParams.showUnusualDays = utils.toBoolElse(attrs.show_unusual_days || '', false); + this.controllerParams.mapping = mapping; + this.controllerParams.context = params.context || {}; + this.controllerParams.displayName = params.action && params.action.name; + this.controllerParams.scales = scales; + + this.rendererParams.displayFields = displayFields; + this.rendererParams.model = viewInfo.model; + this.rendererParams.hideDate = utils.toBoolElse(attrs.hide_date || '', false); + this.rendererParams.hideTime = utils.toBoolElse(attrs.hide_time || '', false); + this.rendererParams.canDelete = this.controllerParams.activeActions.delete; + this.rendererParams.canCreate = this.controllerParams.activeActions.create; + this.rendererParams.scalesInfo = scalesInfo; + + this.loadParams.fieldNames = _.uniq(fieldNames); + this.loadParams.mapping = mapping; + this.loadParams.fields = fields; + this.loadParams.fieldsInfo = viewInfo.fieldsInfo; + this.loadParams.editable = !fields[mapping.date_start].readonly; + this.loadParams.creatable = this.controllerParams.activeActions.create; + this.loadParams.eventLimit = eventLimit; + this.loadParams.fieldColor = attrs.color; + + this.loadParams.filters = filters; + this.loadParams.mode = (params.context && params.context.default_mode) || attrs.mode; + this.loadParams.scales = scales; + this.loadParams.initialDate = moment(params.initialDate || new Date()); + this.loadParams.scalesInfo = scalesInfo; + }, +}); + +return CalendarView; + +}); diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js new file mode 100644 index 00000000..aab8dd14 --- /dev/null +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -0,0 +1,166 @@ +odoo.define('web.FieldManagerMixin', function (require) { +"use strict"; + +/** + * The FieldManagerMixin is a mixin, designed to do the plumbing between field + * widgets and a basicmodel. Field widgets can be used outside of a view. In + * that case, someone needs to listen to events bubbling up from the widgets and + * calling the correct methods on the model. This is the field_manager's job. + */ + +var BasicModel = require('web.BasicModel'); +var concurrency = require('web.concurrency'); + +var FieldManagerMixin = { + custom_events: { + field_changed: '_onFieldChanged', + load: '_onLoad', + mutexify: '_onMutexify', + }, + /** + * A FieldManagerMixin can be initialized with an instance of a basicModel. + * If not, it will simply uses its own. + * + * @param {BasicModel} [model] + */ + init: function (model) { + this.model = model || new BasicModel(this); + this.mutex = new concurrency.Mutex(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply changes by notifying the basic model, then saving the data if + * necessary, and finally, confirming the changes to the UI. + * + * @todo find a way to remove ugly 3rd argument... + * + * @param {string} dataPointID + * @param {Object} changes + * @param {OdooEvent} event + * @returns {Promise} resolves when the change has been done, and the UI + * updated + */ + _applyChanges: function (dataPointID, changes, event) { + var self = this; + var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning'); + return this.model.notifyChanges(dataPointID, changes, options) + .then(function (result) { + if (event.data.force_save) { + return self.model.save(dataPointID).then(function () { + return self._confirmSave(dataPointID); + }).guardedCatch(function () { + return self._rejectSave(dataPointID); + }); + } else if (options.notifyChange !== false) { + return self._confirmChange(dataPointID, result, event); + } + }); + }, + /** + * This method will be called whenever a field value has changed (and has + * been confirmed by the model). + * + * @abstract + * @param {string} id basicModel Id for the changed record + * @param {string[]} fields the fields (names) that have been changed + * @param {OdooEvent} event the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, event) { + return Promise.resolve(); + }, + /** + * This method will be called whenever a save has been triggered by a change + * in some controlled field value. For example, when a priority widget is + * being changed in a readonly form. + * + * @see _onFieldChanged + * @abstract + * @param {string} id The basicModel ID for the saved record + * @returns {Promise} + */ + _confirmSave: function (id) { + return Promise.resolve(); + }, + /** + * This method will be called whenever a save has been triggered by a change + * and has failed. For example, when a statusbar button is clicked in a + * readonly form view. + * + * @abstract + * @private + * @param {string} id The basicModel ID for the saved record + * @returns {Deferred} + */ + _rejectSave: function (id) { + return Promise.resolve(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This is the main job of the FMM: deciding what to do when a controlled + * field changes. Most of the time, it notifies the model that a change + * just occurred, then confirm the change. + * + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + // in case of field changed in relational record (e.g. in the form view + // of a one2many subrecord), the field_changed event must be stopped as + // soon as is it handled by a field_manager (i.e. the one of the + // subrecord's form view), otherwise it bubbles up to the main form view + // but its model doesn't have any data related to the given dataPointID + event.stopPropagation(); + return this._applyChanges(event.data.dataPointID, event.data.changes, event) + .then(event.data.onSuccess || function () {}) + .guardedCatch(event.data.onFailure || function () {}); + }, + /** + * Some widgets need to trigger a reload of their data. For example, a + * one2many with a pager needs to be able to fetch the next page. To do + * that, it can trigger a load event. This will then ask the model to + * actually reload the data, then call the on_success callback. + * + * @param {OdooEvent} event + * @param {number} [event.data.limit] + * @param {number} [event.data.offset] + * @param {function} [event.data.on_success] callback + */ + _onLoad: function (event) { + var self = this; + event.stopPropagation(); // prevent other field managers from handling this request + var data = event.data; + if (!data.on_success) { return; } + var params = {}; + if ('limit' in data) { + params.limit = data.limit; + } + if ('offset' in data) { + params.offset = data.offset; + } + this.mutex.exec(function () { + return self.model.reload(data.id, params).then(function (db_id) { + data.on_success(self.model.get(db_id)); + }); + }); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.action the function to execute in the mutex + */ + _onMutexify: function (ev) { + ev.stopPropagation(); // prevent other field managers from handling this request + this.mutex.exec(ev.data.action); + }, +}; + +return FieldManagerMixin; +}); diff --git a/addons/web/static/src/js/views/file_upload_mixin.js b/addons/web/static/src/js/views/file_upload_mixin.js new file mode 100644 index 00000000..84dddcd9 --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_mixin.js @@ -0,0 +1,234 @@ +odoo.define('web.fileUploadMixin', function (require) { +'use strict'; + +/** + * Mixin to be used in view Controllers to manage uploads and generate progress bars. + * supported views: kanban, list + */ + +const { csrf_token, _t } = require('web.core'); +const ProgressBar = require('web.ProgressBar'); +const ProgressCard = require('web.ProgressCard'); + +const ProgressBarMixin = { + + custom_events: { + progress_bar_abort: '_onProgressBarAbort', + }, + + init() { + /** + * Contains the uploads currently happening, used to attach progress bars. + * e.g: {'fileUploadId45': {progressBar, progressCard, ...params}} + */ + this._fileUploads = {}; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * used to use a mocked version of Xhr in the tests. + * + * @private + * @returns {XMLHttpRequest} + */ + _createXhr() { + return new window.XMLHttpRequest(); + }, + /** + * @private + */ + _getFileUploadRenderOptions() { + return { + predicate: () => true, + targetCallback: undefined, + }; + }, + /** + * @private + * @returns {string} upload route + */ + _getFileUploadRoute() { + return '/web/binary/upload_attachment'; + }, + /** + * @private + * @param {Object} params + * @param {Object[]} params.files + * @param {XMLHttpRequest} params.xhr + */ + _makeFileUpload(params) { + const { files, xhr } = params; + const fileUploadId = _.uniqueId('fileUploadId'); + const formData = new FormData(); + const formDataKeys = this._makeFileUploadFormDataKeys(Object.assign({ fileUploadId }, params)); + + formData.append('csrf_token', csrf_token); + for (const key in formDataKeys) { + if (formDataKeys[key] !== undefined) { + formData.append(key, formDataKeys[key]); + } + } + for (const file of files) { + formData.append('ufile', file); + } + + return { + fileUploadId, + xhr, + title: files.length === 1 + ? files[0].name + : _.str.sprintf(_t("%s Files"), files.length), + type: files.length === 1 ? files[0].type : undefined, + formData, + }; + }, + /** + * @private + * @param {Object} param0 + * @param {string} param0.fileUploadId + * @returns {Object} the list of the form entries of a file upload. + */ + _makeFileUploadFormDataKeys({ fileUploadId }) { + return { + callback: fileUploadId, + }; + }, + /** + * @private + * @param {integer} fileUploadId + */ + async _removeFileUpload(fileUploadId) { + const upload = this._fileUploads[fileUploadId]; + upload.progressCard && upload.progressCard.destroy(); + upload.progressBar && upload.progressBar.destroy(); + delete this._fileUploads[fileUploadId]; + await this.reload(); + }, + /** + * @private + */ + async _renderFileUploads() { + const { predicate, targetCallback } = this._getFileUploadRenderOptions(); + + for (const fileUploadId in this._fileUploads) { + const upload = this._fileUploads[fileUploadId]; + if (!predicate(upload)) { + continue; + } + + if (!upload.progressBar) { + if (!upload.recordId || this.viewType !== 'kanban') { + upload.progressCard = new ProgressCard(this, { + title: upload.title, + type: upload.type, + viewType: this.viewType, + }); + } + upload.progressBar = new ProgressBar(this, { + xhr: upload.xhr, + title: upload.title, + fileUploadId, + }); + } + + let $targetCard; + if (upload.progressCard) { + await upload.progressCard.prependTo(this.renderer.$el); + $targetCard = upload.progressCard.$el; + } else if (targetCallback) { + $targetCard = targetCallback(upload); + } + await upload.progressBar.appendTo($targetCard); + } + }, + /** + * @private + * @param {Object[]} files + * @param {Object} [params] optional additional data + */ + async _uploadFiles(files, params={}) { + if (!files || !files.length) { return; } + + await new Promise(resolve => { + const xhr = this._createXhr(); + xhr.open('POST', this._getFileUploadRoute()); + const fileUploadData = this._makeFileUpload(Object.assign({ files, xhr }, params)); + const { fileUploadId, formData } = fileUploadData; + this._fileUploads[fileUploadId] = fileUploadData; + xhr.upload.addEventListener("progress", ev => { + this._updateFileUploadProgress(fileUploadId, ev); + }); + const progressPromise = this._onBeforeUpload(); + xhr.onload = async () => { + await progressPromise; + resolve(); + this._onUploadLoad({ fileUploadId, xhr }); + }; + xhr.onerror = async () => { + await progressPromise; + resolve(); + this._onUploadError({ fileUploadId, xhr }); + }; + xhr.send(formData); + }); + }, + /** + * @private + * @param {string} fileUploadId + * @param {ProgressEvent} ev + */ + _updateFileUploadProgress(fileUploadId, ev) { + const { progressCard, progressBar } = this._fileUploads[fileUploadId]; + progressCard && progressCard.update(ev.loaded, ev.total); + progressBar && progressBar.update(ev.loaded, ev.total); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Hook to customize the behaviour of _uploadFiles() before an upload is made. + * + * @private + */ + async _onBeforeUpload() { + await this._renderFileUploads(); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {integer} ev.data.fileUploadId + */ + _onProgressBarAbort(ev) { + this._removeFileUpload(ev.data.fileUploadId); + }, + /** + * Hook to customize the behaviour of the xhr.onload of an upload. + * + * @private + * @param {string} param0.fileUploadId + */ + _onUploadLoad({ fileUploadId }) { + this._removeFileUpload(fileUploadId); + }, + /** + * Hook to customize the behaviour of the xhr.onerror of an upload. + * + * @private + * @param {string} param1.fileUploadId + * @param {XMLHttpRequest} param0.xhr + */ + _onUploadError({ fileUploadId, xhr }) { + this.do_notify(xhr.status, _.str.sprintf(_t('message: %s'), xhr.reponseText), true); + this._removeFileUpload(fileUploadId); + }, + +}; + +return ProgressBarMixin; + +}); diff --git a/addons/web/static/src/js/views/file_upload_progress_bar.js b/addons/web/static/src/js/views/file_upload_progress_bar.js new file mode 100644 index 00000000..6c938e2d --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_progress_bar.js @@ -0,0 +1,76 @@ +odoo.define('web.ProgressBar', function (require) { +'use strict'; + +const { _t } = require('web.core'); +const Dialog = require('web.Dialog'); +const Widget = require('web.Widget'); + +const ProgressBar = Widget.extend({ + template: 'web.FileUploadProgressBar', + + events: { + 'click .o_upload_cross': '_onClickCross', + }, + + /** + * @override + * @param {Object} param1 + * @param {String} param1.title + * @param {String} param1.fileUploadId + * @param {XMLHttpRequest} param2.xhr + */ + init(parent, { title, fileUploadId, xhr }) { + this._super(...arguments); + this.title = title; + this.fileUploadId = fileUploadId; + this.xhr = xhr; + }, + + /** + * @override + * @return {Promise} + */ + start() { + this.xhr.onabort = () => this.do_notify(false, _t("Upload cancelled")); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {integer} loaded + * @param {integer} total + */ + update(loaded, total) { + if (!this.$el) { + return; + } + const percent = Math.round((loaded / total) * 100); + this.$('.o_file_upload_progress_bar_value').css("width", percent + "%"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickCross(ev) { + ev.stopPropagation(); + const promptText = _.str.sprintf(_t("Do you really want to cancel the upload of %s?"), _.escape(this.title)); + Dialog.confirm(this, promptText, { + confirm_callback: () => { + this.xhr.abort(); + this.trigger_up('progress_bar_abort', { fileUploadId: this.fileUploadId }); + } + }); + }, +}); + +return ProgressBar; + +}); diff --git a/addons/web/static/src/js/views/file_upload_progress_card.js b/addons/web/static/src/js/views/file_upload_progress_card.js new file mode 100644 index 00000000..1f1db52e --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_progress_card.js @@ -0,0 +1,52 @@ +odoo.define('web.ProgressCard', function (require) { +'use strict'; + +const { _t } = require('web.core'); +const Widget = require('web.Widget'); + +const ProgressCard = Widget.extend({ + template: 'web.ProgressCard', + + /** + * @override + * @param {Object} param1 + * @param {String} param1.title + * @param {String} param1.type file mimetype + * @param {String} param1.viewType + */ + init(parent, { title, type, viewType }) { + this._super(...arguments); + this.title = title; + this.type = type; + this.viewType = viewType; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {integer} loaded + * @param {integer} total + */ + update(loaded, total) { + if (!this.$el) { + return; + } + const percent = Math.round((loaded / total) * 100); + const $textDivLeft = this.$('.o_file_upload_progress_text_left'); + const $textDivRight = this.$('.o_file_upload_progress_text_right'); + if (percent === 100) { + $textDivLeft.text(_t('Processing...')); + } else { + const mbLoaded = Math.round(loaded/1000000); + const mbTotal = Math.round(total/1000000); + $textDivLeft.text(_.str.sprintf(_t("Uploading... (%s%%)"), percent)); + $textDivRight.text(_.str.sprintf(_t("(%s/%sMb)"), mbLoaded, mbTotal)); + } + }, +}); + +return ProgressCard; + +}); diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js new file mode 100644 index 00000000..323f7a75 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -0,0 +1,691 @@ +odoo.define('web.FormController', function (require) { +"use strict"; + +var BasicController = require('web.BasicController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); + +var _t = core._t; +var qweb = core.qweb; + +var FormController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + button_clicked: '_onButtonClicked', + edited_list: '_onEditedList', + open_one2many_record: '_onOpenOne2ManyRecord', + open_record: '_onOpenRecord', + toggle_column_order: '_onToggleColumnOrder', + focus_control_button: '_onFocusControlButton', + form_dialog_discarded: '_onFormDialogDiscarded', + }), + /** + * @override + * + * @param {boolean} params.hasActionMenus + * @param {Object} params.toolbarActions + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.actionButtons = params.actionButtons; + this.disableAutofocus = params.disableAutofocus; + this.footerToButtons = params.footerToButtons; + this.defaultButtons = params.defaultButtons; + this.hasActionMenus = params.hasActionMenus; + this.toolbarActions = params.toolbarActions || {}; + }, + /** + * Called each time the form view is attached into the DOM + * + * @todo convert to new style + */ + on_attach_callback: function () { + this._super.apply(this, arguments); + this.autofocus(); + }, + /** + * This hook is called when a form view is restored (by clicking on the + * breadcrumbs). In general, we force mode back to readonly, because + * whenever we leave a form view by stacking another action on the top of + * it, it is saved, and should no longer be in edit mode. However, there is + * a special case for new records for which we still want to be in 'edit' + * as no record has been created (changes have been discarded before + * leaving). + * + * @override + */ + willRestore: function () { + this.mode = this.model.isNew(this.handle) ? 'edit' : 'readonly'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Calls autofocus on the renderer + */ + autofocus: function () { + if (!this.disableAutofocus) { + var isControlActivted = this.renderer.autofocus(); + if (!isControlActivted) { + // this can happen in read mode if there are no buttons with + // btn-primary class + if (this.$buttons && this.mode === 'readonly') { + return this.$buttons.find('.o_form_button_edit').focus(); + } + } + } + }, + /** + * This method switches the form view in edit mode, with a new record. + * + * @todo make record creation a basic controller feature + * @param {string} [parentID] if given, the parentID will be used as parent + * for the new record. + * @param {Object} [additionalContext] + * @returns {Promise} + */ + createRecord: async function (parentID, additionalContext) { + const record = this.model.get(this.handle, { raw: true }); + const handle = await this.model.load({ + context: record.getContext({ additionalContext: additionalContext}), + fields: record.fields, + fieldsInfo: record.fieldsInfo, + modelName: this.modelName, + parentID: parentID, + res_ids: record.res_ids, + type: 'record', + viewType: 'form', + }); + this.handle = handle; + this._updateControlPanel(); + return this._setMode('edit'); + }, + /** + * Returns the current res_id, wrapped in a list. This is only used by the + * action menus (and the debugmanager) + * + * @override + * + * @returns {number[]} either [current res_id] or [] + */ + getSelectedIds: function () { + var env = this.model.get(this.handle, {env: true}); + return env.currentId ? [env.currentId] : []; + }, + /** + * @override method from AbstractController + * @returns {string} + */ + getTitle: function () { + return this.model.getName(this.handle); + }, + /** + * Add the current ID to the state pushed in the url. + * + * @override + */ + getState: function () { + const state = this._super.apply(this, arguments); + const env = this.model.get(this.handle, {env: true}); + state.id = env.currentId; + return state; + }, + /** + * Render buttons for the control panel. The form view can be rendered in + * a dialog, and in that case, if we have buttons defined in the footer, we + * have to use them instead of the standard buttons. + * + * @override method from AbstractController + * @param {jQuery} [$node] + */ + renderButtons: function ($node) { + var $footer = this.footerToButtons ? this.renderer.$el && this.renderer.$('footer') : null; + var mustRenderFooterButtons = $footer && $footer.length; + if ((this.defaultButtons && !this.$buttons) || mustRenderFooterButtons) { + this.$buttons = $('<div/>'); + if (mustRenderFooterButtons) { + this.$buttons.append($footer); + } else { + this.$buttons.append(qweb.render("FormView.buttons", {widget: this})); + this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this)); + this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this)); + this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this)); + this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this)); + this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit')); + this.$buttons.find('.o_form_buttons_edit').tooltip({ + delay: {show: 200, hide:0}, + title: function(){ + return qweb.render('SaveCancelButton.tooltip'); + }, + trigger: 'manual', + }); + } + } + if (this.$buttons && $node) { + this.$buttons.appendTo($node); + } + }, + /** + * The form view has to prevent a click on the pager if the form is dirty + * + * @override method from BasicController + * @param {jQueryElement} $node + * @param {Object} options + * @returns {Promise} + */ + _getPagingInfo: function () { + // Only display the pager if we are not on a new record. + if (this.model.isNew(this.handle)) { + return null; + } + return Object.assign(this._super(...arguments), { + validate: this.canBeDiscarded.bind(this), + }); + }, + /** + * @override + * @private + **/ + _getActionMenuItems: function (state) { + if (!this.hasActionMenus || this.mode === 'edit') { + return null; + } + const props = this._super(...arguments); + const activeField = this.model.getActiveField(state); + const otherActionItems = []; + if (this.archiveEnabled && activeField in state.data) { + if (state.data[activeField]) { + otherActionItems.push({ + description: _t("Archive"), + callback: () => { + Dialog.confirm(this, _t("Are you sure that you want to archive this record?"), { + confirm_callback: () => this._toggleArchiveState(true), + }); + }, + }); + } else { + otherActionItems.push({ + description: _t("Unarchive"), + callback: () => this._toggleArchiveState(false), + }); + } + } + if (this.activeActions.create && this.activeActions.duplicate) { + otherActionItems.push({ + description: _t("Duplicate"), + callback: () => this._onDuplicateRecord(this), + }); + } + if (this.activeActions.delete) { + otherActionItems.push({ + description: _t("Delete"), + callback: () => this._onDeleteRecord(this), + }); + } + return Object.assign(props, { + items: Object.assign(this.toolbarActions, { other: otherActionItems }), + }); + }, + /** + * Show a warning message if the user modified a translated field. For each + * field, the notification provides a link to edit the field's translations. + * + * @override + */ + saveRecord: async function () { + const changedFields = await this._super(...arguments); + // the title could have been changed + this._updateControlPanel(); + + if (_t.database.multi_lang && changedFields.length) { + // need to make sure changed fields that should be translated + // are displayed with an alert + var fields = this.renderer.state.fields; + var data = this.renderer.state.data; + var alertFields = {}; + for (var k = 0; k < changedFields.length; k++) { + var field = fields[changedFields[k]]; + var fieldData = data[changedFields[k]]; + if (field.translate && fieldData && fieldData !== '<p><br></p>') { + alertFields[changedFields[k]] = field; + } + } + if (!_.isEmpty(alertFields)) { + this.renderer.updateAlertFields(alertFields); + } + } + return changedFields; + }, + /** + * Overrides to force the viewType to 'form', so that we ensure that the + * correct fields are reloaded (this is only useful for one2many form views). + * + * @override + */ + update: async function (params, options) { + if ('currentId' in params && !params.currentId) { + this.mode = 'edit'; // if there is no record, we are in 'edit' mode + } + params = _.extend({viewType: 'form', mode: this.mode}, params); + await this._super(params, options); + this.autofocus(); + }, + /** + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + if (this.footerToButtons) { + var $footer = this.renderer.$el && this.renderer.$('footer'); + if ($footer && $footer.length) { + this.$buttons.empty().append($footer); + } + } + var edit_mode = (this.mode === 'edit'); + this.$buttons.find('.o_form_buttons_edit') + .toggleClass('o_hidden', !edit_mode); + this.$buttons.find('.o_form_buttons_view') + .toggleClass('o_hidden', edit_mode); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _applyChanges: async function () { + const result = await this._super.apply(this, arguments); + core.bus.trigger('DOM_updated'); + return result; + }, + /** + * Assign on the buttons save and discard additionnal behavior to facilitate + * the work of the users doing input only using the keyboard + * + * @param {jQueryElement} $saveCancelButtonContainer The div containing the + * save and cancel buttons + * @private + */ + _assignSaveCancelKeyboardBehavior: function ($saveCancelButtonContainer) { + var self = this; + $saveCancelButtonContainer.children().on('keydown', function (e) { + switch(e.which) { + case $.ui.keyCode.ENTER: + e.preventDefault(); + self.saveRecord(); + break; + case $.ui.keyCode.ESCAPE: + e.preventDefault(); + self._discardChanges(); + break; + case $.ui.keyCode.TAB: + if (!e.shiftKey && e.target.classList.contains('btn-primary')) { + $saveCancelButtonContainer.tooltip('show'); + e.preventDefault(); + } + break; + } + }); + }, + /** + * When a save operation has been confirmed from the model, this method is + * called. + * + * @private + * @override method from field manager mixin + * @param {string} id - id of the previously changed record + * @returns {Promise} + */ + _confirmSave: function (id) { + if (id === this.handle) { + if (this.mode === 'readonly') { + return this.reload(); + } else { + return this._setMode('readonly'); + } + } else { + // A subrecord has changed, so update the corresponding relational field + // i.e. the one whose value is a record with the given id or a list + // having a record with the given id in its data + var record = this.model.get(this.handle); + + // Callback function which returns true + // if a value recursively contains a record with the given id. + // This will be used to determine the list of fields to reload. + var containsChangedRecord = function (value) { + return _.isObject(value) && + (value.id === id || _.find(value.data, containsChangedRecord)); + }; + + var changedFields = _.findKey(record.data, containsChangedRecord); + return this.renderer.confirmChange(record, record.id, [changedFields]); + } + }, + /** + * Override to disable buttons in the renderer. + * + * @override + * @private + */ + _disableButtons: function () { + this._super.apply(this, arguments); + this.renderer.disableButtons(); + }, + /** + * Override to enable buttons in the renderer. + * + * @override + * @private + */ + _enableButtons: function () { + this._super.apply(this, arguments); + this.renderer.enableButtons(); + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @override + */ + _onDeletedRecords: function () { + var state = this.model.get(this.handle, {raw: true}); + if (!state.res_ids.length) { + this.trigger_up('history_back'); + } else { + this._super.apply(this, arguments); + } + }, + /** + * Overrides to reload the form when saving failed in readonly (e.g. after + * a change on a widget like priority or statusbar). + * + * @override + * @private + */ + _rejectSave: function () { + if (this.mode === 'readonly') { + return this.reload(); + } + return this._super.apply(this, arguments); + }, + /** + * Calls unfreezeOrder when changing the mode. + * Also, when there is a change of mode, the tracking of last activated + * field is reset, so that the following field activation process starts + * with the 1st field. + * + * @override + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) === this.handle) { + this.model.unfreezeOrder(this.handle); + } + if (this.mode !== mode) { + this.renderer.resetLastActivatedField(); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + _shouldBounceOnClick(element) { + return this.mode === 'readonly' && !!element.closest('.oe_title, .o_inner_group'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + // stop the event's propagation as a form controller might have other + // form controllers in its descendants (e.g. in a FormViewDialog) + ev.stopPropagation(); + var self = this; + var def; + + this._disableButtons(); + + function saveAndExecuteAction () { + return self.saveRecord(self.handle, { + stayInEdit: true, + }).then(function () { + // we need to reget the record to make sure we have changes made + // by the basic model, such as the new res_id, if the record is + // new. + var record = self.model.get(ev.data.record.id); + return self._callButtonAction(attrs, record); + }); + } + var attrs = ev.data.attrs; + if (attrs.confirm) { + def = new Promise(function (resolve, reject) { + Dialog.confirm(self, attrs.confirm, { + confirm_callback: saveAndExecuteAction, + }).on("closed", null, resolve); + }); + } else if (attrs.special === 'cancel') { + def = this._callButtonAction(attrs, ev.data.record); + } else if (!attrs.special || attrs.special === 'save') { + // save the record but don't switch to readonly mode + def = saveAndExecuteAction(); + } else { + console.warn('Unhandled button event', ev); + return; + } + + // Kind of hack for FormViewDialog: button on footer should trigger the dialog closing + // if the `close` attribute is set + def.then(function () { + self._enableButtons(); + if (attrs.close) { + self.trigger_up('close_dialog'); + } + }).guardedCatch(this._enableButtons.bind(this)); + }, + /** + * Called when the user wants to create a new record -> @see createRecord + * + * @private + */ + _onCreate: function () { + this.createRecord(); + }, + /** + * Deletes the current record + * + * @private + */ + _onDeleteRecord: function () { + this._deleteRecords([this.handle]); + }, + /** + * Called when the user wants to discard the changes made to the current + * record -> @see discardChanges + * + * @private + */ + _onDiscard: function () { + this._disableButtons(); + this._discardChanges() + .then(this._enableButtons.bind(this)) + .guardedCatch(this._enableButtons.bind(this)); + }, + /** + * Called when the user clicks on 'Duplicate Record' in the action menus + * + * @private + */ + _onDuplicateRecord: async function () { + const handle = await this.model.duplicateRecord(this.handle); + this.handle = handle; + this._updateControlPanel(); + this._setMode('edit'); + }, + /** + * Called when the user wants to edit the current record -> @see _setMode + * + * @private + */ + _onEdit: function () { + this._disableButtons(); + // wait for potential pending changes to be saved (done with widgets + // allowing to edit in readonly) + this.mutex.getUnlockedDef() + .then(this._setMode.bind(this, 'edit')) + .then(this._enableButtons.bind(this)) + .guardedCatch(this._enableButtons.bind(this)); + }, + /** + * This method is called when someone tries to freeze the order, most likely + * in a x2many list view + * + * @private + * @param {OdooEvent} ev + * @param {integer} ev.id of the list to freeze while editing a line + */ + _onEditedList: function (ev) { + ev.stopPropagation(); + if (ev.data.id) { + this.model.save(ev.data.id, {savePoint: true}); + } + this.model.freezeOrder(ev.data.id); + }, + /** + * Set the focus on the first primary button of the controller (likely Edit) + * + * @private + * @param {OdooEvent} event + */ + _onFocusControlButton:function(e) { + if (this.$buttons) { + e.stopPropagation(); + this.$buttons.find('.btn-primary:visible:first()').focus(); + } + }, + /** + * Reset the focus on the control that openned a Dialog after it was closed + * + * @private + * @param {OdooEvent} event + */ + _onFormDialogDiscarded: function(ev) { + ev.stopPropagation(); + var isFocused = this.renderer.focusLastActivatedWidget(); + if (ev.data.callback) { + ev.data.callback(_.str.toBool(isFocused)); + } + }, + /** + * Opens a one2many record (potentially new) in a dialog. This handler is + * o2m specific as in this case, the changes done on the related record + * shouldn't be saved in DB when the user clicks on 'Save' in the dialog, + * but later on when he clicks on 'Save' in the main form view. For this to + * work correctly, the main model and the local id of the opened record must + * be given to the dialog, which will complete the viewInfo of the record + * with the one of the form view. + * + * @private + * @param {OdooEvent} ev + */ + _onOpenOne2ManyRecord: async function (ev) { + ev.stopPropagation(); + var data = ev.data; + var record; + if (data.id) { + record = this.model.get(data.id, {raw: true}); + } + + // Sync with the mutex to wait for potential onchanges + await this.model.mutex.getUnlockedDef(); + + new dialogs.FormViewDialog(this, { + context: data.context, + domain: data.domain, + fields_view: data.fields_view, + model: this.model, + on_saved: data.on_saved, + on_remove: data.on_remove, + parentID: data.parentID, + readonly: data.readonly, + deletable: record ? data.deletable : false, + recordID: record && record.id, + res_id: record && record.res_id, + res_model: data.field.relation, + shouldSaveLocally: true, + title: (record ? _t("Open: ") : _t("Create ")) + (ev.target.string || data.field.string), + }).open(); + }, + /** + * Open an existing record in a form view dialog + * + * @private + * @param {OdooEvent} ev + */ + _onOpenRecord: function (ev) { + ev.stopPropagation(); + var self = this; + var record = this.model.get(ev.data.id, {raw: true}); + new dialogs.FormViewDialog(self, { + context: ev.data.context, + fields_view: ev.data.fields_view, + on_saved: ev.data.on_saved, + on_remove: ev.data.on_remove, + readonly: ev.data.readonly, + deletable: ev.data.deletable, + res_id: record.res_id, + res_model: record.model, + title: _t("Open: ") + ev.data.string, + }).open(); + }, + /** + * Called when the user wants to save the current record -> @see saveRecord + * + * @private + * @param {MouseEvent} ev + */ + _onSave: function (ev) { + ev.stopPropagation(); // Prevent x2m lines to be auto-saved + this._disableButtons(); + this.saveRecord().then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }, + /** + * This method is called when someone tries to sort a column, most likely + * in a x2many list view + * + * @private + * @param {OdooEvent} ev + */ + _onToggleColumnOrder: function (ev) { + ev.stopPropagation(); + var self = this; + this.model.setSort(ev.data.id, ev.data.name).then(function () { + var field = ev.data.field; + var state = self.model.get(self.handle); + self.renderer.confirmChange(state, state.id, [field]); + }); + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the action menus. + * + * @private + * @param {boolean} archive + */ + _toggleArchiveState: function (archive) { + const resIds = this.model.localIdsToResIds([this.handle]); + this._archive(resIds, archive); + }, +}); + +return FormController; + +}); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js new file mode 100644 index 00000000..e4c1b187 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -0,0 +1,1211 @@ +odoo.define('web.FormRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +// symbol used as key to set the <field> node id on its widget +const symbol = Symbol('form'); + +var FormRenderer = BasicRenderer.extend({ + className: "o_form_view", + events: _.extend({}, BasicRenderer.prototype.events, { + 'click .o_notification_box .oe_field_translate': '_onTranslate', + 'click .o_notification_box .close': '_onTranslateNotificationClose', + 'shown.bs.tab a[data-toggle="tab"]': '_onNotebookTabChanged', + }), + custom_events: _.extend({}, BasicRenderer.prototype.custom_events, { + 'navigation_move':'_onNavigationMove', + 'activate_next_widget' : '_onActivateNextWidget', + }), + // default col attributes for the rendering of groups + INNER_GROUP_COL: 2, + OUTER_GROUP_COL: 2, + + /** + * @override + * @param {Object} params.fieldIdsToNames maps <field> node ids to field names + * (useful when there are several occurrences of the same field in the arch) + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.fieldIdsToNames = params.fieldIdsToNames; + this.idsForLabels = {}; + this.lastActivatedFieldIndex = -1; + this.alertFields = {}; + // The form renderer doesn't render invsible fields (invisible="1") by + // default, to speed up the rendering. However, we sometimes have to + // display them (e.g. in Studio, in "show invisible" mode). This flag + // allows to disable this optimization. + this.renderInvisible = false; + }, + /** + * @override + */ + start: function () { + this._applyFormSizeClass(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focuses the field having attribute 'default_focus' set, if any, or the + * first focusable field otherwise. + * In read mode, delegate which button to give the focus to, to the form_renderer + * + * @returns {int | undefined} the index of the widget activated else + * undefined + */ + autofocus: function () { + if (this.mode === 'readonly') { + var firstPrimaryFormButton = this.$el.find('button.btn-primary:enabled:visible:first()'); + if (firstPrimaryFormButton.length > 0) { + return firstPrimaryFormButton.focus(); + } else { + return; + } + } + var focusWidget = this.defaultFocusField; + if (!focusWidget || !focusWidget.isFocusable()) { + var widgets = this.allFieldWidgets[this.state.id]; + for (var i = 0; i < (widgets ? widgets.length : 0); i++) { + var widget = widgets[i]; + if (widget.isFocusable()) { + focusWidget = widget; + break; + } + } + } + if (focusWidget) { + return focusWidget.activate({noselect: true, noAutomaticCreate: true}); + } + }, + /** + * Extend the method so that labels also receive the 'o_field_invalid' class + * if necessary. + * + * @override + * @see BasicRenderer.canBeSaved + * @param {string} recordID + * @returns {string[]} + */ + canBeSaved: function () { + var fieldNames = this._super.apply(this, arguments); + + var $labels = this.$('label'); + $labels.removeClass('o_field_invalid'); + + const allWidgets = this.allFieldWidgets[this.state.id] || []; + const widgets = allWidgets.filter(w => fieldNames.includes(w.name)); + for (const widget of widgets) { + const idForLabel = this.idsForLabels[widget[symbol]]; + if (idForLabel) { + $labels + .filter('[for=' + idForLabel + ']') + .addClass('o_field_invalid'); + } + } + return fieldNames; + }, + /* + * Updates translation alert fields for the current state and display updated fields + * + * @param {Object} alertFields + */ + updateAlertFields: function (alertFields) { + this.alertFields[this.state.res_id] = _.extend(this.alertFields[this.state.res_id] || {}, alertFields); + this.displayTranslationAlert(); + }, + /** + * Show a warning message if the user modified a translated field. For each + * field, the notification provides a link to edit the field's translations. + */ + displayTranslationAlert: function () { + this.$('.o_notification_box').remove(); + if (this.alertFields[this.state.res_id]) { + var $notification = $(qweb.render('notification-box', {type: 'info'})) + .append(qweb.render('translation-alert', { + fields: this.alertFields[this.state.res_id], + lang: _t.database.parameters.name + })); + if (this.$('.o_form_statusbar').length) { + this.$('.o_form_statusbar').after($notification); + } else if (this.$('.o_form_sheet_bg').length) { + this.$('.o_form_sheet_bg').prepend($notification); + } else { + this.$el.prepend($notification); + } + } + }, + /** + * @see BasicRenderer.confirmChange + * + * We need to reapply the idForLabel postprocessing since some widgets may + * have recomputed their dom entirely. + * + * @override + */ + confirmChange: function () { + var self = this; + return this._super.apply(this, arguments).then(function (resetWidgets) { + _.each(resetWidgets, function (widget) { + self._setIDForLabel(widget, self.idsForLabels[widget[symbol]]); + }); + if (self.$('.o_field_invalid').length) { + self.canBeSaved(self.state.id); + } + return resetWidgets; + }); + }, + /** + * Disable statusbar buttons and stat buttons so that they can't be clicked anymore + * + */ + disableButtons: function () { + this.$('.o_statusbar_buttons button, .oe_button_box button') + .attr('disabled', true); + }, + /** + * Enable statusbar buttons and stat buttons so they can be clicked again + * + */ + enableButtons: function () { + this.$('.o_statusbar_buttons button, .oe_button_box button') + .removeAttr('disabled'); + }, + /** + * Put the focus on the last activated widget. + * This function is used when closing a dialog to give the focus back to the + * form that has opened it and ensures that the focus is in the correct + * field. + */ + focusLastActivatedWidget: function () { + if (this.lastActivatedFieldIndex !== -1) { + return this._activateNextFieldWidget(this.state, this.lastActivatedFieldIndex - 1, + { noAutomaticCreate: true }); + } + return false; + }, + /** + * returns the active tab pages for each notebook + * + * @todo currently, this method is unused... + * + * @see setLocalState + * @returns {Object} a map from notebook name to the active tab index + */ + getLocalState: function () { + const state = {}; + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + const name = notebook.dataset.name; + const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item > .nav-link'); + state[name] = Math.max([...navs].findIndex( + nav => nav.classList.contains('active') + ), 0); + } + return state; + }, + /** + * Reset the tracking of the last activated field. The fast entry with + * keyboard navigation needs to track the last activated field in order to + * set the focus. + * + * In particular, when there are changes of mode (e.g. edit -> readonly -> + * edit), we do not want to auto-set the focus on the previously last + * activated field. To avoid this issue, this method should be called + * whenever there is a change of mode. + */ + resetLastActivatedField: function () { + this.lastActivatedFieldIndex = -1; + }, + /** + * Resets state which stores information like scroll position, curently + * active page, ... + * + * @override + */ + resetLocalState() { + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + [...notebook.querySelectorAll(':scope .o_notebook_headers .nav-item .nav-link')] + .map(nav => nav.classList.remove('active')); + [...notebook.querySelectorAll(':scope .tab-content > .tab-pane')] + .map(tab => tab.classList.remove('active')); + } + + }, + /** + * Restore active tab pages for each notebook. It relies on the implicit fact + * that each nav header corresponds to a tab page. + * + * @param {Object} state the result from a getLocalState call + */ + setLocalState: function (state) { + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + if (notebook.closest(".o_field_widget")) { + continue; + } + const name = notebook.dataset.name; + if (name in state) { + const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item'); + const pages = notebook.querySelectorAll(':scope > .tab-content > .tab-pane'); + // We can't base the amount on the 'navs' length since some overrides + // are adding pageless nav items. + const validTabsAmount = pages.length; + if (!validTabsAmount) { + continue; // No page defined on the notebook. + } + let activeIndex = state[name]; + if (navs[activeIndex].classList.contains('o_invisible_modifier')) { + activeIndex = [...navs].findIndex( + nav => !nav.classList.contains('o_invisible_modifier') + ); + } + if (activeIndex <= 0) { + continue; // No visible tab OR first tab = active tab (no change to make). + } + for (let i = 0; i < validTabsAmount; i++) { + navs[i].querySelector('.nav-link').classList.toggle('active', activeIndex === i); + pages[i].classList.toggle('active', activeIndex === i); + } + core.bus.trigger('DOM_updated'); + } + } + }, + /** + * @override method from AbstractRenderer + * @param {Object} state a valid state given by the model + * @param {Object} params + * @param {string} [params.mode] new mode, either 'edit' or 'readonly' + * @param {string[]} [params.fieldNames] if given, the renderer will only + * update the fields in this list + * @returns {Promise} + */ + updateState: function (state, params) { + this._setState(state); + this.mode = (params && 'mode' in params) ? params.mode : this.mode; + + // if fieldNames are given, we update the corresponding field widget. + // I think this is wrong, and the caller could directly call the + // confirmChange method + if (params.fieldNames) { + // only update the given fields + return this.confirmChange(this.state, this.state.id, params.fieldNames); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activates the first visible tab from a given list of tab objects. The + * first tab having an "autofocus" attribute set will be focused in + * priority. + * + * @private + * @param {Object[]} tabs + */ + _activateFirstVisibleTab(tabs) { + const visibleTabs = tabs.filter( + (tab) => !tab.$header.hasClass("o_invisible_modifier") + ); + const autofocusTab = visibleTabs.findIndex( + (tab) => tab.node.attrs.autofocus === "autofocus" + ); + const tabToFocus = visibleTabs[Math.max(0, autofocusTab)]; + if (tabToFocus) { + tabToFocus.$header.find('.nav-link').addClass('active'); + tabToFocus.$page.addClass('active'); + } + }, + /** + * @override + */ + _activateNextFieldWidget: function (record, currentIndex) { + //if we are the last widget, we should give the focus to the first Primary Button in the form + //else do the default behavior + if ( (currentIndex + 1) >= (this.allFieldWidgets[record.id] || []).length) { + this.trigger_up('focus_control_button'); + this.lastActivatedFieldIndex = -1; + } else { + var activatedIndex = this._super.apply(this, arguments); + if (activatedIndex === -1 ) { // no widget have been activated, we should go to the edit/save buttons + this.trigger_up('focus_control_button'); + this.lastActivatedFieldIndex = -1; + } + else { + this.lastActivatedFieldIndex = activatedIndex; + } + } + return this.lastActivatedFieldIndex; + }, + /** + * Add a tooltip on a button + * + * @private + * @param {Object} node + * @param {jQuery} $button + */ + _addButtonTooltip: function (node, $button) { + var self = this; + $button.tooltip({ + title: function () { + return qweb.render('WidgetButton.tooltip', { + debug: config.isDebug(), + state: self.state, + node: node, + }); + }, + }); + }, + /** + * @private + * @param {jQueryElement} $el + * @param {Object} node + */ + _addOnClickAction: function ($el, node) { + if (node.attrs.special || node.attrs.confirm || node.attrs.type || $el.hasClass('oe_stat_button')) { + var self = this; + $el.on("click", function () { + self.trigger_up('button_clicked', { + attrs: node.attrs, + record: self.state, + }); + }); + } + }, + _applyFormSizeClass: function () { + const formEl = this.$el[0]; + if (config.device.size_class <= config.device.SIZES.XS) { + formEl.classList.add('o_xxs_form_view'); + } else { + formEl.classList.remove('o_xxs_form_view'); + } + if (config.device.size_class === config.device.SIZES.XXL) { + formEl.classList.add('o_xxl_form_view'); + } else { + formEl.classList.remove('o_xxl_form_view'); + } + }, + /** + * @private + * @param {string} uid a <field> node id + * @returns {string} + */ + _getIDForLabel: function (uid) { + if (!this.idsForLabels[uid]) { + this.idsForLabels[uid] = _.uniqueId('o_field_input_'); + } + return this.idsForLabels[uid]; + }, + /** + * @override + * @private + */ + _getRecord: function (recordId) { + return this.state.id === recordId ? this.state : null; + }, + /** + * @override + * @private + */ + _postProcessField: function (widget, node) { + this._super.apply(this, arguments); + // set the node id on the widget, as it might be necessary later (tooltips, confirmChange...) + widget[symbol] = node.attrs.id; + this._setIDForLabel(widget, this._getIDForLabel(node.attrs.id)); + if (JSON.parse(node.attrs.default_focus || "0")) { + this.defaultFocusField = widget; + } + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderButtonBox: function (node) { + var self = this; + var $result = $('<' + node.tag + '>', {class: 'o_not_full'}); + + // The rendering of buttons may be async (see renderFieldWidget), so we + // must wait for the buttons to be ready (and their modifiers to be + // applied) before manipulating them, as we check if they are visible or + // not. To do so, we extract from this.defs the promises corresponding + // to the buttonbox buttons, and wait for them to be resolved. + var nextDefIndex = this.defs.length; + var buttons = _.map(node.children, function (child) { + if (child.tag === 'button') { + return self._renderStatButton(child); + } else { + return self._renderNode(child); + } + }); + + // At this point, each button is an empty div that will be replaced by + // the real $el of the button when it is ready (with replaceWith). + // However, this only works if the empty div is appended somewhere, so + // we here append them into a wrapper, and unwrap them once they have + // been replaced. + var $tempWrapper = $('<div>'); + _.each(buttons, function ($button) { + $button.appendTo($tempWrapper); + }); + var defs = this.defs.slice(nextDefIndex); + Promise.all(defs).then(function () { + buttons = $tempWrapper.children(); + var buttons_partition = _.partition(buttons, function (button) { + return $(button).is('.o_invisible_modifier'); + }); + var invisible_buttons = buttons_partition[0]; + var visible_buttons = buttons_partition[1]; + + // Get the unfolded buttons according to window size + var nb_buttons = self._renderButtonBoxNbButtons(); + var unfolded_buttons = visible_buttons.slice(0, nb_buttons).concat(invisible_buttons); + + // Get the folded buttons + var folded_buttons = visible_buttons.slice(nb_buttons); + if (folded_buttons.length === 1) { + unfolded_buttons = buttons; + folded_buttons = []; + } + + // Toggle class to tell if the button box is full (CSS requirement) + var full = (visible_buttons.length > nb_buttons); + $result.toggleClass('o_full', full).toggleClass('o_not_full', !full); + + // Add the unfolded buttons + _.each(unfolded_buttons, function (button) { + $(button).appendTo($result); + }); + + // Add the dropdown with folded buttons if any + if (folded_buttons.length) { + $result.append(dom.renderButton({ + attrs: { + 'class': 'oe_stat_button o_button_more dropdown-toggle', + 'data-toggle': 'dropdown', + }, + text: _t("More"), + })); + + var $dropdown = $("<div>", {class: "dropdown-menu o_dropdown_more", role: "menu"}); + _.each(folded_buttons, function (button) { + $(button).addClass('dropdown-item').appendTo($dropdown); + }); + $dropdown.appendTo($result); + } + }); + + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + return $result; + }, + /** + * @private + * @returns {integer} + */ + _renderButtonBoxNbButtons: function () { + return [2, 2, 2, 4][config.device.size_class] || 7; + }, + /** + * Do not render a field widget if it is always invisible. + * + * @override + */ + _renderFieldWidget(node) { + if (!this.renderInvisible && node.attrs.modifiers.invisible === true) { + return $(); + } + return this._super(...arguments); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderGenericTag: function (node) { + var $result = $('<' + node.tag + '>', _.omit(node.attrs, 'modifiers')); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderHeaderButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node); + + // Current API of odoo for rendering buttons is "if classes are given + // use those on top of the 'btn' and 'btn-{size}' classes, otherwise act + // as if 'btn-secondary' class was given". The problem is that, for + // header buttons only, we allowed users to only indicate their custom + // classes without having to explicitely ask for the 'btn-secondary' + // class to be added. We force it so here when no bootstrap btn type + // class is found. + if ($button.not('.btn-primary, .btn-secondary, .btn-link, .btn-success, .btn-info, .btn-warning, .btn-danger').length) { + $button.addClass('btn-secondary'); + } + + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + + // Display tooltip + if (config.isDebug() || node.attrs.help) { + this._addButtonTooltip(node, $button); + } + return $button; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderHeaderButtons: function (node) { + var self = this; + var buttons = []; + _.each(node.children, function (child) { + if (child.tag === 'button') { + buttons.push(self._renderHeaderButton(child)); + } + if (child.tag === 'widget') { + buttons.push(self._renderTagWidget(child)); + } + }); + return this._renderStatusbarButtons(buttons); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroup: function (node) { + var self = this; + var $result = $('<table/>', {class: 'o_group o_inner_group'}); + var $tbody = $('<tbody />').appendTo($result); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + + var col = parseInt(node.attrs.col, 10) || this.INNER_GROUP_COL; + + if (node.attrs.string) { + var $sep = $('<tr><td colspan="' + col + '" style="width: 100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>'); + $result.append($sep); + } + + var rows = []; + var $currentRow = $('<tr/>'); + var currentColspan = 0; + node.children.forEach(function (child) { + if (child.tag === 'newline') { + rows.push($currentRow); + $currentRow = $('<tr/>'); + currentColspan = 0; + return; + } + + var colspan = parseInt(child.attrs.colspan, 10); + var isLabeledField = (child.tag === 'field' && child.attrs.nolabel !== '1'); + if (!colspan) { + if (isLabeledField) { + colspan = 2; + } else { + colspan = 1; + } + } + var finalColspan = colspan - (isLabeledField ? 1 : 0); + currentColspan += colspan; + + if (currentColspan > col) { + rows.push($currentRow); + $currentRow = $('<tr/>'); + currentColspan = colspan; + } + + var $tds; + if (child.tag === 'field') { + $tds = self._renderInnerGroupField(child); + } else if (child.tag === 'label') { + $tds = self._renderInnerGroupLabel(child); + } else { + var $td = $('<td/>'); + var $child = self._renderNode(child); + if ($child.hasClass('o_td_label')) { // transfer classname to outer td for css reasons + $td.addClass('o_td_label'); + $child.removeClass('o_td_label'); + } + $tds = $td.append($child); + } + if (finalColspan > 1) { + $tds.last().attr('colspan', finalColspan); + } + $currentRow.append($tds); + }); + rows.push($currentRow); + + _.each(rows, function ($tr) { + var nonLabelColSize = 100 / (col - $tr.children('.o_td_label').length); + _.each($tr.children(':not(.o_td_label)'), function (el) { + var $el = $(el); + $el.css('width', ((parseInt($el.attr('colspan'), 10) || 1) * nonLabelColSize) + '%'); + }); + $tbody.append($tr); + }); + + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroupField: function (node) { + var $el = this._renderFieldWidget(node, this.state); + var $tds = $('<td/>').append($el); + + if (node.attrs.nolabel !== '1') { + var $labelTd = this._renderInnerGroupLabel(node); + $tds = $labelTd.add($tds); + + // apply the oe_(edit|read)_only className on the label as well + if (/\boe_edit_only\b/.test(node.attrs.class)) { + $tds.addClass('oe_edit_only'); + } + if (/\boe_read_only\b/.test(node.attrs.class)) { + $tds.addClass('oe_read_only'); + } + } + + return $tds; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroupLabel: function (node) { + return $('<td/>', {class: 'o_td_label'}) + .append(this._renderTagLabel(node)); + }, + /** + * Render a node, from the arch of the view. It is a generic method, that + * will dispatch on specific other methods. The rendering of a node is a + * jQuery element (or a string), with the correct classes, attrs, and + * content. + * + * For fields, it will return the $el of the field widget. Note that this + * method is synchronous, field widgets are instantiated and appended, but + * if they are asynchronous, they register their promises in this.defs, and + * the _renderView method will properly wait. + * + * @private + * @param {Object} node + * @returns {jQueryElement | string} + */ + _renderNode: function (node) { + var renderer = this['_renderTag' + _.str.capitalize(node.tag)]; + if (renderer) { + return renderer.call(this, node); + } + if (node.tag === 'div' && node.attrs.name === 'button_box') { + return this._renderButtonBox(node); + } + if (_.isString(node)) { + return node; + } + return this._renderGenericTag(node); + }, + /** + * Renders a 'group' node, which contains 'group' nodes in its children. + * + * @param {Object} node] + * @returns {JQueryElement} + */ + _renderOuterGroup: function (node) { + var self = this; + var $result = $('<div/>', {class: 'o_group'}); + var nbCols = parseInt(node.attrs.col, 10) || this.OUTER_GROUP_COL; + var colSize = Math.max(1, Math.round(12 / nbCols)); + if (node.attrs.string) { + var $sep = $('<div/>', {class: 'o_horizontal_separator'}).text(node.attrs.string); + $result.append($sep); + } + $result.append(_.map(node.children, function (child) { + if (child.tag === 'newline') { + return $('<br/>'); + } + var $child = self._renderNode(child); + $child.addClass('o_group_col_' + (colSize * (parseInt(child.attrs.colspan, 10) || 1))); + return $child; + })); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderStatButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node, { + extraClass: 'oe_stat_button', + }); + $button.append(_.map(node.children, this._renderNode.bind(this))); + if (node.attrs.help) { + this._addButtonTooltip(node, $button); + } + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + return $button; + }, + /** + * @private + * @param {Array} buttons + * @return {jQueryElement} + */ + _renderStatusbarButtons: function (buttons) { + var $statusbarButtons = $('<div>', {class: 'o_statusbar_buttons'}); + buttons.forEach(button => $statusbarButtons.append(button)); + return $statusbarButtons; + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabHeader: function (page, page_id) { + var $a = $('<a>', { + 'data-toggle': 'tab', + disable_anchor: 'true', + href: '#' + page_id, + class: 'nav-link', + role: 'tab', + text: page.attrs.string, + }); + return $('<li>', {class: 'nav-item'}).append($a); + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabPage: function (page, page_id) { + var $result = $('<div class="tab-pane" id="' + page_id + '">'); + $result.append(_.map(page.children, this._renderNode.bind(this))); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node); + $button.append(_.map(node.children, this._renderNode.bind(this))); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + + // Display tooltip + if (config.isDebug() || node.attrs.help) { + this._addButtonTooltip(node, $button); + } + + return $button; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagField: function (node) { + return this._renderFieldWidget(node, this.state); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagForm: function (node) { + var $result = $('<div/>'); + if (node.attrs.class) { + $result.addClass(node.attrs.class); + } + var allNodes = node.children.map(this._renderNode.bind(this)); + $result.append(allNodes); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagGroup: function (node) { + var isOuterGroup = _.some(node.children, function (child) { + return child.tag === 'group'; + }); + if (!isOuterGroup) { + return this._renderInnerGroup(node); + } + return this._renderOuterGroup(node); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagHeader: function (node) { + var self = this; + var $statusbar = $('<div>', {class: 'o_form_statusbar'}); + $statusbar.append(this._renderHeaderButtons(node)); + _.each(node.children, function (child) { + if (child.tag === 'field') { + var $el = self._renderFieldWidget(child, self.state); + $statusbar.append($el); + } + }); + this._handleAttributes($statusbar, node); + this._registerModifiers(node, this.state, $statusbar); + return $statusbar; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagLabel: function (node) { + if (!this.renderInvisible && node.tag === 'field' && + node.attrs.modifiers.invisible === true) { + // skip rendering of invisible fields/labels + return $(); + } + var self = this; + var text; + let fieldName; + if (node.tag === 'label') { + fieldName = this.fieldIdsToNames[node.attrs.for]; // 'for' references a <field> node id + } else { + fieldName = node.attrs.name; + } + if ('string' in node.attrs) { // allow empty string + text = node.attrs.string; + } else if (fieldName) { + text = this.state.fields[fieldName].string; + } else { + return this._renderGenericTag(node); + } + var $result = $('<label>', { + class: 'o_form_label', + for: this._getIDForLabel(node.tag === 'label' ? node.attrs.for : node.attrs.id), + text: text, + }); + if (node.tag === 'label') { + this._handleAttributes($result, node); + } + var modifiersOptions; + if (fieldName) { + modifiersOptions = { + callback: function (element, modifiers, record) { + var widgets = self.allFieldWidgets[record.id]; + var widget = _.findWhere(widgets, {name: fieldName}); + if (!widget) { + return; // FIXME this occurs if the widget is created + // after the label (explicit <label/> tag in the + // arch), so this won't work on first rendering + // only on reevaluation + } + element.$el.toggleClass('o_form_label_empty', !!( // FIXME condition is evaluated twice (label AND widget...) + record.data.id + && (modifiers.readonly || self.mode === 'readonly') + && !widget.isSet() + )); + }, + }; + } + // FIXME if the function is called with a <label/> node, the registered + // modifiers will be those on this node. Maybe the desired behavior + // would be to merge them with associated field node if any... note: + // this worked in 10.0 for "o_form_label_empty" reevaluation but not for + // "o_invisible_modifier" reevaluation on labels... + this._registerModifiers(node, this.state, $result, modifiersOptions); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagNotebook: function (node) { + var self = this; + var $headers = $('<ul class="nav nav-tabs">'); + var $pages = $('<div class="tab-content">'); + // renderedTabs is used to aggregate the generated $headers and $pages + // alongside their node, so that their modifiers can be registered once + // all tabs have been rendered, to ensure that the first visible tab + // is correctly activated + var renderedTabs = _.map(node.children, function (child, index) { + var pageID = _.uniqueId('notebook_page_'); + var $header = self._renderTabHeader(child, pageID); + var $page = self._renderTabPage(child, pageID); + self._handleAttributes($header, child); + $headers.append($header); + $pages.append($page); + return { + $header: $header, + $page: $page, + node: child, + }; + }); + // register the modifiers for each tab + _.each(renderedTabs, function (tab) { + self._registerModifiers(tab.node, self.state, tab.$header, { + callback: function (element, modifiers) { + // if the active tab is invisible, activate the first visible tab instead + var $link = element.$el.find('.nav-link'); + if (modifiers.invisible && $link.hasClass('active')) { + $link.removeClass('active'); + tab.$page.removeClass('active'); + self.inactiveNotebooks.push(renderedTabs); + } + if (!modifiers.invisible) { + // make first page active if there is only one page to display + var $visibleTabs = $headers.find('li:not(.o_invisible_modifier)'); + if ($visibleTabs.length === 1) { + self.inactiveNotebooks.push(renderedTabs); + } + } + }, + }); + }); + this._activateFirstVisibleTab(renderedTabs); + var $notebookHeaders = $('<div class="o_notebook_headers">').append($headers); + var $notebook = $('<div class="o_notebook">').append($notebookHeaders, $pages); + $notebook[0].dataset.name = node.attrs.name || '_default_'; + this._registerModifiers(node, this.state, $notebook); + this._handleAttributes($notebook, node); + return $notebook; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagSeparator: function (node) { + var $separator = $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + this._handleAttributes($separator, node); + this._registerModifiers(node, this.state, $separator); + return $separator; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagSheet: function (node) { + this.has_sheet = true; + var $sheet = $('<div>', {class: 'clearfix position-relative o_form_sheet'}); + $sheet.append(node.children.map(this._renderNode.bind(this))); + return $sheet; + }, + /** + * Instantiate custom widgets + * + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagWidget: function (node) { + return this._renderWidget(this.state, node); + }, + /** + * Main entry point for the rendering. From here, we call _renderNode on + * the root of the arch, then, when every promise (from the field widgets) + * are done, it will resolves itself. + * + * @private + * @override method from BasicRenderer + * @returns {Promise} + */ + _renderView: function () { + var self = this; + + // render the form and evaluate the modifiers + var defs = []; + this.defs = defs; + this.inactiveNotebooks = []; + var $form = this._renderNode(this.arch).addClass(this.className); + delete this.defs; + + return Promise.all(defs).then(() => this.__renderView()).then(function () { + self._updateView($form.contents()); + if (self.state.res_id in self.alertFields) { + self.displayTranslationAlert(); + } + }).then(function(){ + if (self.lastActivatedFieldIndex >= 0) { + self._activateNextFieldWidget(self.state, self.lastActivatedFieldIndex); + } + }).guardedCatch(function () { + $form.remove(); + }); + }, + /** + * Meant to be overridden if asynchronous work needs to be done when + * rendering the view. This is called right before attaching the new view + * content. + * @private + * @returns {Promise<any>} + */ + async __renderView() {}, + /** + * This method is overridden to activate the first notebook page if the + * current active page is invisible due to modifiers. This is done after + * all modifiers are applied on all page elements. + * + * @override + */ + async _updateAllModifiers() { + await this._super(...arguments); + for (const tabs of this.inactiveNotebooks) { + this._activateFirstVisibleTab(tabs); + } + this.inactiveNotebooks = []; + }, + /** + * Updates the form's $el with new content. + * + * @private + * @see _renderView + * @param {JQuery} $newContent + */ + _updateView: function ($newContent) { + var self = this; + + // Set the new content of the form view, and toggle classnames + this.$el.html($newContent); + this.$el.toggleClass('o_form_nosheet', !this.has_sheet); + if (this.has_sheet) { + this.$el.children().not('.o_FormRenderer_chatterContainer') + .wrapAll($('<div/>', {class: 'o_form_sheet_bg'})); + } + this.$el.toggleClass('o_form_editable', this.mode === 'edit'); + this.$el.toggleClass('o_form_readonly', this.mode === 'readonly'); + + // Attach the tooltips on the fields' label + _.each(this.allFieldWidgets[this.state.id], function (widget) { + const idForLabel = self.idsForLabels[widget[symbol]]; + var $label = idForLabel ? self.$('.o_form_label[for=' + idForLabel + ']') : $(); + self._addFieldTooltip(widget, $label); + if (widget.attrs.widget === 'upgrade_boolean') { + // this widget needs a reference to its $label to be correctly + // rendered + widget.renderWithLabel($label); + } + }); + }, + /** + * Sets id attribute of given widget to idForLabel + * + * @private + * @param {AbstractField} widget + * @param {idForLabel} string + */ + _setIDForLabel: function (widget, idForLabel) { + widget.setIDForLabel(idForLabel); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onActivateNextWidget: function (ev) { + ev.stopPropagation(); + var index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target); + this._activateNextFieldWidget(this.state, index); + }, + /** + * @override + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + ev.stopPropagation(); + // We prevent the default behaviour and stop the propagation of the + // originalEvent when the originalEvent is a tab keydown to not let + // the browser do it. The action is done by this renderer. + if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) { + ev.data.originalEvent.preventDefault(); + ev.data.originalEvent.stopPropagation(); + } + var index; + let target = ev.data.target || ev.target; + if (target.__owl__) { + target = target.__owl__.parent; // Owl fields are wrapped by the FieldWrapper + } + if (ev.data.direction === "next") { + index = this.allFieldWidgets[this.state.id].indexOf(target); + this._activateNextFieldWidget(this.state, index); + } else if (ev.data.direction === "previous") { + index = this.allFieldWidgets[this.state.id].indexOf(target); + this._activatePreviousFieldWidget(this.state, index); + } + }, + /** + * Listen to notebook tab changes and trigger a DOM_updated event such that + * widgets in the visible tab can correctly compute their dimensions (e.g. + * autoresize on field text) + * + * @private + */ + _onNotebookTabChanged: function () { + core.bus.trigger('DOM_updated'); + }, + /** + * open the translation view for the current field + * + * @private + * @param {MouseEvent} ev + */ + _onTranslate: function (ev) { + ev.preventDefault(); + this.trigger_up('translate', { + fieldName: ev.target.name, + id: this.state.id, + isComingFromTranslationAlert: true, + }); + }, + /** + * remove alert fields of record from alertFields object + * + * @private + * @param {MouseEvent} ev + */ + _onTranslateNotificationClose: function(ev) { + delete this.alertFields[this.state.res_id]; + }, +}); + +return FormRenderer; +}); diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js new file mode 100644 index 00000000..a7885e0c --- /dev/null +++ b/addons/web/static/src/js/views/form/form_view.js @@ -0,0 +1,201 @@ +odoo.define('web.FormView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var Context = require('web.Context'); +var core = require('web.core'); +var FormController = require('web.FormController'); +var FormRenderer = require('web.FormRenderer'); +const { generateID } = require('web.utils'); + +var _lt = core._lt; + +var FormView = BasicView.extend({ + config: _.extend({}, BasicView.prototype.config, { + Renderer: FormRenderer, + Controller: FormController, + }), + display_name: _lt('Form'), + icon: 'fa-edit', + multi_record: false, + withSearchBar: false, + searchMenuTypes: [], + viewType: 'form', + /** + * @override + */ + init: function (viewInfo, params) { + var hasActionMenus = params.hasActionMenus; + this._super.apply(this, arguments); + + var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); + this.loadParams.type = 'record'; + + // this is kind of strange, but the param object is modified by + // AbstractView, so we only need to use its hasActionMenus value if it was + // not already present in the beginning of this method + if (hasActionMenus === undefined) { + hasActionMenus = params.hasActionMenus; + } + this.controllerParams.hasActionMenus = hasActionMenus; + this.controllerParams.disableAutofocus = params.disable_autofocus || this.arch.attrs.disable_autofocus; + this.controllerParams.toolbarActions = viewInfo.toolbar; + this.controllerParams.footerToButtons = params.footerToButtons; + + var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; + this.controllerParams.defaultButtons = defaultButtons; + this.controllerParams.mode = mode; + + this.rendererParams.mode = mode; + this.rendererParams.isFromFormViewDialog = params.isFromFormViewDialog; + this.rendererParams.fieldIdsToNames = this.fieldsView.fieldIdsToNames; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getController: function (parent) { + return this._loadSubviews(parent).then(this._super.bind(this, parent)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _extractParamsFromAction: function (action) { + var params = this._super.apply(this, arguments); + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + var fullscreen = action.target === 'fullscreen'; + params.withControlPanel = !(inDialog || inline); + params.footerToButtons = inDialog; + params.hasSearchView = inDialog ? false : params.hasSearchView; + params.hasActionMenus = !inDialog && !inline; + params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes; + if (inDialog || inline || fullscreen) { + params.mode = 'edit'; + } else if (action.context && action.context.form_view_initial_mode) { + params.mode = action.context.form_view_initial_mode; + } + return params; + }, + /** + * Loads the subviews for x2many fields when they are not inline + * + * @private + * @param {Widget} parent the parent of the model, if it has to be created + * @returns {Promise} + */ + _loadSubviews: function (parent) { + var self = this; + var defs = []; + if (this.loadParams && this.loadParams.fieldsInfo) { + var fields = this.loadParams.fields; + + _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) { + var field = fields[fieldName]; + if (!field) { + // when a one2many record is opened in a form view, the fields + // of the main one2many view (list or kanban) are added to the + // fieldsInfo of its form view, but those fields aren't in the + // loadParams.fields, as they are not displayed in the view, so + // we can ignore them. + return; + } + if (field.type !== 'one2many' && field.type !== 'many2many') { + return; + } + + if (attrs.Widget.prototype.useSubview && !attrs.__no_fetch && !attrs.views[attrs.mode]) { + var context = {}; + var regex = /'([a-z]*_view_ref)' *: *'(.*?)'/g; + var matches; + while (matches = regex.exec(attrs.context)) { + context[matches[1]] = matches[2]; + } + + // Remove *_view_ref coming from parent view + var refinedContext = _.pick(self.loadParams.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); + // Specify the main model to prevent access rights defined in the context + // (e.g. create: 0) to apply to subviews. We use here the same logic as + // the one applied by the server for inline views. + refinedContext.base_model_name = self.controllerParams.modelName; + defs.push(parent.loadViews( + field.relation, + new Context(context, self.userContext, refinedContext).eval(), + [[null, attrs.mode === 'tree' ? 'list' : attrs.mode]]) + .then(function (views) { + for (var viewName in views) { + // clone to make runbot green? + attrs.views[viewName] = self._processFieldsView(views[viewName], viewName); + attrs.views[viewName].fields = attrs.views[viewName].viewFields; + self._processSubViewAttrs(attrs.views[viewName], attrs); + } + self._setSubViewLimit(attrs); + })); + } else { + self._setSubViewLimit(attrs); + } + }); + } + return Promise.all(defs); + }, + /** + * @override + */ + _processArch(arch, fv) { + fv.fieldIdsToNames = {}; // maps field ids (identifying <field> nodes) to field names + return this._super(...arguments); + }, + /** + * Override to populate the 'fieldIdsToNames' dict mapping <field> node ids + * to field names. Those ids are computed as follows: + * - if set on the node, we use the 'id' attribute + * - otherwise + * - if this is the first occurrence of the field in the arch, we use + * its name as id ('name' attribute) + * - otherwise we generate an id by concatenating the field name with + * a unique id + * - in both cases, we set the id we generated in the attrs, as it + * will be used by the renderer. + * + * @override + */ + _processNode(node, fv) { + if (node.tag === 'field') { + const name = node.attrs.name; + let uid = node.attrs.id; + if (!uid) { + uid = name in fv.fieldIdsToNames ? `${name}__${generateID()}__` : name; + node.attrs.id = uid; + } + fv.fieldIdsToNames[uid] = name; + } + return this._super(...arguments); + }, + /** + * We set here the limit for the number of records fetched (in one page). + * This method is only called for subviews, not for main views. + * + * @private + * @param {Object} attrs + */ + _setSubViewLimit: function (attrs) { + var view = attrs.views && attrs.views[attrs.mode]; + var limit = view && view.arch.attrs.limit && parseInt(view.arch.attrs.limit, 10); + attrs.limit = limit || attrs.Widget.prototype.limit || 40; + }, +}); + +return FormView; + +}); diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js new file mode 100644 index 00000000..6cb2b899 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -0,0 +1,356 @@ +odoo.define('web.GraphController', function (require) { +"use strict"; + +/*--------------------------------------------------------- + * Odoo Graph view + *---------------------------------------------------------*/ + +const AbstractController = require('web.AbstractController'); +const { ComponentWrapper } = require('web.OwlCompatibility'); +const DropdownMenu = require('web.DropdownMenu'); +const { DEFAULT_INTERVAL, INTERVAL_OPTIONS } = require('web.searchUtils'); +const { qweb } = require('web.core'); +const { _t } = require('web.core'); + +class CarretDropdownMenu extends DropdownMenu { + /** + * @override + */ + get displayCaret() { + return true; + } +} + +var GraphController = AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + item_selected: '_onItemSelected', + open_view: '_onOpenView', + }), + + /** + * @override + * @param {Widget} parent + * @param {GraphModel} model + * @param {GraphRenderer} renderer + * @param {Object} params + * @param {string[]} params.measures + * @param {boolean} params.isEmbedded + * @param {string[]} params.groupableFields, + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.measures = params.measures; + // this parameter condition the appearance of a 'Group By' + // button in the control panel owned by the graph view. + this.isEmbedded = params.isEmbedded; + this.withButtons = params.withButtons; + // views to use in the action triggered when the graph is clicked + this.views = params.views; + this.title = params.title; + + // this parameter determines what is the list of fields + // that may be used within the groupby menu available when + // the view is embedded + this.groupableFields = params.groupableFields; + this.buttonDropdownPromises = []; + }, + /** + * @override + */ + start: function () { + this.$el.addClass('o_graph_controller'); + return this._super.apply(this, arguments); + }, + /** + * @todo check if this can be removed (mostly duplicate with + * AbstractController method) + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off().tooltip('dispose'); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the current mode, measure and groupbys, so we can restore the + * view when we save the current state in the search view, or when we add it + * to the dashboard. + * + * @override + * @returns {Object} + */ + getOwnedQueryParams: function () { + var state = this.model.get(); + return { + context: { + graph_measure: state.measure, + graph_mode: state.mode, + graph_groupbys: state.groupBy, + } + }; + }, + /** + * @override + */ + reload: async function () { + const promises = [this._super(...arguments)]; + if (this.withButtons) { + const state = this.model.get(); + this.measures.forEach(m => m.isActive = m.fieldName === state.measure); + promises.push(this.measureMenu.update({ items: this.measures })); + } + return Promise.all(promises); + }, + /** + * Render the buttons according to the GraphView.buttons and + * add listeners on it. + * Set this.$buttons with the produced jQuery element + * + * @param {jQuery} [$node] a jQuery node where the rendered buttons should + * be inserted $node may be undefined, in which case the GraphView does + * nothing + */ + renderButtons: function ($node) { + this.$buttons = $(qweb.render('GraphView.buttons')); + this.$buttons.find('button').tooltip(); + this.$buttons.click(ev => this._onButtonClick(ev)); + + if (this.withButtons) { + const state = this.model.get(); + const fragment = document.createDocumentFragment(); + // Instantiate and append MeasureMenu + this.measures.forEach(m => m.isActive = m.fieldName === state.measure); + this.measureMenu = new ComponentWrapper(this, CarretDropdownMenu, { + title: _t("Measures"), + items: this.measures, + }); + this.buttonDropdownPromises = [this.measureMenu.mount(fragment)]; + if ($node) { + if (this.isEmbedded) { + // Instantiate and append GroupBy menu + this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, { + title: _t("Group By"), + icon: 'fa fa-bars', + items: this._getGroupBys(state.groupBy), + }); + this.buttonDropdownPromises.push(this.groupByMenu.mount(fragment)); + } + this.$buttons.appendTo($node); + } + } + }, + /** + * Makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that). + * + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + var state = this.model.get(); + this.$buttons.find('.o_graph_button').removeClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="' + state.mode + '"]') + .addClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="stack"]') + .data('stacked', state.stacked) + .toggleClass('active', state.stacked) + .toggleClass('o_hidden', state.mode !== 'bar'); + this.$buttons + .find('.o_graph_button[data-order]') + .toggleClass('o_hidden', state.mode === 'pie' || !!Object.keys(state.timeRanges).length) + .filter('.o_graph_button[data-order="' + state.orderBy + '"]') + .toggleClass('active', !!state.orderBy); + + if (this.withButtons) { + this._attachDropdownComponents(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Attaches the different dropdown components to the buttons container. + * + * @returns {Promise} + */ + async _attachDropdownComponents() { + await Promise.all(this.buttonDropdownPromises); + const actionsContainer = this.$buttons[0]; + // Attach "measures" button + actionsContainer.appendChild(this.measureMenu.el); + this.measureMenu.el.classList.add('o_graph_measures_list'); + if (this.isEmbedded) { + // Attach "groupby" button + actionsContainer.appendChild(this.groupByMenu.el); + this.groupByMenu.el.classList.add('o_group_by_menu'); + } + // Update button classes accordingly to the current mode + const buttons = actionsContainer.querySelectorAll('.o_dropdown_toggler_btn'); + for (const button of buttons) { + button.classList.remove('o_dropdown_toggler_btn', 'btn-secondary'); + if (this.isEmbedded) { + button.classList.add('btn-outline-secondary'); + } else { + button.classList.add('btn-primary'); + button.tabIndex = 0; + } + } + }, + + /** + * Returns the items used by the Group By menu in embedded mode. + * + * @private + * @param {string[]} activeGroupBys + * @returns {Object[]} + */ + _getGroupBys(activeGroupBys) { + const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys); + const groupBys = Object.keys(this.groupableFields).map(fieldName => { + const field = this.groupableFields[fieldName]; + const groupByActivity = normalizedGroupBys.filter(gb => gb.fieldName === fieldName); + const groupBy = { + id: fieldName, + isActive: Boolean(groupByActivity.length), + description: field.string, + itemType: 'groupBy', + }; + if (['date', 'datetime'].includes(field.type)) { + groupBy.hasOptions = true; + const activeOptionIds = groupByActivity.map(gb => gb.interval); + groupBy.options = Object.values(INTERVAL_OPTIONS).map(o => { + return Object.assign({}, o, { isActive: activeOptionIds.includes(o.id) }); + }); + } + return groupBy; + }).sort((gb1, gb2) => { + return gb1.description.localeCompare(gb2.description); + }); + return groupBys; + }, + + /** + * This method puts the active groupBys in a convenient form. + * + * @private + * @param {string[]} activeGroupBys + * @returns {Object[]} normalizedGroupBys + */ + _normalizeActiveGroupBys(activeGroupBys) { + return activeGroupBys.map(groupBy => { + const fieldName = groupBy.split(':')[0]; + const field = this.groupableFields[fieldName]; + const normalizedGroupBy = { fieldName }; + if (['date', 'datetime'].includes(field.type)) { + normalizedGroupBy.interval = groupBy.split(':')[1] || DEFAULT_INTERVAL; + } + return normalizedGroupBy; + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Do what need to be done when a button from the control panel is clicked. + * + * @private + * @param {MouseEvent} ev + */ + _onButtonClick: function (ev) { + var $target = $(ev.target); + if ($target.hasClass('o_graph_button')) { + if (_.contains(['bar','line', 'pie'], $target.data('mode'))) { + this.update({ mode: $target.data('mode') }); + } else if ($target.data('mode') === 'stack') { + this.update({ stacked: !$target.data('stacked') }); + } else if (['asc', 'desc'].includes($target.data('order'))) { + const order = $target.data('order'); + const state = this.model.get(); + this.update({ orderBy: state.orderBy === order ? false : order }); + } + } + }, + + /** + * @private + * @param {OdooEvent} ev + */ + _onItemSelected(ev) { + const item = ev.data.item; + if (this.isEmbedded && item.itemType === 'groupBy') { + const fieldName = item.id; + const optionId = ev.data.option && ev.data.option.id; + const activeGroupBys = this.model.get().groupBy; + if (optionId) { + const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys); + const index = normalizedGroupBys.findIndex(ngb => + ngb.fieldName === fieldName && ngb.interval === optionId); + if (index === -1) { + activeGroupBys.push(fieldName + ':' + optionId); + } else { + activeGroupBys.splice(index, 1); + } + } else { + const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]); + const indexOfGroupby = groupByFieldNames.indexOf(fieldName); + if (indexOfGroupby === -1) { + activeGroupBys.push(fieldName); + } else { + activeGroupBys.splice(indexOfGroupby, 1); + } + } + this.update({ groupBy: activeGroupBys }); + this.groupByMenu.update({ + items: this._getGroupBys(activeGroupBys), + }); + } else if (item.itemType === 'measure') { + this.update({ measure: item.fieldName }); + this.measures.forEach(m => m.isActive = m.fieldName === item.fieldName); + this.measureMenu.update({ items: this.measures }); + } + }, + + /** + * @private + * @param {OdooEvent} ev + * @param {Array[]} ev.data.domain + */ + _onOpenView(ev) { + ev.stopPropagation(); + const state = this.model.get(); + const context = Object.assign({}, state.context); + Object.keys(context).forEach(x => { + if (x === 'group_by' || x.startsWith('search_default_')) { + delete context[x]; + } + }); + this.do_action({ + context: context, + domain: ev.data.domain, + name: this.title, + res_model: this.modelName, + target: 'current', + type: 'ir.actions.act_window', + view_mode: 'list', + views: this.views, + }); + }, +}); + +return GraphController; + +}); diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js new file mode 100644 index 00000000..b1bcddb4 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -0,0 +1,322 @@ +odoo.define('web.GraphModel', function (require) { +"use strict"; + +var core = require('web.core'); +const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils'); + +var _t = core._t; + +/** + * The graph model is responsible for fetching and processing data from the + * server. It basically just do a(some) read_group(s) and format/normalize data. + */ +var AbstractModel = require('web.AbstractModel'); + +return AbstractModel.extend({ + /** + * @override + * @param {Widget} parent + */ + init: function () { + this._super.apply(this, arguments); + this.chart = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * + * We defend against outside modifications by extending the chart data. It + * may be overkill. + * + * @override + * @returns {Object} + */ + __get: function () { + return Object.assign({ isSample: this.isSampleModel }, this.chart); + }, + /** + * Initial loading. + * + * @todo All the work to fall back on the graph_groupbys keys in the context + * should be done by the graphView I think. + * + * @param {Object} params + * @param {Object} params.context + * @param {Object} params.fields + * @param {string[]} params.domain + * @param {string[]} params.groupBys a list of valid field names + * @param {string[]} params.groupedBy a list of valid field names + * @param {boolean} params.stacked + * @param {string} params.measure a valid field name + * @param {'pie'|'bar'|'line'} params.mode + * @param {string} params.modelName + * @param {Object} params.timeRanges + * @returns {Promise} The promise does not return a handle, we don't need + * to keep track of various entities. + */ + __load: function (params) { + var groupBys = params.context.graph_groupbys || params.groupBys; + this.initialGroupBys = groupBys; + this.fields = params.fields; + this.modelName = params.modelName; + this.chart = Object.assign({ + context: params.context, + dataPoints: [], + domain: params.domain, + groupBy: params.groupedBy.length ? params.groupedBy : groupBys, + measure: params.context.graph_measure || params.measure, + mode: params.context.graph_mode || params.mode, + origins: [], + stacked: params.stacked, + timeRanges: params.timeRanges, + orderBy: params.orderBy + }); + + this._computeDerivedParams(); + + return this._loadGraph(); + }, + /** + * Reload data. It is similar to the load function. Note that we ignore the + * handle parameter, we always expect our data to be in this.chart object. + * + * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This + * is insane. + * + * @param {any} handle ignored! + * @param {Object} params + * @param {boolean} [params.stacked] + * @param {Object} [params.context] + * @param {string[]} [params.domain] + * @param {string[]} [params.groupBy] + * @param {string} [params.measure] a valid field name + * @param {string} [params.mode] one of 'bar', 'pie', 'line' + * @param {Object} [params.timeRanges] + * @returns {Promise} + */ + __reload: function (handle, params) { + if ('context' in params) { + this.chart.context = params.context; + this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy; + this.chart.measure = params.context.graph_measure || this.chart.measure; + this.chart.mode = params.context.graph_mode || this.chart.mode; + } + if ('domain' in params) { + this.chart.domain = params.domain; + } + if ('groupBy' in params) { + this.chart.groupBy = params.groupBy.length ? params.groupBy : this.initialGroupBys; + } + if ('measure' in params) { + this.chart.measure = params.measure; + } + if ('timeRanges' in params) { + this.chart.timeRanges = params.timeRanges; + } + + this._computeDerivedParams(); + + if ('mode' in params) { + this.chart.mode = params.mode; + return Promise.resolve(); + } + if ('stacked' in params) { + this.chart.stacked = params.stacked; + return Promise.resolve(); + } + if ('orderBy' in params) { + this.chart.orderBy = params.orderBy; + return Promise.resolve(); + } + return this._loadGraph(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Compute this.chart.processedGroupBy, this.chart.domains, this.chart.origins, + * and this.chart.comparisonFieldIndex. + * Those parameters are determined by this.chart.timeRanges, this.chart.groupBy, and this.chart.domain. + * + * @private + */ + _computeDerivedParams: function () { + this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy); + + const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges; + if (range) { + this.chart.domains = [ + this.chart.domain.concat(range), + this.chart.domain.concat(comparisonRange), + ]; + this.chart.origins = [rangeDescription, comparisonRangeDescription]; + const groupBys = this.chart.processedGroupBy.map(function (gb) { + return gb.split(":")[0]; + }); + this.chart.comparisonFieldIndex = groupBys.indexOf(fieldName); + } else { + this.chart.domains = [this.chart.domain]; + this.chart.origins = [""]; + this.chart.comparisonFieldIndex = -1; + } + }, + /** + * @override + */ + _isEmpty() { + return this.chart.dataPoints.length === 0; + }, + /** + * Fetch and process graph data. It is basically a(some) read_group(s) + * with correct fields for each domain. We have to do some light processing + * to separate date groups in the field list, because they can be defined + * with an aggregation function, such as my_date:week. + * + * @private + * @returns {Promise} + */ + _loadGraph: function () { + var self = this; + this.chart.dataPoints = []; + var groupBy = this.chart.processedGroupBy; + var fields = _.map(groupBy, function (groupBy) { + return groupBy.split(':')[0]; + }); + + if (this.chart.measure !== '__count__') { + if (this.fields[this.chart.measure].type === 'many2one') { + fields = fields.concat(this.chart.measure + ":count_distinct"); + } + else { + fields = fields.concat(this.chart.measure); + } + } + + var context = _.extend({fill_temporal: true}, this.chart.context); + + var proms = []; + this.chart.domains.forEach(function (domain, originIndex) { + proms.push(self._rpc({ + model: self.modelName, + method: 'read_group', + context: context, + domain: domain, + fields: fields, + groupBy: groupBy, + lazy: false, + }).then(self._processData.bind(self, originIndex))); + }); + return Promise.all(proms); + }, + /** + * Since read_group is insane and returns its result on different keys + * depending of some input, we have to normalize the result. + * Each group coming from the read_group produces a dataPoint + * + * @todo This is not good for race conditions. The processing should get + * the object this.chart in argument, or an array or something. We want to + * avoid writing on a this.chart object modified by a subsequent read_group + * + * @private + * @param {number} originIndex + * @param {any} rawData result from the read_group + */ + _processData: function (originIndex, rawData) { + var self = this; + var isCount = this.chart.measure === '__count__'; + var labels; + + function getLabels (dataPt) { + return self.chart.processedGroupBy.map(function (field) { + return self._sanitizeValue(dataPt[field], field.split(":")[0]); + }); + } + rawData.forEach(function (dataPt){ + labels = getLabels(dataPt); + var count = dataPt.__count || dataPt[self.chart.processedGroupBy[0]+'_count'] || 0; + var value = isCount ? count : dataPt[self.chart.measure]; + if (value instanceof Array) { + // when a many2one field is used as a measure AND as a grouped + // field, bad things happen. The server will only return the + // grouped value and will not aggregate it. Since there is a + // name clash, we are then in the situation where this value is + // an array. Fortunately, if we group by a field, then we can + // say for certain that the group contains exactly one distinct + // value for that field. + value = 1; + } + self.chart.dataPoints.push({ + resId: dataPt[self.chart.groupBy[0]] instanceof Array ? dataPt[self.chart.groupBy[0]][0] : -1, + count: count, + domain: dataPt.__domain, + value: value, + labels: labels, + originIndex: originIndex, + }); + }); + }, + /** + * Process the groupBy parameter in order to keep only the finer interval option for + * elements based on date/datetime field (e.g. 'date:year'). This means that + * 'week' is prefered to 'month'. The field stays at the place of its first occurence. + * For instance, + * ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar']. + * + * @private + * @param {string[]} groupBy + * @returns {string[]} + */ + _processGroupBy: function(groupBy) { + const groupBysMap = new Map(); + for (const gb of groupBy) { + let [fieldName, interval] = gb.split(':'); + const field = this.fields[fieldName]; + if (['date', 'datetime'].includes(field.type)) { + interval = interval || DEFAULT_INTERVAL; + } + if (groupBysMap.has(fieldName)) { + const registeredInterval = groupBysMap.get(fieldName); + if (rankInterval(registeredInterval) < rankInterval(interval)) { + groupBysMap.set(fieldName, interval); + } + } else { + groupBysMap.set(fieldName, interval); + } + } + return [...groupBysMap].map(([fieldName, interval]) => { + if (interval) { + return `${fieldName}:${interval}`; + } + return fieldName; + }); + }, + /** + * Helper function (for _processData), turns various values in a usable + * string form, that we can display in the interface. + * + * @private + * @param {any} value value for the field fieldName received by the read_group rpc + * @param {string} fieldName + * @returns {string} + */ + _sanitizeValue: function (value, fieldName) { + if (value === false && this.fields[fieldName].type !== 'boolean') { + return _t("Undefined"); + } + if (value instanceof Array) { + return value[1]; + } + if (fieldName && (this.fields[fieldName].type === 'selection')) { + var selected = _.where(this.fields[fieldName].selection, {0: value})[0]; + return selected ? selected[1] : value; + } + return value; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js new file mode 100644 index 00000000..118245f9 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -0,0 +1,1099 @@ +odoo.define('web.GraphRenderer', function (require) { +"use strict"; + +/** + * The graph renderer turns the data from the graph model into a nice looking + * canvas chart. This code uses the Chart.js library. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); +var fieldUtils = require('web.field_utils'); + +var _t = core._t; +var DateClasses = dataComparisonUtils.DateClasses; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'line']; + +var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728", + "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", + "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; +var COLOR_NB = COLORS.length; + +function hexToRGBA(hex, opacity) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + var rgb = result.slice(1, 4).map(function (n) { + return parseInt(n, 16); + }).join(','); + return 'rgba(' + rgb + ',' + opacity + ')'; +} + +// used to format values in tooltips and yAxes. +var FORMAT_OPTIONS = { + // allow to decide if utils.human_number should be used + humanReadable: function (value) { + return Math.abs(value) >= 1000; + }, + // with the choices below, 1236 is represented by 1.24k + minDigits: 1, + decimals: 2, + // avoid comma separators for thousands in numbers when human_number is used + formatterCallback: function (str) { + return str; + }, +}; + +var NO_DATA = [_t('No data')]; +NO_DATA.isNoData = true; + +// hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 4 * (Math.max(1, config.device.size_class)); + +return AbstractRenderer.extend({ + className: "o_graph_renderer", + sampleDataTargets: ['.o_graph_canvas_container'], + /** + * @override + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + * @param {boolean} [params.isEmbedded] + * @param {Object} [params.fields] + * @param {string} [params.title] + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.isEmbedded = params.isEmbedded || false; + this.title = params.title || ''; + this.fields = params.fields || {}; + this.disableLinking = params.disableLinking; + + this.chart = null; + this.chartId = _.uniqueId('chart'); + this.$legendTooltip = null; + this.$tooltip = null; + }, + /** + * Chart.js does not need the canvas to be in dom in order + * to be able to work well. We could avoid the calls to on_attach_callback + * and on_detach_callback. + * + * @override + */ + on_attach_callback: function () { + this._super.apply(this, arguments); + this.isInDOM = true; + this._render(); + }, + /** + * @override + */ + on_detach_callback: function () { + this._super.apply(this, arguments); + this.isInDOM = false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This function aims to remove a suitable number of lines from the tooltip in order to make it reasonably visible. + * A message indicating the number of lines is added if necessary. + * + * @private + * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip + */ + _adjustTooltipHeight: function (maxTooltipHeight) { + var sizeOneLine = this.$tooltip.find('tbody tr')[0].clientHeight; + var tbodySize = this.$tooltip.find('tbody')[0].clientHeight; + var toKeep = Math.floor((maxTooltipHeight - (this.$tooltip[0].clientHeight - tbodySize)) / sizeOneLine) - 1; + var $lines = this.$tooltip.find('tbody tr'); + var toRemove = $lines.length - toKeep; + if (toRemove > 0) { + $lines.slice(toKeep).remove(); + var tr = document.createElement('tr'); + var td = document.createElement('td'); + tr.classList.add('o_show_more'); + td.innerHTML = _t("..."); + tr.appendChild(td); + this.$tooltip.find('tbody').append(tr); + } + }, + /** + * This function creates a custom HTML tooltip. + * + * @private + * @param {Object} tooltipModel see chartjs documentation + */ + _customTooltip: function (tooltipModel) { + this.$el.css({ cursor: 'default' }); + if (this.$tooltip) { + this.$tooltip.remove(); + } + if (tooltipModel.opacity === 0) { + return; + } + if (tooltipModel.dataPoints.length === 0) { + return; + } + + if (this._isRedirectionEnabled()) { + this.$el.css({ cursor: 'pointer' }); + } + + const chartArea = this.chart.chartArea; + const chartAreaLeft = chartArea.left; + const chartAreaRight = chartArea.right; + const chartAreaTop = chartArea.top; + const rendererTop = this.$el[0].getBoundingClientRect().top; + + const maxTooltipLabelWidth = Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px'; + + const tooltipItems = this._getTooltipItems(tooltipModel); + + this.$tooltip = $(qweb.render('GraphView.CustomTooltip', { + measure: this.fields[this.state.measure].string, + tooltipItems: tooltipItems, + maxWidth: maxTooltipLabelWidth, + })).css({top: '2px', left: '2px'}); + const $container = this.$el.find('.o_graph_canvas_container'); + $container.append(this.$tooltip); + + let top; + const tooltipHeight = this.$tooltip[0].clientHeight; + const minTopAllowed = Math.floor(chartAreaTop); + const maxTopAllowed = Math.floor(window.innerHeight - rendererTop - tooltipHeight) - 2; + const y = Math.floor(tooltipModel.y); + if (minTopAllowed <= maxTopAllowed) { + // Here we know that the full tooltip can fit in the screen. + // We put it in the position where Chart.js would put it + // if two conditions are respected: + // 1: the tooltip is not cut (because we know it is possible to not cut it) + // 2: the tooltip does not hide the legend. + // If it is not possible to use the Chart.js proposition (y) + // we use the best approximated value. + if (y <= maxTopAllowed) { + if (y >= minTopAllowed) { + top = y; + } else { + top = minTopAllowed; + } + } else { + top = maxTopAllowed; + } + } else { + // Here we know that we cannot satisfy condition 1 above, + // so we position the tooltip at the minimal position and + // cut it the minimum possible. + top = minTopAllowed; + const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) -2; + this._adjustTooltipHeight(maxTooltipHeight); + } + this.$tooltip[0].style.top = Math.floor(top) + 'px'; + + this._fixTooltipLeftPosition(this.$tooltip[0], tooltipModel.x); + }, + /** + * Filter out some dataPoints because they would lead to bad graphics. + * The filtering is done with respect to the graph view mode. + * Note that the method does not alter this.state.dataPoints, since we + * want to be able to change of mode without fetching data again: + * we simply present the same data in a different way. + * + * @private + * @returns {Object[]} + */ + _filterDataPoints: function () { + var dataPoints = []; + if (_.contains(['bar', 'pie'], this.state.mode)) { + dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + } else if (this.state.mode === 'line') { + var counts = 0; + this.state.dataPoints.forEach(function (dataPt) { + if (dataPt.labels[0] !== _t("Undefined")) { + dataPoints.push(dataPt); + } + counts += dataPt.count; + }); + // data points with zero count might have been created on purpose + // we only remove them if there are no data point with positive count + if (counts === 0) { + dataPoints = []; + } + } + return dataPoints; + }, + /** + * Sets best left position of a tooltip approaching the proposal x + * + * @private + * @param {DOMElement} tooltip + * @param {number} x, left offset proposed + */ + _fixTooltipLeftPosition: function (tooltip, x) { + let left; + const tooltipWidth = tooltip.clientWidth; + const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2); + const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth -2); + x = Math.floor(x); + if (x <= maxLeftAllowed) { + if (x >= minLeftAllowed) { + left = x; + } else { + left = minLeftAllowed; + } + } else { + left = maxLeftAllowed; + } + tooltip.style.left = left + 'px'; + }, + /** + * Used to format correctly the values in tooltips and yAxes + * + * @private + * @param {number} value + * @returns {string} The value formatted using fieldUtils.format.float + */ + _formatValue: function (value) { + var measureField = this.fields[this.state.measure]; + var formatter = fieldUtils.format.float; + var formatedValue = formatter(value, measureField, FORMAT_OPTIONS); + return formatedValue; + }, + /** + * Used any time we need a new color in our charts. + * + * @private + * @param {number} index + * @returns {string} a color in HEX format + */ + _getColor: function (index) { + return COLORS[index % COLOR_NB]; + }, + /** + * Determines the initial section of the labels array + * over a dataset has to be completed. The section only depends + * on the datasets origins. + * + * @private + * @param {number} originIndex + * @param {number} defaultLength + * @returns {number} + */ + _getDatasetDataLength: function (originIndex, defaultLength) { + if (_.contains(['bar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) { + return this.dateClasses.dateSets[originIndex].length; + } + return defaultLength; + }, + /** + * Determines to which dataset belong the data point + * + * @private + * @param {Object} dataPt + * @returns {string} + */ + _getDatasetLabel: function (dataPt) { + if (_.contains(['bar', 'line'], this.state.mode)) { + // ([origin] + second to last groupBys) or measure + var datasetLabel = dataPt.labels.slice(1).join("/"); + if (this.state.origins.length > 1) { + datasetLabel = this.state.origins[dataPt.originIndex] + + (datasetLabel ? ('/' + datasetLabel) : ''); + } + datasetLabel = datasetLabel || this.fields[this.state.measure].string; + return datasetLabel; + } + return this.state.origins[dataPt.originIndex]; + }, + /** + * Returns an object used to style chart elements independently from the datasets. + * + * @private + * @returns {Object} + */ + _getElementOptions: function () { + var elementOptions = {}; + if (this.state.mode === 'bar') { + elementOptions.rectangle = {borderWidth: 1}; + } else if (this.state.mode === 'line') { + elementOptions.line = { + tension: 0, + fill: false, + }; + } + return elementOptions; + }, + /** + * Returns a DateClasses instance used to manage equivalence of dates. + * + * @private + * @param {Object[]} dataPoints + * @returns {DateClasses} + */ + _getDateClasses: function (dataPoints) { + var self = this; + var dateSets = this.state.origins.map(function () { + return []; + }); + dataPoints.forEach(function (dataPt) { + dateSets[dataPt.originIndex].push(dataPt.labels[self.state.comparisonFieldIndex]); + }); + dateSets = dateSets.map(function (dateSet) { + return _.uniq(dateSet); + }); + return new DateClasses(dateSets); + }, + /** + * Determines over which label is the data point + * + * @private + * @param {Object} dataPt + * @returns {Array} + */ + _getLabel: function (dataPt) { + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode)) { + if (i === 0) { + return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])]; + } else { + return dataPt.labels.slice(0, 1); + } + } else if (i === 0) { + return Array.prototype.concat.apply([], [ + this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]), + dataPt.labels.slice(i+1) + ]); + } else { + return dataPt.labels; + } + }, + /** + * Returns the options used to generate the chart legend. + * + * @private + * @param {Number} datasetsCount + * @returns {Object} + */ + _getLegendOptions: function (datasetsCount) { + var legendOptions = { + display: datasetsCount <= MAX_LEGEND_LENGTH, + // position: this.state.mode === 'pie' ? 'right' : 'top', + position: 'top', + onHover: this._onlegendTooltipHover.bind(this), + onLeave: this._onLegendTootipLeave.bind(this), + }; + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + } + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + return data.datasets.map(function (dataset, i) { + return { + text: self._shortenLabel(dataset.label), + fullText: dataset.label, + fillStyle: dataset[referenceColor], + hidden: !chart.isDatasetVisible(i), + lineCap: dataset.borderCapStyle, + lineDash: dataset.borderDash, + lineDashOffset: dataset.borderDashOffset, + lineJoin: dataset.borderJoinStyle, + lineWidth: dataset.borderWidth, + strokeStyle: dataset[referenceColor], + pointStyle: dataset.pointStyle, + datasetIndex: i, + }; + }); + }, + }; + } else { + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + var metaData = data.datasets.map(function (dataset, index) { + return chart.getDatasetMeta(index).data; + }); + return data.labels.map(function (label, i) { + var hidden = metaData.reduce( + function (hidden, data) { + if (data[i]) { + hidden = hidden || data[i].hidden; + } + return hidden; + }, + false + ); + var fullText = self._relabelling(label); + var text = self._shortenLabel(fullText); + return { + text: text, + fullText: fullText, + fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i), + hidden: hidden, + index: i, + }; + }); + }, + }; + } + return legendOptions; + }, + /** + * Returns the options used to generate the chart axes. + * + * @private + * @returns {Object} + */ + _getScaleOptions: function () { + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + return { + xAxes: [{ + type: 'category', + scaleLabel: { + display: this.state.processedGroupBy.length && !this.isEmbedded, + labelString: this.state.processedGroupBy.length ? + this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '', + }, + ticks: { + // don't use bind: callback is called with 'index' as second parameter + // with value labels.indexOf(label)! + callback: function (label) { + return self._relabelling(label); + }, + }, + }], + yAxes: [{ + type: 'linear', + scaleLabel: { + display: !this.isEmbedded, + labelString: this.fields[this.state.measure].string, + }, + ticks: { + callback: this._formatValue.bind(this), + suggestedMax: 0, + suggestedMin: 0, + } + }], + }; + } + return {}; + }, + /** + * Extracts the important information from a tooltipItem generated by Charts.js + * (a tooltip item corresponds to a line (different from measure name) of a tooltip) + * + * @private + * @param {Object} item + * @param {Object} data + * @returns {Object} + */ + _getTooltipItemContent: function (item, data) { + var dataset = data.datasets[item.datasetIndex]; + var label = data.labels[item.index]; + var value; + var boxColor; + if (this.state.mode === 'bar') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.yLabel); + boxColor = dataset.backgroundColor; + } else if (this.state.mode === 'line') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.yLabel); + boxColor = dataset.borderColor; + } else { + if (label.isNoData) { + value = this._formatValue(0); + } else { + value = this._formatValue(dataset.data[item.index]); + } + label = this._relabelling(label, dataset.originIndex); + if (this.state.origins.length > 1) { + label = dataset.label + "/" + label; + } + boxColor = dataset.backgroundColor[item.index]; + } + return { + label: label, + value: value, + boxColor: boxColor, + }; + }, + /** + * This function extracts the information from the data points in tooltipModel.dataPoints + * (corresponding to datapoints over a given label determined by the mouse position) + * that will be displayed in a custom tooltip. + * + * @private + * @param {Object} tooltipModel see chartjs documentation + * @return {Object[]} + */ + _getTooltipItems: function (tooltipModel) { + var self = this; + var data = this.chart.config.data; + + var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) { + return dPt2.yLabel - dPt1.yLabel; + }); + return orderedItems.reduce( + function (acc, item) { + acc.push(self._getTooltipItemContent(item, data)); + return acc; + }, + [] + ); + }, + /** + * Returns the options used to generate chart tooltips. + * + * @private + * @returns {Object} + */ + _getTooltipOptions: function () { + var tooltipOptions = { + // disable Chart.js tooltips + enabled: false, + custom: this._customTooltip.bind(this), + }; + if (this.state.mode === 'line') { + tooltipOptions.mode = 'index'; + tooltipOptions.intersect = false; + } + return tooltipOptions; + }, + /** + * Returns true iff the current graph can be clicked on to redirect to the + * list of records. + * + * @private + * @returns {boolean} + */ + _isRedirectionEnabled: function () { + return !this.disableLinking && + (this.state.mode === 'bar' || this.state.mode === 'pie'); + }, + /** + * Return the first index of the array list where label can be found + * or -1. + * + * @private + * @param {Array[]} list + * @param {Array} label + * @returns {number} + */ + _indexOf: function (list, label) { + var index = -1; + for (var j = 0; j < list.length; j++) { + var otherLabel = list[j]; + if (label.length === otherLabel.length) { + var equal = true; + for (var i = 0; i < label.length; i++) { + if (label[i] !== otherLabel[i]) { + equal = false; + } + } + if (equal) { + index = j; + break; + } + } + } + return index; + }, + /** + * Separate dataPoints coming from the read_group(s) into different datasets. + * This function returns the parameters data and labels used to produce the charts. + * + * @private + * @param {Object[]} dataPoints + * @param {function} getLabel, + * @param {function} getDatasetLabel, determines to which dataset belong a given data point + * @param {function} [getDatasetDataLength], determines the initial section of the labels array + * over which the datasets have to be completed. These sections only depend + * on the datasets origins. Default is the constant function _ => labels.length. + * @returns {Object} the parameter data used to instantiate the chart. + */ + _prepareData: function (dataPoints) { + var self = this; + + var labelMap = {}; + var labels = dataPoints.reduce( + function (acc, dataPt) { + var label = self._getLabel(dataPt); + var labelKey = dataPt.resId + ':' + JSON.stringify(label); + var index = labelMap[labelKey]; + if (index === undefined) { + labelMap[labelKey] = dataPt.labelIndex = acc.length; + acc.push(label); + } + else{ + dataPt.labelIndex = index; + } + return acc; + }, + [] + ); + + var newDataset = function (datasetLabel, originIndex) { + var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0); + const domain = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill([]); + return { + label: datasetLabel, + data: data, + domain: domain, + originIndex: originIndex, + }; + }; + + // dataPoints --> datasets + var datasets = _.values(dataPoints.reduce( + function (acc, dataPt) { + var datasetLabel = self._getDatasetLabel(dataPt); + if (!(datasetLabel in acc)) { + acc[datasetLabel] = newDataset(datasetLabel, dataPt.originIndex); + } + var labelIndex = dataPt.labelIndex; + acc[datasetLabel].data[labelIndex] = dataPt.value; + acc[datasetLabel].domain[labelIndex] = dataPt.domain; + return acc; + }, + {} + )); + + // sort by origin + datasets = datasets.sort(function (dataset1, dataset2) { + return dataset1.originIndex - dataset2.originIndex; + }); + + return { + datasets: datasets, + labels: labels, + }; + }, + /** + * Prepare options for the chart according to the current mode (= chart type). + * This function returns the parameter options used to instantiate the chart + * + * @private + * @param {number} datasetsCount + * @returns {Object} the chart options used for the current mode + */ + _prepareOptions: function (datasetsCount) { + const options = { + maintainAspectRatio: false, + scales: this._getScaleOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(), + elements: this._getElementOptions(), + }; + if (this._isRedirectionEnabled()) { + options.onClick = this._onGraphClicked.bind(this); + } + return options; + }, + /** + * Determine how to relabel a label according to a given origin. + * The idea is that the getLabel function is in general not invertible but + * it is when restricted to the set of dataPoints coming from a same origin. + + * @private + * @param {Array} label + * @param {Array} originIndex + * @returns {string} + */ + _relabelling: function (label, originIndex) { + if (label.isNoData) { + return label[0]; + } + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode) && i === 0) { + // here label is an array of length 1 and contains a number + return this.dateClasses.representative(label, originIndex) || ''; + } else if (this.state.mode === 'pie' && i === 0) { + // here label is an array of length at least one containing string or numbers + var labelCopy = label.slice(0); + if (originIndex !== undefined) { + labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex)); + } else { + labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i])); + } + return labelCopy.join('/'); + } + // here label is an array containing strings or numbers. + return label.join('/') || _t('Total'); + }, + /** + * Render the chart or display a message error in case data is not good enough. + * + * Note that This method is synchronous, but the actual rendering is done + * asynchronously. The reason for that is that Chart.js needs to be in the + * DOM to correctly render itself. So, we trick Odoo by returning + * immediately, then we render the chart when the widget is in the DOM. + * + * @override + */ + async _renderView() { + if (this.chart) { + this.chart.destroy(); + } + this.$el.empty(); + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } + var dataPoints = this._filterDataPoints(); + dataPoints = this._sortDataPoints(dataPoints); + if (this.isInDOM) { + this._renderTitle(); + + // detect if some pathologies are still present after the filtering + if (this.state.mode === 'pie') { + const someNegative = dataPoints.some(dataPt => dataPt.value < 0); + const somePositive = dataPoints.some(dataPt => dataPt.value > 0); + if (someNegative && somePositive) { + const context = { + title: _t("Invalid data"), + description: [ + _t("Pie chart cannot mix positive and negative numbers. "), + _t("Try to change your domain to only display positive results") + ].join("") + }; + this._renderNoContentHelper(context); + return; + } + } + + if (this.state.isSample && !this.isEmbedded) { + this._renderNoContentHelper(); + } + + // only render the graph if the widget is already in the DOM (this + // happens typically after an update), otherwise, it will be + // rendered when the widget will be attached to the DOM (see + // 'on_attach_callback') + var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'}); + var $canvas = $('<canvas/>').attr('id', this.chartId); + $canvasContainer.append($canvas); + this.$el.append($canvasContainer); + + var i = this.state.comparisonFieldIndex; + if (i === 0) { + this.dateClasses = this._getDateClasses(dataPoints); + } + if (this.state.mode === 'bar') { + this._renderBarChart(dataPoints); + } else if (this.state.mode === 'line') { + this._renderLineChart(dataPoints); + } else if (this.state.mode === 'pie') { + this._renderPieChart(dataPoints); + } + } + }, + /** + * create bar chart. + * + * @private + * @param {Object[]} dataPoints + */ + _renderBarChart: function (dataPoints) { + var self = this; + + // prepare data + var data = this._prepareData(dataPoints); + + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + var color = self._getColor(index); + dataset.backgroundColor = color; + }); + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'bar', + data: data, + options: options, + }); + }, + /** + * create line chart. + * + * @private + * @param {Object[]} dataPoints + */ + _renderLineChart: function (dataPoints) { + var self = this; + + // prepare data + var data = this._prepareData(dataPoints); + data.datasets.forEach(function (dataset, index) { + if (self.state.processedGroupBy.length <= 1 && self.state.origins.length > 1) { + if (dataset.originIndex === 0) { + dataset.fill = 'origin'; + dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4); + dataset.borderColor = hexToRGBA(COLORS[0], 1); + } else if (dataset.originIndex === 1) { + dataset.borderColor = hexToRGBA(COLORS[1], 1); + } else { + dataset.borderColor = self._getColor(index); + } + } else { + dataset.borderColor = self._getColor(index); + } + if (data.labels.length === 1) { + // shift of the real value to right. This is done to center the points in the chart + // See data.labels below in Chart parameters + dataset.data.unshift(undefined); + } + dataset.pointBackgroundColor = dataset.borderColor; + dataset.pointBorderColor = 'rgba(0,0,0,0.2)'; + }); + if (data.datasets.length === 1) { + const dataset = data.datasets[0]; + dataset.fill = 'origin'; + dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4); + } + + // center the points in the chart (without that code they are put on the left and the graph seems empty) + data.labels = data.labels.length > 1 ? + data.labels : + Array.prototype.concat.apply([], [[['']], data.labels, [['']]]); + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'line', + data: data, + options: options, + }); + }, + /** + * create pie chart + * + * @private + * @param {Object[]} dataPoints + */ + _renderPieChart: function (dataPoints) { + var self = this; + // prepare data + var data = {}; + var colors = []; + const allZero = dataPoints.every(dataPt => dataPt.value === 0); + if (allZero) { + // add fake data to display a pie chart with a grey zone associated + // with every origin + data.labels = [NO_DATA]; + data.datasets = this.state.origins.map(function (origin) { + return { + label: origin, + data: [1], + backgroundColor: ['#d3d3d3'], + }; + }); + } else { + data = this._prepareData(dataPoints); + // give same color to same groups from different origins + colors = data.labels.map(function (label, index) { + return self._getColor(index); + }); + data.datasets.forEach(function (dataset) { + dataset.backgroundColor = colors; + dataset.borderColor = 'rgba(255,255,255,0.6)'; + }); + // make sure there is a zone associated with every origin + var representedOriginIndexes = data.datasets.map(function (dataset) { + return dataset.originIndex; + }); + var addNoDataToLegend = false; + var fakeData = (new Array(data.labels.length)).concat([1]); + this.state.origins.forEach(function (origin, originIndex) { + if (!_.contains(representedOriginIndexes, originIndex)) { + data.datasets.splice(originIndex, 0, { + label: origin, + data: fakeData, + backgroundColor: colors.concat(['#d3d3d3']), + }); + addNoDataToLegend = true; + } + }); + if (addNoDataToLegend) { + data.labels.push(NO_DATA); + } + } + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'pie', + data: data, + options: options, + }); + }, + /** + * Add the graph title (if any) above the canvas + * + * @private + */ + _renderTitle: function () { + if (this.title) { + this.$el.prepend($('<label/>', { + text: this.title, + })); + } + }, + /** + * Used to avoid too long legend items + * + * @private + * @param {string} label + * @returns {string} shortened version of the input label + */ + _shortenLabel: function (label) { + // string returned could be 'wrong' if a groupby value contain a '/'! + var groups = label.split("/"); + var shortLabel = groups.slice(0, 3).join("/"); + if (shortLabel.length > 30) { + shortLabel = shortLabel.slice(0, 30) + '...'; + } else if (groups.length > 3) { + shortLabel = shortLabel + '/...'; + } + return shortLabel; + }, + /** + * Sort datapoints according to the current order (ASC or DESC). + * + * Note: this should be moved to the model at some point. + * + * @private + * @param {Object[]} dataPoints + * @returns {Object[]} sorted dataPoints if orderby set on state + */ + _sortDataPoints(dataPoints) { + if (!Object.keys(this.state.timeRanges).length && this.state.orderBy && + ['bar', 'line'].includes(this.state.mode) && this.state.groupBy.length) { + // group data by their x-axis value, and then sort datapoints + // based on the sum of values by group in ascending/descending order + const groupByFieldName = this.state.groupBy[0].split(':')[0]; + const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one'; + const groupedDataPoints = {}; + dataPoints.forEach(function (dataPoint) { + const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0]; + groupedDataPoints[key] = groupedDataPoints[key] || []; + groupedDataPoints[key].push(dataPoint); + }); + dataPoints = _.sortBy(groupedDataPoints, function (group) { + return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0); + }); + dataPoints = dataPoints.flat(); + if (this.state.orderBy === 'desc') { + dataPoints = dataPoints.reverse('value'); + } + } + return dataPoints; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onGraphClicked: function (ev) { + const activeElement = this.chart.getElementAtEvent(ev); + if (activeElement.length === 0) { + return; + } + const domain = this.chart.data.datasets[activeElement[0]._datasetIndex].domain; + if (!domain) { + return; // empty dataset + } + this.trigger_up('open_view', { + domain: domain[activeElement[0]._index], + }); + }, + /** + * If the text of a legend item has been shortened and the user mouse over + * that item (actually the event type is mousemove), a tooltip with the item + * full text is displayed. + * + * @private + * @param {MouseEvent} e + * @param {Object} legendItem + */ + _onlegendTooltipHover: function (e, legendItem) { + // set cursor pointer on hover of legend + e.target.style.cursor = 'pointer'; + // The string legendItem.text is an initial segment of legendItem.fullText. + // If the two coincide, no need to generate a tooltip. + // If a tooltip for the legend already exists, it is already good and don't need + // to be recreated. + if (legendItem.text === legendItem.fullText || this.$legendTooltip) { + return; + } + + const chartAreaLeft = this.chart.chartArea.left; + const chartAreaRight = this.chart.chartArea.right; + const rendererTop = this.$el[0].getBoundingClientRect().top; + + this.$legendTooltip = $('<div>', { + class: "o_tooltip_legend", + text: legendItem.fullText, + css: { + maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px', + top: (e.clientY - rendererTop) + 'px', + } + }); + const $container = this.$el.find('.o_graph_canvas_container'); + $container.append(this.$legendTooltip); + + this._fixTooltipLeftPosition(this.$legendTooltip[0], e.clientX); + }, + /** + * If there's a legend tooltip and the user mouse out of the corresponding + * legend item, the tooltip is removed. + * + * @private + */ + _onLegendTootipLeave: function (e) { + // remove cursor style pointer on mouseleave from legend + e.target.style.cursor = ""; + if (this.$legendTooltip) { + this.$legendTooltip.remove(); + this.$legendTooltip = null; + } + }, +}); +}); diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js new file mode 100644 index 00000000..759817c8 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -0,0 +1,162 @@ +odoo.define('web.GraphView', function (require) { +"use strict"; + +/** + * The Graph View is responsible to display a graphical (meaning: chart) + * representation of the current dataset. As of now, it is currently able to + * display data in three types of chart: bar chart, line chart and pie chart. + */ + +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var GraphModel = require('web.GraphModel'); +var Controller = require('web.GraphController'); +var GraphRenderer = require('web.GraphRenderer'); + +var _t = core._t; +var _lt = core._lt; + +var searchUtils = require('web.searchUtils'); +var GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES; + +var GraphView = AbstractView.extend({ + display_name: _lt('Graph'), + icon: 'fa-bar-chart', + jsLibs: [ + '/web/static/lib/Chart/Chart.js', + ], + config: _.extend({}, AbstractView.prototype.config, { + Model: GraphModel, + Controller: Controller, + Renderer: GraphRenderer, + }), + viewType: 'graph', + searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'], + + /** + * @override + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + const additionalMeasures = params.additionalMeasures || []; + let measure; + const measures = {}; + const measureStrings = {}; + let groupBys = []; + const groupableFields = {}; + this.fields.__count__ = { string: _t("Count"), type: 'integer' }; + + this.arch.children.forEach(field => { + let fieldName = field.attrs.name; + if (fieldName === "id") { + return; + } + const interval = field.attrs.interval; + if (interval) { + fieldName = fieldName + ':' + interval; + } + if (field.attrs.type === 'measure') { + const { string } = this.fields[fieldName]; + measure = fieldName; + measures[fieldName] = { + description: string, + fieldName, + groupNumber: 0, + isActive: false, + itemType: 'measure', + }; + } else { + groupBys.push(fieldName); + } + if (field.attrs.string) { + measureStrings[fieldName] = field.attrs.string; + } + }); + + for (const name in this.fields) { + const field = this.fields[name]; + if (name !== 'id' && field.store === true) { + if ( + ['integer', 'float', 'monetary'].includes(field.type) || + additionalMeasures.includes(name) + ) { + measures[name] = { + description: field.string, + fieldName: name, + groupNumber: 0, + isActive: false, + itemType: 'measure', + }; + } + if (GROUPABLE_TYPES.includes(field.type)) { + groupableFields[name] = field; + } + } + } + for (const name in measureStrings) { + if (measures[name]) { + measures[name].description = measureStrings[name]; + } + } + + // Remove invisible fields from the measures + this.arch.children.forEach(field => { + let fieldName = field.attrs.name; + if (field.attrs.invisible && py.eval(field.attrs.invisible)) { + groupBys = groupBys.filter(groupBy => groupBy !== fieldName); + if (fieldName in groupableFields) { + delete groupableFields[fieldName]; + } + if (!additionalMeasures.includes(fieldName)) { + delete measures[fieldName]; + } + } + }); + + const sortedMeasures = Object.values(measures).sort((a, b) => { + const descA = a.description.toLowerCase(); + const descB = b.description.toLowerCase(); + return descA > descB ? 1 : descA < descB ? -1 : 0; + }); + const countMeasure = { + description: _t("Count"), + fieldName: '__count__', + groupNumber: 1, + isActive: false, + itemType: 'measure', + }; + this.controllerParams.withButtons = params.withButtons !== false; + this.controllerParams.measures = [...sortedMeasures, countMeasure]; + this.controllerParams.groupableFields = groupableFields; + this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled"); + // retrieve form and list view ids from the action to open those views + // when the graph is clicked + function _findView(views, viewType) { + const view = views.find(view => { + return view.type === viewType; + }); + return [view ? view.viewID : false, viewType]; + } + this.controllerParams.views = [ + _findView(params.actionViews, 'list'), + _findView(params.actionViews, 'form'), + ]; + + this.rendererParams.fields = this.fields; + this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead + this.rendererParams.disableLinking = !!JSON.parse(this.arch.attrs.disable_linking || '0'); + + this.loadParams.mode = this.arch.attrs.type || 'bar'; + this.loadParams.orderBy = this.arch.attrs.order; + this.loadParams.measure = measure || '__count__'; + this.loadParams.groupBys = groupBys; + this.loadParams.fields = this.fields; + this.loadParams.comparisonDomain = params.comparisonDomain; + this.loadParams.stacked = this.arch.attrs.stacked !== "False"; + }, +}); + +return GraphView; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js new file mode 100644 index 00000000..4aeb5404 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -0,0 +1,411 @@ +odoo.define('web.KanbanColumn', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); +var Dialog = require('web.Dialog'); +var KanbanRecord = require('web.KanbanRecord'); +var RecordQuickCreate = require('web.kanban_record_quick_create'); +var view_dialogs = require('web.view_dialogs'); +var viewUtils = require('web.viewUtils'); +var Widget = require('web.Widget'); +var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); + +var _t = core._t; +var QWeb = core.qweb; + +var KanbanColumn = Widget.extend({ + template: 'KanbanView.Group', + custom_events: { + cancel_quick_create: '_onCancelQuickCreate', + quick_create_add_record: '_onQuickCreateAddRecord', + tweak_column: '_onTweakColumn', + tweak_column_records: '_onTweakColumnRecords', + }, + events: { + 'click .o_column_edit': '_onEditColumn', + 'click .o_column_delete': '_onDeleteColumn', + 'click .o_kanban_quick_add': '_onAddQuickCreate', + 'click .o_kanban_load_more': '_onLoadMore', + 'click .o_kanban_toggle_fold': '_onToggleFold', + 'click .o_column_archive_records': '_onArchiveRecords', + 'click .o_column_unarchive_records': '_onUnarchiveRecords', + 'click .o_kanban_config .dropdown-menu': '_onConfigDropdownClicked', + }, + /** + * @override + */ + init: function (parent, data, options, recordOptions) { + this._super(parent); + this.db_id = data.id; + this.data_records = data.data; + this.data = data; + + var value = data.value; + this.id = data.res_id; + this.folded = !data.isOpen; + this.has_active_field = 'active' in data.fields; + this.fields = data.fields; + this.records = []; + this.modelName = data.model; + + this.quick_create = options.quick_create; + this.quickCreateView = options.quickCreateView; + this.groupedBy = options.groupedBy; + this.grouped_by_m2o = options.grouped_by_m2o; + this.editable = options.editable; + this.deletable = options.deletable; + this.archivable = options.archivable; + this.draggable = options.draggable; + this.KanbanRecord = options.KanbanRecord || KanbanRecord; // the KanbanRecord class to use + this.records_editable = options.records_editable; + this.records_deletable = options.records_deletable; + this.recordsDraggable = options.recordsDraggable; + this.relation = options.relation; + this.offset = 0; + this.remaining = data.count - this.data_records.length; + this.canBeFolded = this.folded; + + if (options.hasProgressBar) { + this.barOptions = { + columnID: this.db_id, + progressBarStates: options.progressBarStates, + }; + } + + this.record_options = _.clone(recordOptions); + + if (options.grouped_by_m2o || options.grouped_by_date ) { + // For many2one and datetime, a false value means that the field is not set. + this.title = value ? value : _t('Undefined'); + } else { + // False and 0 might be valid values for these fields. + this.title = value === undefined ? _t('Undefined') : value; + } + + if (options.group_by_tooltip) { + this.tooltipInfo = _.compact(_.map(options.group_by_tooltip, function (help, field) { + help = help ? help + "</br>" : ''; + return (data.tooltipData && data.tooltipData[field] && "<div>" + help + data.tooltipData[field] + "</div>") || ''; + })); + this.tooltipInfo = this.tooltipInfo.join("<div class='dropdown-divider' role='separator' />"); + } + }, + /** + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + this.$header = this.$('.o_kanban_header'); + + for (var i = 0; i < this.data_records.length; i++) { + defs.push(this._addRecord(this.data_records[i])); + } + + if (this.recordsDraggable) { + this.$el.sortable({ + connectWith: '.o_kanban_group', + containment: this.draggable ? false : 'parent', + revert: 0, + delay: 0, + items: '> .o_kanban_record:not(.o_updating)', + cursor: 'move', + over: function () { + self.$el.addClass('o_kanban_hover'); + }, + out: function () { + self.$el.removeClass('o_kanban_hover'); + }, + start: function (event, ui) { + ui.item.addClass('o_currently_dragged'); + }, + stop: function (event, ui) { + var item = ui.item; + setTimeout(function () { + item.removeClass('o_currently_dragged'); + }); + }, + update: function (event, ui) { + var record = ui.item.data('record'); + var index = self.records.indexOf(record); + record.$el.removeAttr('style'); // jqueryui sortable add display:block inline + if (index >= 0) { + if ($.contains(self.$el[0], record.$el[0])) { + // resequencing records + self.trigger_up('kanban_column_resequence', {ids: self._getIDs()}); + } + } else { + // adding record to this column + ui.item.addClass('o_updating'); + self.trigger_up('kanban_column_add_record', {record: record, ids: self._getIDs()}); + } + } + }); + } + this.$el.click(function (event) { + if (self.folded) { + self._onToggleFold(event); + } + }); + if (this.barOptions) { + this.$el.addClass('o_kanban_has_progressbar'); + this.progressBar = new KanbanColumnProgressBar(this, this.barOptions, this.data); + defs.push(this.progressBar.appendTo(this.$header)); + } + + var title = this.folded ? this.title + ' (' + this.data.count + ')' : this.title; + this.$header.find('.o_column_title').text(title); + + this.$el.toggleClass('o_column_folded', this.canBeFolded); + if (this.tooltipInfo) { + this.$header.find('.o_kanban_header_title').tooltip({}).attr('data-original-title', this.tooltipInfo); + } + if (!this.remaining) { + this.$('.o_kanban_load_more').remove(); + } else { + this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this})); + } + + return Promise.all(defs); + }, + /** + * Called when a record has been quick created, as a new column is rendered + * and appended into a fragment, before replacing the old column in the DOM. + * When this happens, the quick create widget is inserted into the new + * column directly, and it should be focused. However, as it is rendered + * into a fragment, the focus has to be set manually once in the DOM. + */ + on_attach_callback: function () { + _.invoke(this.records, 'on_attach_callback'); + if (this.quickCreateWidget) { + this.quickCreateWidget.on_attach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds the quick create record to the top of the column. + * + * @returns {Promise} + */ + addQuickCreate: async function () { + if (this.folded) { + // first open the column, and then add the quick create + this.trigger_up('column_toggle_fold', { + openQuickCreate: true, + }); + return; + } + + if (this.quickCreateWidget) { + return Promise.reject(); + } + this.trigger_up('close_quick_create'); // close other quick create widgets + var context = this.data.getContext(); + context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy); + this.quickCreateWidget = new RecordQuickCreate(this, { + context: context, + formViewRef: this.quickCreateView, + model: this.modelName, + }); + await this.quickCreateWidget.appendTo(document.createDocumentFragment()); + this.trigger_up('start_quick_create'); + this.quickCreateWidget.$el.insertAfter(this.$header); + this.quickCreateWidget.on_attach_callback(); + }, + /** + * Closes the quick create widget if it isn't dirty. + */ + cancelQuickCreate: function () { + if (this.quickCreateWidget) { + this.quickCreateWidget.cancel(); + } + }, + /** + * @returns {Boolean} true iff the column is empty + */ + isEmpty: function () { + return !this.records.length; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a record in the column. + * + * @private + * @param {Object} recordState + * @param {Object} [options] + * @param {string} [options.position] + * 'before' to add at the top, add at the bottom by default + * @return {Promise} + */ + _addRecord: function (recordState, options) { + var record = new this.KanbanRecord(this, recordState, this.record_options); + this.records.push(record); + if (options && options.position === 'before') { + return record.insertAfter(this.quickCreateWidget ? this.quickCreateWidget.$el : this.$header); + } else { + var $load_more = this.$('.o_kanban_load_more'); + if ($load_more.length) { + return record.insertBefore($load_more); + } else { + return record.appendTo(this.$el); + } + } + }, + /** + * Destroys the QuickCreate widget. + * + * @private + */ + _cancelQuickCreate: function () { + this.quickCreateWidget.destroy(); + this.quickCreateWidget = undefined; + }, + /** + * @returns {integer[]} the res_ids of the records in the column + */ + _getIDs: function () { + var ids = []; + this.$('.o_kanban_record').each(function (index, r) { + ids.push($(r).data('record').id); + }); + return ids; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddQuickCreate: function () { + this.trigger_up('add_quick_create', { groupId: this.db_id }); + }, + /** + * @private + */ + _onCancelQuickCreate: function () { + this._cancelQuickCreate(); + }, + /** + * Prevent from closing the config dropdown when the user clicks on a + * disabled item (e.g. 'Fold' in sample mode). + * + * @private + */ + _onConfigDropdownClicked(ev) { + ev.stopPropagation(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onDeleteColumn: function (event) { + event.preventDefault(); + var buttons = [ + { + text: _t("Ok"), + classes: 'btn-primary', + close: true, + click: this.trigger_up.bind(this, 'kanban_column_delete'), + }, + {text: _t("Cancel"), close: true} + ]; + new Dialog(this, { + size: 'medium', + buttons: buttons, + $content: $('<div>', { + text: _t("Are you sure that you want to remove this column ?") + }), + }).open(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onEditColumn: function (event) { + event.preventDefault(); + new view_dialogs.FormViewDialog(this, { + res_model: this.relation, + res_id: this.id, + context: session.user_context, + title: _t("Edit Column"), + on_saved: this.trigger_up.bind(this, 'reload'), + }).open(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onLoadMore: function (event) { + event.preventDefault(); + this.trigger_up('kanban_load_more'); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onQuickCreateAddRecord: function (event) { + this.trigger_up('quick_create_record', event.data); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onToggleFold: function (event) { + event.preventDefault(); + this.trigger_up('column_toggle_fold'); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onTweakColumn: function (ev) { + ev.data.callback(this.$el); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onTweakColumnRecords: function (ev) { + _.each(this.records, function (record) { + ev.data.callback(record.$el, record.state.data); + }); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onArchiveRecords: function (event) { + event.preventDefault(); + Dialog.confirm(this, _t("Are you sure that you want to archive all the records from this column?"), { + confirm_callback: this.trigger_up.bind(this, 'kanban_column_records_toggle_active', { + archive: true, + }), + }); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onUnarchiveRecords: function (event) { + event.preventDefault(); + this.trigger_up('kanban_column_records_toggle_active', { + archive: false, + }); + } +}); + +return KanbanColumn; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js new file mode 100644 index 00000000..752d2b2d --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js @@ -0,0 +1,288 @@ +odoo.define('web.KanbanColumnProgressBar', function (require) { +'use strict'; + +const core = require('web.core'); +var session = require('web.session'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +const _t = core._t; + +var KanbanColumnProgressBar = Widget.extend({ + template: 'KanbanView.ColumnProgressBar', + events: { + 'click .o_kanban_counter_progress': '_onProgressBarParentClick', + 'click .progress-bar': '_onProgressBarClick', + }, + /** + * Allows to disable animations for tests. + * @type {boolean} + */ + ANIMATE: true, + + /** + * @constructor + */ + init: function (parent, options, columnState) { + this._super.apply(this, arguments); + + this.columnID = options.columnID; + this.columnState = columnState; + + // <progressbar/> attributes + this.fieldName = columnState.progressBarValues.field; + this.colors = _.extend({}, columnState.progressBarValues.colors, { + __false: 'muted', // color to use for false value + }); + this.sumField = columnState.progressBarValues.sum_field; + + // Previous progressBar state + var state = options.progressBarStates[this.columnID]; + if (state) { + this.groupCount = state.groupCount; + this.subgroupCounts = state.subgroupCounts; + this.totalCounterValue = state.totalCounterValue; + this.activeFilter = state.activeFilter; + } + + // Prepare currency (TODO this should be automatic... use a field ?) + var sumFieldInfo = this.sumField && columnState.fieldsInfo.kanban[this.sumField]; + var currencyField = sumFieldInfo && sumFieldInfo.options && sumFieldInfo.options.currency_field; + if (currencyField && columnState.data.length) { + this.currency = session.currencies[columnState.data[0].data[currencyField].res_id]; + } + }, + /** + * @override + */ + start: function () { + var self = this; + + this.$bars = {}; + _.each(this.colors, function (val, key) { + self.$bars[key] = self.$(`.progress-bar[data-filter=${key}]`); + }); + this.$counter = this.$('.o_kanban_counter_side'); + this.$number = this.$counter.find('b'); + + if (this.currency) { + var $currency = $('<span/>', { + text: this.currency.symbol, + }); + if (this.currency.position === 'before') { + $currency.prependTo(this.$counter); + } else { + $currency.appendTo(this.$counter); + } + } + + return this._super.apply(this, arguments).then(function () { + // This should be executed when the progressbar is fully rendered + // and is in the DOM, this happens to be always the case with + // current use of progressbars + self.computeCounters(); + self._notifyState(); + self._render(); + }); + }, + /** + * Computes the count of each sub group and the total count + */ + computeCounters() { + const subgroupCounts = {}; + let allSubgroupCount = 0; + for (const key of Object.keys(this.colors)) { + const subgroupCount = this.columnState.progressBarValues.counts[key] || 0; + if (this.activeFilter === key && subgroupCount === 0) { + this.activeFilter = false; + } + subgroupCounts[key] = subgroupCount; + allSubgroupCount += subgroupCount; + }; + subgroupCounts.__false = this.columnState.count - allSubgroupCount; + + this.groupCount = this.columnState.count; + this.subgroupCounts = subgroupCounts; + this.prevTotalCounterValue = this.totalCounterValue; + this.totalCounterValue = this.sumField ? (this.columnState.aggregateValues[this.sumField] || 0) : this.columnState.count; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates the rendering according to internal data. This is done without + * qweb rendering because there are animations. + * + * @private + */ + _render: function () { + var self = this; + + // Update column display according to active filter + this.trigger_up('tweak_column', { + callback: function ($el) { + $el.removeClass('o_kanban_group_show'); + _.each(self.colors, function (val, key) { + $el.removeClass('o_kanban_group_show_' + val); + }); + if (self.activeFilter) { + $el.addClass('o_kanban_group_show o_kanban_group_show_' + self.colors[self.activeFilter]); + } + }, + }); + this.trigger_up('tweak_column_records', { + callback: function ($el, recordData) { + var categoryValue = recordData[self.fieldName] ? recordData[self.fieldName] : '__false'; + _.each(self.colors, function (val, key) { + $el.removeClass('oe_kanban_card_' + val); + }); + if (self.colors[categoryValue]) { + $el.addClass('oe_kanban_card_' + self.colors[categoryValue]); + } + }, + }); + + // Display and animate the progress bars + var barNumber = 0; + var barMinWidth = 6; // In % + const selection = self.columnState.fields[self.fieldName].selection; + _.each(self.colors, function (val, key) { + var $bar = self.$bars[key]; + var count = self.subgroupCounts && self.subgroupCounts[key] || 0; + + if (!$bar) { + return; + } + + // Adapt tooltip + let value; + if (selection) { // progressbar on a field of type selection + const option = selection.find(option => option[0] === key); + value = option && option[1] || _t('Other'); + } else { + value = key; + } + $bar.attr('data-original-title', count + ' ' + value); + $bar.tooltip({ + delay: 0, + trigger: 'hover', + }); + + // Adapt active state + $bar.toggleClass('progress-bar-animated progress-bar-striped', key === self.activeFilter); + + // Adapt width + $bar.removeClass('o_bar_has_records transition-off'); + window.getComputedStyle($bar[0]).getPropertyValue('width'); // Force reflow so that animations work + if (count > 0) { + $bar.addClass('o_bar_has_records'); + // Make sure every bar that has records has some space + // and that everything adds up to 100% + var maxWidth = 100 - barMinWidth * barNumber; + self.$('.progress-bar.o_bar_has_records').css('max-width', maxWidth + '%'); + $bar.css('width', (count * 100 / self.groupCount) + '%'); + barNumber++; + $bar.attr('aria-valuemin', 0); + $bar.attr('aria-valuemax', self.groupCount); + $bar.attr('aria-valuenow', count); + } else { + $bar.css('width', ''); + } + }); + this.$('.progress-bar').css('min-width', ''); + this.$('.progress-bar.o_bar_has_records').css('min-width', barMinWidth + '%'); + + // Display and animate the counter number + var start = this.prevTotalCounterValue; + var end = this.totalCounterValue; + + if (this.activeFilter) { + if (this.sumField) { + end = 0; + _.each(self.columnState.data, function (record) { + var recordData = record.data; + if (self.activeFilter === recordData[self.fieldName] || + (self.activeFilter === '__false' && !recordData[self.fieldName])) { + end += parseFloat(recordData[self.sumField]); + } + }); + } else { + end = this.subgroupCounts[this.activeFilter]; + } + } + this.prevTotalCounterValue = end; + var animationClass = start > 999 ? 'o_kanban_grow' : 'o_kanban_grow_huge'; + + if (start !== undefined && (end > start || this.activeFilter) && this.ANIMATE) { + $({currentValue: start}).animate({currentValue: end}, { + duration: 1000, + start: function () { + self.$counter.addClass(animationClass); + }, + step: function () { + self.$number.html(_getCounterHTML(this.currentValue)); + }, + complete: function () { + self.$number.html(_getCounterHTML(this.currentValue)); + self.$counter.removeClass(animationClass); + }, + }); + } else { + this.$number.html(_getCounterHTML(end)); + } + + function _getCounterHTML(value) { + return utils.human_number(value, 0, 3); + } + }, + /** + * Notifies the new progressBar state so that if a full rerender occurs, the + * new progressBar that would replace this one will be initialized with + * current state, so that animations are correct. + * + * @private + */ + _notifyState: function () { + this.trigger_up('set_progress_bar_state', { + columnID: this.columnID, + values: { + groupCount: this.groupCount, + subgroupCounts: this.subgroupCounts, + totalCounterValue: this.totalCounterValue, + activeFilter: this.activeFilter, + }, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onProgressBarClick: function (ev) { + this.$clickedBar = $(ev.currentTarget); + var filter = this.$clickedBar.data('filter'); + this.activeFilter = (this.activeFilter === filter ? false : filter); + this._notifyState(); + this._render(); + }, + /** + * @private + * @param {Event} ev + */ + _onProgressBarParentClick: function (ev) { + if (ev.target !== ev.currentTarget) { + return; + } + this.activeFilter = false; + this._notifyState(); + this._render(); + }, +}); +return KanbanColumnProgressBar; +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js new file mode 100644 index 00000000..c4bed5fa --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js @@ -0,0 +1,246 @@ +odoo.define('web.kanban_column_quick_create', function (require) { +"use strict"; + +/** + * This file defines the ColumnQuickCreate widget for Kanban. It allows to + * create kanban columns directly from the Kanban view. + */ + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); + +var _t = core._t; +var QWeb = core.qweb; + +var ColumnQuickCreate = Widget.extend({ + template: 'KanbanView.ColumnQuickCreate', + events: { + 'click .o_quick_create_folded': '_onUnfold', + 'click .o_kanban_add': '_onAddClicked', + 'click .o_kanban_examples': '_onShowExamples', + 'keydown': '_onKeydown', + 'keypress input': '_onKeypress', + 'blur input': '_onInputBlur', + 'focus input': '_onInputFocus', + }, + + /** + * @override + * @param {Object} [options] + * @param {Object} [options.examples] + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.applyExamplesText = options.applyExampleText || _t("Use This For My Kanban"); + this.examples = options.examples; + this.folded = true; + this.isMobile = false; + }, + /** + * @override + */ + start: function () { + this.$quickCreateFolded = this.$('.o_quick_create_folded'); + this.$quickCreateUnfolded = this.$('.o_quick_create_unfolded'); + this.$input = this.$('input'); + + // destroy the quick create when the user clicks outside + core.bus.on('click', this, this._onWindowClicked); + + this._update(); + + return this._super.apply(this, arguments); + }, + /** + * Called each time the quick create is attached into the DOM + */ + on_attach_callback: function () { + if (!this.folded) { + this.$input.focus(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Folds/unfolds the Column quick create widget + */ + toggleFold: function () { + this.folded = !this.folded; + this._update(); + if (!this.folded) { + this.$input.focus(); + this.trigger_up('scrollTo', {selector: '.o_column_quick_create'}); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Clears the input value and notify the environment to create a column + * + * @private + */ + _add: function () { + var value = this.$input.val().trim(); + if (!value.length) { + this._cancel(); + return; + } + this.$input.val(''); + this.trigger_up('quick_create_add_column', {value: value}); + this.$input.focus(); + }, + /** + * Cancels the quick creation + * + * @private + */ + _cancel: function () { + if (!this.folded) { + this.$input.val(''); + this.folded = true; + this._update(); + } + }, + /** + * Updates the rendering according to the current state (folded/unfolded) + * + * @private + */ + _update: function () { + this.$quickCreateFolded.toggle(this.folded); + this.$quickCreateUnfolded.toggle(!this.folded); + this.trigger_up('quick_create_column_updated'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onAddClicked: function (event) { + event.stopPropagation(); + this._add(); + }, + /** + * When the input is not focused, no key event may occur in the column, so + * the discard feature will not work by pressing ESC. We simply hide the + * help message in that case, so we do not mislead our users. + * + * @private + * @param {KeyboardEvent} event + */ + _onInputBlur: function () { + this.$('.o_discard_msg').hide(); + }, + /** + * When the input is focused, we need to show the discard help message (it + * might have been hidden, @see _onInputBlur) + * + * @private + * @param {KeyboardEvent} event + */ + _onInputFocus: function () { + this.$('.o_discard_msg').show(); + }, + /** + * Cancels quick creation on escape keydown event + * + * @private + * @param {KeyEvent} event + */ + _onKeydown: function (event) { + if (event.keyCode === $.ui.keyCode.ESCAPE) { + this._cancel(); + } + }, + /** + * Validates quick creation on enter keypress event + * + * @private + * @param {KeyEvent} event + */ + _onKeypress: function (event) { + if (event.keyCode === $.ui.keyCode.ENTER) { + this._add(); + } + }, + /** + * Opens a dialog containing examples of Kanban processes + * + * @private + */ + _onShowExamples: function () { + var self = this; + var dialog = new Dialog(this, { + $content: $(QWeb.render('KanbanView.ExamplesDialog', { + examples: this.examples, + })), + buttons: [{ + classes: 'btn-primary float-right', + text: this.applyExamplesText, + close: true, + click: function () { + const activeExample = self.examples[this.$('.nav-link.active').data("exampleIndex")]; + activeExample.columns.forEach(column => { + self.trigger_up('quick_create_add_column', { value: column.toString(), foldQuickCreate: true }); + }); + } + }, { + classes: 'btn-secondary float-right', + close: true, + text: _t('Close'), + }], + size: "large", + title: "Kanban Examples", + }).open(); + dialog.on('closed', this, function () { + self.$input.focus(); + }); + }, + /** + * @private + */ + _onUnfold: function () { + if (this.folded) { + this.toggleFold(); + } + }, + /** + * When a click happens outside the quick create, we want to close it. + * + * @private + * @param {MouseEvent} event + */ + _onWindowClicked: function (event) { + // ignore clicks if the quick create is not in the dom + if (!document.contains(this.el)) { + return; + } + + // ignore clicks in modals + if ($(event.target).closest('.modal').length) { + return; + } + + // ignore clicks if target is inside the quick create + if (this.el.contains(event.target)) { + return; + } + + this._cancel(); + }, +}); + +return ColumnQuickCreate; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js new file mode 100644 index 00000000..1b6e6301 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -0,0 +1,537 @@ +odoo.define('web.KanbanController', function (require) { +"use strict"; + +/** + * The KanbanController is the class that coordinates the kanban model and the + * kanban renderer. It also makes sure that update from the search view are + * properly interpreted. + */ + +var BasicController = require('web.BasicController'); +var Context = require('web.Context'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Domain = require('web.Domain'); +var view_dialogs = require('web.view_dialogs'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +var KanbanController = BasicController.extend({ + buttons_template: 'KanbanView.buttons', + custom_events: _.extend({}, BasicController.prototype.custom_events, { + add_quick_create: '_onAddQuickCreate', + quick_create_add_column: '_onAddColumn', + quick_create_record: '_onQuickCreateRecord', + resequence_columns: '_onResequenceColumn', + button_clicked: '_onButtonClicked', + kanban_record_delete: '_onRecordDelete', + kanban_record_update: '_onUpdateRecord', + kanban_column_delete: '_onDeleteColumn', + kanban_column_add_record: '_onAddRecordToColumn', + kanban_column_resequence: '_onColumnResequence', + kanban_load_more: '_onLoadMore', + column_toggle_fold: '_onToggleColumn', + kanban_column_records_toggle_active: '_onToggleActiveRecords', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.quickCreateEnabled set to false to disable the + * quick create feature + * @param {SearchPanel} [params.searchPanel] + * @param {Array[]} [params.controlPanelDomain=[]] initial domain coming + * from the controlPanel + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.on_create = params.on_create; + this.hasButtons = params.hasButtons; + this.quickCreateEnabled = params.quickCreateEnabled; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {jQuery} [$node] + */ + renderButtons: function ($node) { + if (!this.hasButtons || !this.is_action_enabled('create')) { + return; + } + this.$buttons = $(qweb.render(this.buttons_template, { + btnClass: 'btn-primary', + widget: this, + })); + this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this)); + this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this)); + if ($node) { + this.$buttons.appendTo($node); + } + }, + /** + * In grouped mode, set 'Create' button as btn-secondary if there is no column + * (except if we can't create new columns) + * + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + var state = this.model.get(this.handle, {raw: true}); + var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn; + this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Displays the record quick create widget in the requested column, given its + * id (in the first column by default). Ensures that we removed sample data + * if any, before displaying the quick create. + * + * @private + * @param {string} [groupId] + */ + _addQuickCreate(groupId) { + this._removeSampleData(async () => { + await this.update({ shouldUpdateSearchComponents: false }, { reload: false }); + return this.renderer.addQuickCreate(groupId); + }); + }, + /** + * @override method comes from field manager mixin + * @private + * @param {string} id local id from the basic record data + * @returns {Promise} + */ + _confirmSave: function (id) { + var data = this.model.get(this.handle, {raw: true}); + var grouped = data.groupedBy.length; + if (grouped) { + var columnState = this.model.getColumn(id); + return this.renderer.updateColumn(columnState.id, columnState); + } + return this.renderer.updateRecord(this.model.get(id)); + }, + /** + * Only display the pager in the ungrouped case, with data. + * + * @override + * @private + */ + _getPagingInfo: function (state) { + if (!(state.count && !state.groupedBy.length)) { + return null; + } + return this._super(...arguments); + }, + /** + * @private + * @param {Widget} kanbanRecord + * @param {Object} params + */ + _reloadAfterButtonClick: function (kanbanRecord, params) { + var self = this; + var recordModel = this.model.localData[params.record.id]; + var group = this.model.localData[recordModel.parentID]; + var parent = this.model.localData[group.parentID]; + + this.model.reload(params.record.id).then(function (db_id) { + var data = self.model.get(db_id); + kanbanRecord.update(data); + + // Check if we still need to display the record. Some fields of the domain are + // not guaranteed to be in data. This is for example the case if the action + // contains a domain on a field which is not in the Kanban view. Therefore, + // we need to handle multiple cases based on 3 variables: + // domInData: all domain fields are in the data + // activeInDomain: 'active' is already in the domain + // activeInData: 'active' is available in the data + + var domain = (parent ? parent.domain : group.domain) || []; + var domInData = _.every(domain, function (d) { + return d[0] in data.data; + }); + var activeInDomain = _.pluck(domain, 0).indexOf('active') !== -1; + var activeInData = 'active' in data.data; + + // Case # | domInData | activeInDomain | activeInData + // 1 | true | true | true => no domain change + // 2 | true | true | false => not possible + // 3 | true | false | true => add active in domain + // 4 | true | false | false => no domain change + // 5 | false | true | true => no evaluation + // 6 | false | true | false => no evaluation + // 7 | false | false | true => replace domain + // 8 | false | false | false => no evaluation + + // There are 3 cases which cannot be evaluated since we don't have all the + // necessary information. The complete solution would be to perform a RPC in + // these cases, but this is out of scope. A simpler one is to do a try / catch. + + if (domInData && !activeInDomain && activeInData) { + domain = domain.concat([['active', '=', true]]); + } else if (!domInData && !activeInDomain && activeInData) { + domain = [['active', '=', true]]; + } + try { + var visible = new Domain(domain).compute(data.evalContext); + } catch (e) { + return; + } + if (!visible) { + kanbanRecord.destroy(); + } + }); + }, + /** + * @param {number[]} ids + * @private + * @returns {Promise} + */ + _resequenceColumns: function (ids) { + var state = this.model.get(this.handle, {raw: true}); + var model = state.fields[state.groupedBy[0]].relation; + return this.model.resequence(model, ids, this.handle); + }, + /** + * This method calls the server to ask for a resequence. Note that this + * does not rerender the user interface, because in most case, the + * resequencing operation has already been displayed by the renderer. + * + * @private + * @param {string} column_id + * @param {string[]} ids + * @returns {Promise} + */ + _resequenceRecords: function (column_id, ids) { + var self = this; + return this.model.resequence(this.modelName, ids, column_id); + }, + /** + * @override + */ + _shouldBounceOnClick(element) { + const state = this.model.get(this.handle, {raw: true}); + if (!state.count || state.isSample) { + const classesList = [ + 'o_kanban_view', + 'o_kanban_group', + 'o_kanban_header', + 'o_column_quick_create', + 'o_view_nocontent_smiling_face', + ]; + return classesList.some(c => element.classList.contains(c)); + } + return false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This handler is called when an event (from the quick create add column) + * event bubbles up. When that happens, we need to ask the model to create + * a group and to update the renderer + * + * @private + * @param {OdooEvent} ev + */ + _onAddColumn: function (ev) { + var self = this; + this.mutex.exec(function () { + return self.model.createGroup(ev.data.value, self.handle).then(function () { + var state = self.model.get(self.handle, {raw: true}); + var ids = _.pluck(state.data, 'res_id').filter(_.isNumber); + return self._resequenceColumns(ids); + }).then(function () { + return self.update({}, {reload: false}); + }).then(function () { + let quickCreateFolded = self.renderer.quickCreate.folded; + if (ev.data.foldQuickCreate ? !quickCreateFolded : quickCreateFolded) { + self.renderer.quickCreateToggleFold(); + } + self.renderer.trigger_up("quick_create_column_created"); + }); + }); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onAddRecordToColumn: function (ev) { + var self = this; + var record = ev.data.record; + var column = ev.target; + this.alive(this.model.moveRecord(record.db_id, column.db_id, this.handle)) + .then(function (column_db_ids) { + return self._resequenceRecords(column.db_id, ev.data.ids) + .then(function () { + _.each(column_db_ids, function (db_id) { + var data = self.model.get(db_id); + self.renderer.updateColumn(db_id, data); + }); + }); + }).guardedCatch(this.reload.bind(this)); + }, + /** + * @private + * @param {OdooEvent} ev + * @returns {string} ev.data.groupId + */ + _onAddQuickCreate(ev) { + ev.stopPropagation(); + this._addQuickCreate(ev.data.groupId); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + var self = this; + ev.stopPropagation(); + var attrs = ev.data.attrs; + var record = ev.data.record; + var def = Promise.resolve(); + if (attrs.context) { + attrs.context = new Context(attrs.context) + .set_eval_context({ + active_id: record.res_id, + active_ids: [record.res_id], + active_model: record.model, + }); + } + if (attrs.confirm) { + def = new Promise(function (resolve, reject) { + Dialog.confirm(this, attrs.confirm, { + confirm_callback: resolve, + cancel_callback: reject, + }).on("closed", null, reject); + }); + } + def.then(function () { + self.trigger_up('execute_action', { + action_data: attrs, + env: { + context: record.getContext(), + currentID: record.res_id, + model: record.model, + resIDs: record.res_ids, + }, + on_closed: self._reloadAfterButtonClick.bind(self, ev.target, ev.data), + }); + }); + }, + /** + * @private + */ + _onButtonNew: function () { + var state = this.model.get(this.handle, {raw: true}); + var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state); + if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) { + // activate the quick create in the first column when the mutex is + // unlocked, to ensure that there is no pending re-rendering that + // would remove it (e.g. if we are currently adding a new column) + this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null)); + } else if (this.on_create && this.on_create !== 'quick_create') { + // Execute the given action + this.do_action(this.on_create, { + on_close: this.reload.bind(this, {}), + additional_context: state.context, + }); + } else { + // Open the form view + this.trigger_up('switch_view', { + view_type: 'form', + res_id: undefined + }); + } + }, + /** + * Moves the focus from the controller buttons to the first kanban record + * + * @private + * @param {jQueryEvent} ev + */ + _onButtonsKeyDown: function (ev) { + switch(ev.keyCode) { + case $.ui.keyCode.DOWN: + this._giveFocus(); + } + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onColumnResequence: function (ev) { + this._resequenceRecords(ev.target.db_id, ev.data.ids); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onDeleteColumn: function (ev) { + var column = ev.target; + var state = this.model.get(this.handle, {raw: true}); + var relatedModelName = state.fields[state.groupedBy[0]].relation; + this.model + .deleteRecords([column.db_id], relatedModelName) + .then(this.update.bind(this, {}, {})); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onLoadMore: function (ev) { + var self = this; + var column = ev.target; + this.model.loadMore(column.db_id).then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.updateColumn(db_id, data); + }); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {KanbanColumn} ev.target the column in which the record should + * be added + * @param {Object} ev.data.values the field values of the record to + * create; if values only contains the value of the 'display_name', a + * 'name_create' is performed instead of 'create' + * @param {function} [ev.data.onFailure] called when the quick creation + * failed + */ + _onQuickCreateRecord: function (ev) { + var self = this; + var values = ev.data.values; + var column = ev.target; + var onFailure = ev.data.onFailure || function () {}; + + // function that updates the kanban view once the record has been added + // it receives the local id of the created record in arguments + var update = function (db_id) { + + var columnState = self.model.getColumn(db_id); + var state = self.model.get(self.handle); + return self.renderer + .updateColumn(columnState.id, columnState, {openQuickCreate: true, state: state}) + .then(function () { + if (ev.data.openRecord) { + self.trigger_up('open_record', {id: db_id, mode: 'edit'}); + } + }); + }; + + this.model.createRecordInGroup(column.db_id, values) + .then(update) + .guardedCatch(function (reason) { + reason.event.preventDefault(); + var columnState = self.model.get(column.db_id, {raw: true}); + var context = columnState.getContext(); + var state = self.model.get(self.handle, {raw: true}); + var groupedBy = state.groupedBy[0]; + context['default_' + groupedBy] = viewUtils.getGroupValue(columnState, groupedBy); + new view_dialogs.FormViewDialog(self, { + res_model: state.model, + context: _.extend({default_name: values.name || values.display_name}, context), + title: _t("Create"), + disable_multiple_selection: true, + on_saved: function (record) { + self.model.addRecordToGroup(column.db_id, record.res_id) + .then(update); + }, + }).open().opened(onFailure); + }); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onRecordDelete: function (ev) { + this._deleteRecords([ev.data.id]); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onResequenceColumn: function (ev) { + var self = this; + this._resequenceColumns(ev.data.ids); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {boolean} [ev.data.openQuickCreate=false] if true, opens the + * QuickCreate in the toggled column (it assumes that we are opening it) + */ + _onToggleColumn: function (ev) { + var self = this; + const columnID = ev.target.db_id || ev.data.db_id; + this.model.toggleGroup(columnID) + .then(function (db_id) { + var data = self.model.get(db_id); + var options = { + openQuickCreate: !!ev.data.openQuickCreate, + }; + return self.renderer.updateColumn(db_id, data, options); + }) + .then(function () { + if (ev.data.onSuccess) { + ev.data.onSuccess(); + } + }); + }, + /** + * @todo should simply use field_changed event... + * + * @private + * @param {OdooEvent} ev + * @param {function} [ev.data.onSuccess] callback to execute after applying + * changes + */ + _onUpdateRecord: function (ev) { + var onSuccess = ev.data.onSuccess; + delete ev.data.onSuccess; + var changes = _.clone(ev.data); + ev.data.force_save = true; + this._applyChanges(ev.target.db_id, changes, ev).then(onSuccess); + }, + /** + * Allow the user to archive/restore all the records of a column. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleActiveRecords: function (ev) { + var self = this; + var archive = ev.data.archive; + var column = ev.target; + var recordIds = _.pluck(column.records, 'id'); + if (recordIds.length) { + var prom = archive ? + this.model.actionArchive(recordIds, column.db_id) : + this.model.actionUnarchive(recordIds, column.db_id); + prom.then(function (dbID) { + var data = self.model.get(dbID); + if (data) { // Could be null if a wizard is returned for example + self.model.reload(self.handle).then(function () { + const state = self.model.get(self.handle); + self.renderer.updateColumn(dbID, data, { state }); + }); + } + }); + } + }, +}); + +return KanbanController; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_examples_registry.js b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js new file mode 100644 index 00000000..effdd7ff --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js @@ -0,0 +1,27 @@ +odoo.define('web.kanban_examples_registry', function (require) { +"use strict"; + +/** + * This file instantiates and exports a registry. The purpose of this registry + * is to store static data displayed in a dialog to help the end user to + * configure its columns in the grouped Kanban view. + * + * To activate a link on the ColumnQuickCreate widget on open such a dialog, the + * attribute 'examples' on the root arch node must be set to a valid key in this + * registry. + * + * Each value in this registry must be an array of Objects containing the + * following keys: + * - name (string) + * - columns (Array[string]) + * - description (string, optional) BE CAREFUL [*] + * + * [*] The description is added with a t-raw so the translated texts must be + * properly escaped. + */ + +var Registry = require('web.Registry'); + +return new Registry(); + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js new file mode 100644 index 00000000..7dcfe408 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -0,0 +1,445 @@ +odoo.define('web.KanbanModel', function (require) { +"use strict"; + +/** + * The KanbanModel extends the BasicModel to add Kanban specific features like + * moving a record from a group to another, resequencing records... + */ + +var BasicModel = require('web.BasicModel'); +var viewUtils = require('web.viewUtils'); + +var KanbanModel = BasicModel.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds a record to a group in the localData, and fetch the record. + * + * @param {string} groupID localID of the group + * @param {integer} resId id of the record + * @returns {Promise<string>} resolves to the local id of the new record + */ + addRecordToGroup: function (groupID, resId) { + var self = this; + var group = this.localData[groupID]; + var new_record = this._makeDataPoint({ + res_id: resId, + modelName: group.model, + fields: group.fields, + fieldsInfo: group.fieldsInfo, + viewType: group.viewType, + parentID: groupID, + }); + + var def = this._fetchRecord(new_record).then(function (result) { + group.data.unshift(new_record.id); + group.res_ids.unshift(resId); + group.count++; + + // update the res_ids and count of the parent + self.localData[group.parentID].count++; + self._updateParentResIDs(group); + + return result.id; + }); + return this._reloadProgressBarGroupFromRecord(new_record.id, def); + }, + /** + * Creates a new group from a name (performs a name_create). + * + * @param {string} name + * @param {string} parentID localID of the parent of the group + * @returns {Promise<string>} resolves to the local id of the new group + */ + createGroup: function (name, parentID) { + var self = this; + var parent = this.localData[parentID]; + var groupBy = parent.groupedBy[0]; + var groupByField = parent.fields[groupBy]; + if (!groupByField || groupByField.type !== 'many2one') { + return Promise.reject(); // only supported when grouped on m2o + } + return this._rpc({ + model: groupByField.relation, + method: 'name_create', + args: [name], + context: parent.context, // todo: combine with view context + }) + .then(function (result) { + const createGroupDataPoint = (model, parent) => { + const newGroup = model._makeDataPoint({ + modelName: parent.model, + context: parent.context, + domain: parent.domain.concat([[groupBy, "=", result[0]]]), + fields: parent.fields, + fieldsInfo: parent.fieldsInfo, + isOpen: true, + limit: parent.limit, + parentID: parent.id, + openGroupByDefault: true, + orderedBy: parent.orderedBy, + value: result, + viewType: parent.viewType, + }); + if (parent.progressBar) { + newGroup.progressBarValues = _.extend({ + counts: {}, + }, parent.progressBar); + } + return newGroup; + }; + const newGroup = createGroupDataPoint(self, parent); + parent.data.push(newGroup.id); + if (self.isInSampleMode()) { + // in sample mode, create the new group in both models (main + sample) + const sampleParent = self.sampleModel.localData[parentID]; + const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent); + sampleParent.data.push(newSampleGroup.id); + } + return newGroup.id; + }); + }, + /** + * Creates a new record from the given value, and add it to the given group. + * + * @param {string} groupID + * @param {Object} values + * @returns {Promise} resolved with the local id of the created record + */ + createRecordInGroup: function (groupID, values) { + var self = this; + var group = this.localData[groupID]; + var context = this._getContext(group); + var parent = this.localData[group.parentID]; + var groupedBy = parent.groupedBy; + context['default_' + groupedBy] = viewUtils.getGroupValue(group, groupedBy); + var def; + if (Object.keys(values).length === 1 && 'display_name' in values) { + // only 'display_name is given, perform a 'name_create' + def = this._rpc({ + model: parent.model, + method: 'name_create', + args: [values.display_name], + context: context, + }).then(function (records) { + return records[0]; + }); + } else { + // other fields are specified, perform a classical 'create' + def = this._rpc({ + model: parent.model, + method: 'create', + args: [values], + context: context, + }); + } + return def.then(function (resID) { + return self.addRecordToGroup(group.id, resID); + }); + }, + /** + * Add the following (kanban specific) keys when performing a `get`: + * + * - tooltipData + * - progressBarValues + * - isGroupedByM2ONoColumn + * + * @override + * @see _readTooltipFields + * @returns {Object} + */ + __get: function () { + var result = this._super.apply(this, arguments); + var dp = result && this.localData[result.id]; + if (dp) { + if (dp.tooltipData) { + result.tooltipData = $.extend(true, {}, dp.tooltipData); + } + if (dp.progressBarValues) { + result.progressBarValues = $.extend(true, {}, dp.progressBarValues); + } + if (dp.fields[dp.groupedBy[0]]) { + var groupedByM2O = dp.fields[dp.groupedBy[0]].type === 'many2one'; + result.isGroupedByM2ONoColumn = !dp.data.length && groupedByM2O; + } else { + result.isGroupedByM2ONoColumn = false; + } + } + return result; + }, + /** + * Same as @see get but getting the parent element whose ID is given. + * + * @param {string} id + * @returns {Object} + */ + getColumn: function (id) { + var element = this.localData[id]; + if (element) { + return this.get(element.parentID); + } + return null; + }, + /** + * @override + */ + __load: function (params) { + this.defaultGroupedBy = params.groupBy || []; + params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy; + return this._super(params); + }, + /** + * Load more records in a group. + * + * @param {string} groupID localID of the group + * @returns {Promise<string>} resolves to the localID of the group + */ + loadMore: function (groupID) { + var group = this.localData[groupID]; + var offset = group.loadMoreOffset + group.limit; + return this.reload(group.id, { + loadMoreOffset: offset, + }); + }, + /** + * Moves a record from a group to another. + * + * @param {string} recordID localID of the record + * @param {string} groupID localID of the new group of the record + * @param {string} parentID localID of the parent + * @returns {Promise<string[]>} resolves to a pair [oldGroupID, newGroupID] + */ + moveRecord: function (recordID, groupID, parentID) { + var self = this; + var parent = this.localData[parentID]; + var new_group = this.localData[groupID]; + var changes = {}; + var groupedFieldName = parent.groupedBy[0]; + var groupedField = parent.fields[groupedFieldName]; + if (groupedField.type === 'many2one') { + changes[groupedFieldName] = { + id: new_group.res_id, + display_name: new_group.value, + }; + } else if (groupedField.type === 'selection') { + var value = _.findWhere(groupedField.selection, {1: new_group.value}); + changes[groupedFieldName] = value && value[0] || false; + } else { + changes[groupedFieldName] = new_group.value; + } + + // Manually updates groups data. Note: this is done before the actual + // save as it might need to perform a read group in some cases so those + // updated data might be overridden again. + var record = self.localData[recordID]; + var resID = record.res_id; + // Remove record from its current group + var old_group; + for (var i = 0; i < parent.data.length; i++) { + old_group = self.localData[parent.data[i]]; + var index = _.indexOf(old_group.data, recordID); + if (index >= 0) { + old_group.data.splice(index, 1); + old_group.count--; + old_group.res_ids = _.without(old_group.res_ids, resID); + self._updateParentResIDs(old_group); + break; + } + } + // Add record to its new group + new_group.data.push(recordID); + new_group.res_ids.push(resID); + new_group.count++; + + return this.notifyChanges(recordID, changes).then(function () { + return self.save(recordID); + }).then(function () { + record.parentID = new_group.id; + return [old_group.id, new_group.id]; + }); + }, + /** + * @override + */ + reload: function (id, options) { + // if the groupBy is given in the options and if it is an empty array, + // fallback on the default groupBy + if (options && options.groupBy && !options.groupBy.length) { + options.groupBy = this.defaultGroupedBy; + } + return this._super(id, options); + }, + /** + * @override + */ + __reload: function (id, options) { + var def = this._super(id, options); + if (options && options.loadMoreOffset) { + return def; + } + return this._reloadProgressBarGroupFromRecord(id, def); + }, + /** + * @override + */ + save: function (recordID) { + var def = this._super.apply(this, arguments); + return this._reloadProgressBarGroupFromRecord(recordID, def); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _makeDataPoint: function (params) { + var dataPoint = this._super.apply(this, arguments); + if (params.progressBar) { + dataPoint.progressBar = params.progressBar; + } + return dataPoint; + }, + /** + * @override + */ + _load: function (dataPoint, options) { + if (dataPoint.groupedBy.length && dataPoint.progressBar) { + return this._readProgressBarGroup(dataPoint, options); + } + return this._super.apply(this, arguments); + }, + /** + * Ensures that there is no nested groups in Kanban (only the first grouping + * level is taken into account). + * + * @override + * @private + * @param {Object} list valid resource object + */ + _readGroup: function (list) { + var self = this; + if (list.groupedBy.length > 1) { + list.groupedBy = [list.groupedBy[0]]; + } + return this._super.apply(this, arguments).then(function (result) { + return self._readTooltipFields(list).then(_.constant(result)); + }); + }, + /** + * @private + * @param {Object} dataPoint + * @returns {Promise<Object>} + */ + _readProgressBarGroup: function (list, options) { + var self = this; + var groupsDef = this._readGroup(list, options); + var progressBarDef = this._rpc({ + model: list.model, + method: 'read_progress_bar', + kwargs: { + domain: list.domain, + group_by: list.groupedBy[0], + progress_bar: list.progressBar, + context: list.context, + }, + }); + return Promise.all([groupsDef, progressBarDef]).then(function (results) { + var data = results[1]; + _.each(list.data, function (groupID) { + var group = self.localData[groupID]; + group.progressBarValues = _.extend({ + counts: data[group.value] || {}, + }, list.progressBar); + }); + return list; + }); + }, + /** + * Fetches tooltip specific fields on the group by relation and stores it in + * the column datapoint in a special key `tooltipData`. + * Data for the tooltips (group_by_tooltip) are fetched in batch for all + * groups, to avoid doing multiple calls. + * Data are stored in a special key `tooltipData` on the datapoint. + * Note that the option `group_by_tooltip` is only for m2o fields. + * + * @private + * @param {Object} list a list of groups + * @returns {Promise} + */ + _readTooltipFields: function (list) { + var self = this; + var groupedByField = list.fields[list.groupedBy[0].split(':')[0]]; + if (groupedByField.type !== 'many2one') { + return Promise.resolve(); + } + var groupIds = _.reduce(list.data, function (groupIds, id) { + var res_id = self.get(id, {raw: true}).res_id; + // The field on which we are grouping might not be set on all records + if (res_id) { + groupIds.push(res_id); + } + return groupIds; + }, []); + var tooltipFields = []; + var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]]; + if (groupedByFieldInfo && groupedByFieldInfo.options) { + tooltipFields = Object.keys(groupedByFieldInfo.options.group_by_tooltip || {}); + } + if (groupIds.length && tooltipFields.length) { + var fieldNames = _.union(['display_name'], tooltipFields); + return this._rpc({ + model: groupedByField.relation, + method: 'read', + args: [groupIds, fieldNames], + context: list.context, + }).then(function (result) { + _.each(list.data, function (id) { + var dp = self.localData[id]; + dp.tooltipData = _.findWhere(result, {id: dp.res_id}); + }); + }); + } + return Promise.resolve(); + }, + /** + * Reloads all progressbar data. This is done after given promise and + * insures that the given promise's result is not lost. + * + * @private + * @param {string} recordID + * @param {Promise} def + * @returns {Promise} + */ + _reloadProgressBarGroupFromRecord: function (recordID, def) { + var element = this.localData[recordID]; + if (element.type === 'list' && !element.parentID) { + // we are reloading the whole view, so there is no need to manually + // reload the progressbars + return def; + } + + // If we updated a record, then we must potentially update columns' + // progressbars, so we need to load groups info again + var self = this; + while (element) { + if (element.progressBar) { + return def.then(function (data) { + return self._load(element, { + keepEmptyGroups: true, + onlyGroups: true, + }).then(function () { + return data; + }); + }); + } + element = this.localData[element.parentID]; + } + return def; + }, +}); +return KanbanModel; +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js new file mode 100644 index 00000000..02dc22e9 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -0,0 +1,761 @@ +odoo.define('web.KanbanRecord', function (require) { +"use strict"; + +/** + * This file defines the KanbanRecord widget, which corresponds to a card in + * a Kanban view. + */ +var config = require('web.config'); +var core = require('web.core'); +var Domain = require('web.Domain'); +var Dialog = require('web.Dialog'); +var field_utils = require('web.field_utils'); +const FieldWrapper = require('web.FieldWrapper'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); +var widgetRegistry = require('web.widget_registry'); + +var _t = core._t; +var QWeb = core.qweb; + +var KANBAN_RECORD_COLORS = require('web.basic_fields').FieldColorPicker.prototype.RECORD_COLORS; +var NB_KANBAN_RECORD_COLORS = KANBAN_RECORD_COLORS.length; + +var KanbanRecord = Widget.extend({ + events: { + 'click .oe_kanban_action': '_onKanbanActionClicked', + 'click .o_kanban_manage_toggle_button': '_onManageTogglerClicked', + }, + /** + * @override + */ + init: function (parent, state, options) { + this._super(parent); + + this.fields = state.fields; + this.fieldsInfo = state.fieldsInfo.kanban; + this.modelName = state.model; + + this.options = options; + this.editable = options.editable; + this.deletable = options.deletable; + this.read_only_mode = options.read_only_mode; + this.selectionMode = options.selectionMode; + this.qweb = options.qweb; + this.subWidgets = {}; + + this._setState(state); + // avoid quick multiple clicks + this._onKanbanActionClicked = _.debounce(this._onKanbanActionClicked, 300, true); + }, + /** + * @override + */ + start: function () { + return Promise.all([this._super.apply(this, arguments), this._render()]); + }, + /** + * Called each time the record is attached to the DOM. + */ + on_attach_callback: function () { + this.isInDOM = true; + _.invoke(this.subWidgets, 'on_attach_callback'); + }, + /** + * Called each time the record is detached from the DOM. + */ + on_detach_callback: function () { + this.isInDOM = false; + _.invoke(this.subWidgets, 'on_detach_callback'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Re-renders the record with a new state + * + * @param {Object} state + * @returns {Promise} + */ + update: function (state) { + // detach the widgets because the record will empty its $el, which will + // remove all event handlers on its descendants, and we want to keep + // those handlers alive as we will re-use these widgets + _.invoke(_.pluck(this.subWidgets, '$el'), 'detach'); + this._setState(state); + return this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _attachTooltip: function () { + var self = this; + this.$('[tooltip]').each(function () { + var $el = $(this); + var tooltip = $el.attr('tooltip'); + if (tooltip) { + $el.tooltip({ + title: self.qweb.render(tooltip, self.qweb_context) + }); + } + }); + }, + /** + * @private + * @param {string} d a stringified domain + * @returns {boolean} the domain evaluted with the current values + */ + _computeDomain: function (d) { + return new Domain(d).compute(this.state.evalContext); + }, + /** + * Generates the color classname from a given variable + * + * @private + * @param {number | string} variable + * @return {string} the classname + */ + _getColorClassname: function (variable) { + var color = this._getColorID(variable); + return 'oe_kanban_color_' + color; + }, + /** + * Computes a color id between 0 and 10 from a given value + * + * @private + * @param {number | string} variable + * @returns {integer} the color id + */ + _getColorID: function (variable) { + if (typeof variable === 'number') { + return Math.round(variable) % NB_KANBAN_RECORD_COLORS; + } + if (typeof variable === 'string') { + var index = 0; + for (var i = 0; i < variable.length; i++) { + index += variable.charCodeAt(i); + } + return index % NB_KANBAN_RECORD_COLORS; + } + return 0; + }, + /** + * Computes a color name from value + * + * @private + * @param {number | string} variable + * @returns {integer} the color name + */ + _getColorname: function (variable) { + var colorID = this._getColorID(variable); + return KANBAN_RECORD_COLORS[colorID]; + }, + file_type_magic_word: { + '/': 'jpg', + 'R': 'gif', + 'i': 'png', + 'P': 'svg+xml', + }, + /** + * @private + * @param {string} model the name of the model + * @param {string} field the name of the field + * @param {integer} id the id of the resource + * @param {string} placeholder + * @returns {string} the url of the image + */ + _getImageURL: function (model, field, id, placeholder) { + id = (_.isArray(id) ? id[0] : id) || null; + var isCurrentRecord = this.modelName === model && (this.recordData.id === id || (!this.recordData.id && !id)); + var url; + if (isCurrentRecord && this.record[field] && this.record[field].raw_value && !utils.is_bin_size(this.record[field].raw_value)) { + // Use magic-word technique for detecting image type + url = 'data:image/' + this.file_type_magic_word[this.record[field].raw_value[0]] + ';base64,' + this.record[field].raw_value; + } else if (placeholder && (!model || !field || !id || (isCurrentRecord && this.record[field] && !this.record[field].raw_value))) { + url = placeholder; + } else { + var session = this.getSession(); + var params = { + model: model, + field: field, + id: id + }; + if (isCurrentRecord) { + params.unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, ''); + } + url = session.url('/web/image', params); + } + return url; + }, + /** + * Triggers up an event to open the record + * + * @private + */ + _openRecord: function () { + if (this.$el.hasClass('o_currently_dragged')) { + // this record is currently being dragged and dropped, so we do not + // want to open it. + return; + } + var editMode = this.$el.hasClass('oe_kanban_global_click_edit'); + this.trigger_up('open_record', { + id: this.db_id, + mode: editMode ? 'edit' : 'readonly', + }); + }, + /** + * Processes each 'field' tag and replaces it by the specified widget, if + * any, or directly by the formatted value + * + * @private + */ + _processFields: function () { + var self = this; + this.$("field").each(function () { + var $field = $(this); + var field_name = $field.attr("name"); + var field_widget = $field.attr("widget"); + + // a widget is specified for that field or a field is a many2many ; + // in this latest case, we want to display the widget many2manytags + // even if it is not specified in the view. + if (field_widget || self.fields[field_name].type === 'many2many') { + var widget = self.subWidgets[field_name]; + if (!widget) { + // the widget doesn't exist yet, so instanciate it + var Widget = self.fieldsInfo[field_name].Widget; + if (Widget) { + widget = self._processWidget($field, field_name, Widget); + self.subWidgets[field_name] = widget; + } else if (config.isDebug()) { + // the widget is not implemented + $field.replaceWith($('<span>', { + text: _.str.sprintf(_t('[No widget %s]'), field_widget), + })); + } + } else { + // a widget already exists for that field, so reset it with the new state + widget.reset(self.state); + $field.replaceWith(widget.$el); + if (self.isInDOM && widget.on_attach_callback) { + widget.on_attach_callback(); + } + } + } else { + self._processField($field, field_name); + } + }); + }, + /** + * Replace a field by its formatted value. + * + * @private + * @param {JQuery} $field + * @param {String} field_name + * @returns {Jquery} the modified node + */ + _processField: function ($field, field_name) { + // no widget specified for that field, so simply use a formatter + // note: we could have used the widget corresponding to the field's type, but + // it is much more efficient to use a formatter + var field = this.fields[field_name]; + var value = this.recordData[field_name]; + var options = { data: this.recordData, forceString: true }; + var formatted_value = field_utils.format[field.type](value, field, options); + var $result = $('<span>', { + text: formatted_value, + }); + $field.replaceWith($result); + this._setFieldDisplay($result, field_name); + return $result; + }, + /** + * Replace a field by its corresponding widget. + * + * @private + * @param {JQuery} $field + * @param {String} field_name + * @param {Class} Widget + * @returns {Widget} the widget instance + */ + _processWidget: function ($field, field_name, Widget) { + // some field's attrs might be record dependent (they start with + // 't-att-') and should thus be evaluated, which is done by qweb + // we here replace those attrs in the dict of attrs of the state + // by their evaluted value, to make it transparent from the + // field's widgets point of view + // that dict being shared between records, we don't modify it + // in place + var self = this; + var attrs = Object.create(null); + _.each(this.fieldsInfo[field_name], function (value, key) { + if (_.str.startsWith(key, 't-att-')) { + key = key.slice(6); + value = $field.attr(key); + } + attrs[key] = value; + }); + var options = _.extend({}, this.options, { attrs: attrs }); + let widget; + let def; + if (utils.isComponent(Widget)) { + widget = new FieldWrapper(this, Widget, { + fieldName: field_name, + record: this.state, + options: options, + }); + def = widget.mount(document.createDocumentFragment()) + .then(() => { + $field.replaceWith(widget.$el); + }); + } else { + widget = new Widget(this, field_name, this.state, options); + def = widget.replace($field); + } + this.defs.push(def); + def.then(function () { + self._setFieldDisplay(widget.$el, field_name); + }); + return widget; + }, + _processWidgets: function () { + var self = this; + this.$("widget").each(function () { + var $field = $(this); + var Widget = widgetRegistry.get($field.attr('name')); + var widget = new Widget(self, self.state); + + var def = widget._widgetRenderAndInsert(function () { }); + self.defs.push(def); + def.then(function () { + $field.replaceWith(widget.$el); + widget.$el.addClass('o_widget'); + }); + }); + }, + /** + * Renders the record + * + * @returns {Promise} + */ + _render: function () { + this.defs = []; + // call 'on_detach_callback' on each subwidget as they will be removed + // from the DOM at the next line + _.invoke(this.subWidgets, 'on_detach_callback'); + this._replaceElement(this.qweb.render('kanban-box', this.qweb_context)); + this.$el.addClass('o_kanban_record').attr("tabindex", 0); + this.$el.attr('role', 'article'); + this.$el.data('record', this); + // forcefully add class oe_kanban_global_click to have clickable record always to select it + if (this.selectionMode) { + this.$el.addClass('oe_kanban_global_click'); + } + if (this.$el.hasClass('oe_kanban_global_click') || + this.$el.hasClass('oe_kanban_global_click_edit')) { + this.$el.on('click', this._onGlobalClick.bind(this)); + this.$el.on('keydown', this._onKeyDownCard.bind(this)); + } else { + this.$el.on('keydown', this._onKeyDownOpenFirstLink.bind(this)); + } + this._processFields(); + this._processWidgets(); + this._setupColor(); + this._setupColorPicker(); + this._attachTooltip(); + + // We use boostrap tooltips for better and faster display + this.$('span.o_tag').tooltip({ delay: { 'show': 50 } }); + + return Promise.all(this.defs); + }, + /** + * Sets cover image on a kanban card through an attachment dialog. + * + * @private + * @param {string} fieldName field used to set cover image + * @param {boolean} autoOpen automatically open the file choser if there are no attachments + */ + _setCoverImage: function (fieldName, autoOpen) { + var self = this; + this._rpc({ + model: 'ir.attachment', + method: 'search_read', + domain: [ + ['res_model', '=', this.modelName], + ['res_id', '=', this.id], + ['mimetype', 'ilike', 'image'] + ], + fields: ['id', 'name'], + }).then(function (attachmentIds) { + self.imageUploadID = _.uniqueId('o_cover_image_upload'); + self.accepted_file_extensions = 'image/*'; // prevent uploading of other file types + self.attachment_count = attachmentIds.length; + var coverId = self.record[fieldName] && self.record[fieldName].raw_value; + var $content = $(QWeb.render('KanbanView.SetCoverModal', { + coverId: coverId, + attachmentIds: attachmentIds, + widget: self, + })); + var $imgs = $content.find('.o_kanban_cover_image'); + var dialog = new Dialog(self, { + title: _t("Set a Cover Image"), + $content: $content, + buttons: [{ + text: _t("Select"), + classes: attachmentIds.length ? 'btn-primary' : 'd-none', + close: true, + disabled: !coverId, + click: function () { + var $img = $imgs.filter('.o_selected').find('img'); + var data = {}; + data[fieldName] = { + id: $img.data('id'), + display_name: $img.data('name') + }; + self.trigger_up('kanban_record_update', data); + }, + }, { + text: _t('Upload and Set'), + classes: attachmentIds.length ? '' : 'btn-primary', + close: false, + click: function () { + $content.find('input.o_input_file').click(); + }, + }, { + text: _t("Remove Cover Image"), + classes: coverId ? '' : 'd-none', + close: true, + click: function () { + var data = {}; + data[fieldName] = false; + self.trigger_up('kanban_record_update', data); + }, + }, { + text: _t("Discard"), + close: true, + }], + }); + dialog.opened().then(function () { + var $selectBtn = dialog.$footer.find('.btn-primary'); + if (autoOpen && !self.attachment_count) { + $selectBtn.click(); + } + + $content.on('click', '.o_kanban_cover_image', function (ev) { + $imgs.not(ev.currentTarget).removeClass('o_selected'); + $selectBtn.prop('disabled', !$(ev.currentTarget).toggleClass('o_selected').hasClass('o_selected')); + }); + + $content.on('dblclick', '.o_kanban_cover_image', function (ev) { + var $img = $(ev.currentTarget).find('img'); + var data = {}; + data[fieldName] = { + id: $img.data('id'), + display_name: $img.data('name') + }; + self.trigger_up('kanban_record_update', data); + dialog.close(); + }); + $content.on('change', 'input.o_input_file', function () { + $content.find('form.o_form_binary_form').submit(); + }); + $(window).on(self.imageUploadID, function () { + var images = Array.prototype.slice.call(arguments, 1); + var data = {}; + data[fieldName] = { + id: images[0].id, + display_name: images[0].filename + }; + self.trigger_up('kanban_record_update', data); + dialog.close(); + }); + }); + dialog.open(); + }); + }, + /** + * Sets particular classnames on a field $el according to the + * field's attrs (display or bold attributes) + * + * @private + * @param {JQuery} $el + * @param {string} fieldName + */ + _setFieldDisplay: function ($el, fieldName) { + // attribute display + if (this.fieldsInfo[fieldName].display === 'right') { + $el.addClass('float-right'); + } else if (this.fieldsInfo[fieldName].display === 'full') { + $el.addClass('o_text_block'); + } + + // attribute bold + if (this.fieldsInfo[fieldName].bold) { + $el.addClass('o_text_bold'); + } + }, + /** + * Sets internal values of the kanban record according to the given state + * + * @private + * @param {Object} recordState + */ + _setState: function (recordState) { + this.state = recordState; + this.id = recordState.res_id; + this.db_id = recordState.id; + this.recordData = recordState.data; + this.record = this._transformRecord(recordState.data); + this.qweb_context = { + context: this.state.getContext(), + kanban_image: this._getImageURL.bind(this), + kanban_color: this._getColorClassname.bind(this), + kanban_getcolor: this._getColorID.bind(this), + kanban_getcolorname: this._getColorname.bind(this), + kanban_compute_domain: this._computeDomain.bind(this), + selection_mode: this.selectionMode, + read_only_mode: this.read_only_mode, + record: this.record, + user_context: this.getSession().user_context, + widget: this, + }; + }, + /** + * If an attribute `color` is set on the kanban record, adds the + * corresponding color classname. + * + * @private + */ + _setupColor: function () { + var color_field = this.$el.attr('color'); + if (color_field && color_field in this.fields) { + var colorHelp = _.str.sprintf(_t("Card color: %s"), this._getColorname(this.recordData[color_field])); + var colorClass = this._getColorClassname(this.recordData[color_field]); + this.$el.addClass(colorClass); + this.$el.prepend('<span title="' + colorHelp + '" aria-label="' + colorHelp + '" role="img" class="oe_kanban_color_help"/>'); + } + }, + /** + * Renders the color picker in the kanban record, and binds the event handler + * + * @private + */ + _setupColorPicker: function () { + var $colorpicker = this.$('ul.oe_kanban_colorpicker'); + if (!$colorpicker.length) { + return; + } + $colorpicker.html(QWeb.render('KanbanColorPicker', { colors: KANBAN_RECORD_COLORS})); + $colorpicker.on('click', 'a', this._onColorChanged.bind(this)); + }, + /** + * Builds an object containing the formatted record data used in the + * template + * + * @private + * @param {Object} recordData + * @returns {Object} transformed record data + */ + _transformRecord: function (recordData) { + var self = this; + var new_record = {}; + _.each(this.state.getFieldNames(), function (name) { + var value = recordData[name]; + var r = _.clone(self.fields[name] || {}); + + if ((r.type === 'date' || r.type === 'datetime') && value) { + r.raw_value = value.toDate(); + } else if (r.type === 'one2many' || r.type === 'many2many') { + r.raw_value = value.count ? value.res_ids : []; + } else if (r.type === 'many2one') { + r.raw_value = value && value.res_id || false; + } else { + r.raw_value = value; + } + + if (r.type) { + var formatter = field_utils.format[r.type]; + r.value = formatter(value, self.fields[name], recordData, self.state); + } else { + r.value = value; + } + + new_record[name] = r; + }); + return new_record; + }, + /** + * Notifies the controller that the record has changed + * + * @private + * @param {Object} data the new values + */ + _updateRecord: function (data) { + this.trigger_up('kanban_record_update', data); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onColorChanged: function (event) { + event.preventDefault(); + var data = {}; + var color_field = $(event.delegateTarget).data('field') || 'color'; + data[color_field] = $(event.currentTarget).data('color'); + this.trigger_up('kanban_record_update', data); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onGlobalClick: function (event) { + if ($(event.target).parents('.o_dropdown_kanban').length) { + return; + } + var trigger = true; + var elem = event.target; + var ischild = true; + var children = []; + while (elem) { + var events = $._data(elem, 'events'); + if (elem === event.currentTarget) { + ischild = false; + } + var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== 'bs.tooltip'); + var testLinkWithHref = elem.nodeName.toLowerCase() === 'a' && elem.href; + if (ischild) { + children.push(elem); + if (test_event || testLinkWithHref) { + // Do not trigger global click if one child has a click + // event registered (or it is a link with href) + trigger = false; + } + } + if (trigger && test_event) { + _.each(events.click, function (click_event) { + if (click_event.selector) { + // For each parent of original target, check if a + // delegated click is bound to any previously found children + _.each(children, function (child) { + if ($(child).is(click_event.selector)) { + trigger = false; + } + }); + } + }); + } + elem = elem.parentElement; + } + if (trigger) { + this._openRecord(); + } + }, + /** + * @private + * @param {MouseEvent} event + */ + _onKanbanActionClicked: function (event) { + event.preventDefault(); + + var $action = $(event.currentTarget); + var type = $action.data('type') || 'button'; + + switch (type) { + case 'edit': + this.trigger_up('open_record', { id: this.db_id, mode: 'edit' }); + break; + case 'open': + this.trigger_up('open_record', { id: this.db_id }); + break; + case 'delete': + this.trigger_up('kanban_record_delete', { id: this.db_id, record: this }); + break; + case 'action': + case 'object': + var attrs = $action.data(); + attrs.confirm = $action.attr('confirm'); + this.trigger_up('button_clicked', { + attrs: attrs, + record: this.state, + }); + break; + case 'set_cover': + var fieldName = $action.data('field'); + var autoOpen = $action.data('auto-open'); + if (this.fields[fieldName].type === 'many2one' && + this.fields[fieldName].relation === 'ir.attachment' && + this.fieldsInfo[fieldName].widget === 'attachment_image') { + this._setCoverImage(fieldName, autoOpen); + } else { + var warning = _.str.sprintf(_t('Could not set the cover image: incorrect field ("%s") is provided in the view.'), fieldName); + this.do_warn(warning); + } + break; + default: + this.do_warn(false, _t("Kanban: no action for type: ") + type); + } + }, + /** + * This event is linked to the kanban card when there is a global_click + * class on this card + * + * @private + * @param {KeyDownEvent} event + */ + _onKeyDownCard: function (event) { + switch (event.keyCode) { + case $.ui.keyCode.ENTER: + if ($(event.target).hasClass('oe_kanban_global_click')) { + event.preventDefault(); + this._onGlobalClick(event); + break; + } + } + }, + /** + * This event is linked ot the kanban card when there is no global_click + * class on the card + * + * @private + * @param {KeyDownEvent} event + */ + _onKeyDownOpenFirstLink: function (event) { + switch (event.keyCode) { + case $.ui.keyCode.ENTER: + event.preventDefault(); + $(event.target).find('a, button').first().click(); + break; + } + }, + /** + * Toggles the configuration panel of the record + * + * @private + * @param {MouseEvent} event + */ + _onManageTogglerClicked: function (event) { + event.preventDefault(); + this.$el.parent().find('.o_kanban_record').not(this.$el).removeClass('o_dropdown_open'); + this.$el.toggleClass('o_dropdown_open'); + var colorClass = this._getColorClassname(this.recordData.color || 0); + this.$('.o_kanban_manage_button_section').toggleClass(colorClass); + }, +}); + +return KanbanRecord; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js new file mode 100644 index 00000000..e7206917 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js @@ -0,0 +1,315 @@ +odoo.define('web.kanban_record_quick_create', function (require) { +"use strict"; + +/** + * This file defines the RecordQuickCreate widget for Kanban. It allows to + * create kanban records directly from the Kanban view. + */ + +var core = require('web.core'); +var QuickCreateFormView = require('web.QuickCreateFormView'); +const session = require('web.session'); +var Widget = require('web.Widget'); + +var RecordQuickCreate = Widget.extend({ + className: 'o_kanban_quick_create', + custom_events: { + add: '_onAdd', + cancel: '_onCancel', + }, + events: { + 'click .o_kanban_add': '_onAddClicked', + 'click .o_kanban_edit': '_onEditClicked', + 'click .o_kanban_cancel': '_onCancelClicked', + 'mousedown': '_onMouseDown', + }, + mouseDownInside: false, + + /** + * @override + * @param {Widget} parent + * @param {Object} options + * @param {Object} options.context + * @param {string|null} options.formViewRef + * @param {string} options.model + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.context = options.context; + this.formViewRef = options.formViewRef; + this.model = options.model; + this._disabled = false; // to prevent from creating multiple records (e.g. on double-clicks) + }, + /** + * Loads the form fieldsView (if not provided), instantiates the form view + * and starts the form controller. + * + * @override + */ + willStart: function () { + var self = this; + var superWillStart = this._super.apply(this, arguments); + var viewsLoaded; + if (this.formViewRef) { + var views = [[false, 'form']]; + var context = _.extend({}, this.context, { + form_view_ref: this.formViewRef, + }); + viewsLoaded = this.loadViews(this.model, context, views); + } else { + var fieldsView = {}; + fieldsView.arch = '<form>' + + '<field name="display_name" placeholder="Title" modifiers=\'{"required": true}\'/>' + + '</form>'; + var fields = { + display_name: {string: 'Display name', type: 'char'}, + }; + fieldsView.fields = fields; + fieldsView.viewFields = fields; + viewsLoaded = Promise.resolve({form: fieldsView}); + } + viewsLoaded = viewsLoaded.then(function (fieldsViews) { + var formView = new QuickCreateFormView(fieldsViews.form, { + context: self.context, + modelName: self.model, + userContext: session.user_context, + }); + return formView.getController(self).then(function (controller) { + self.controller = controller; + return self.controller.appendTo(document.createDocumentFragment()); + }); + }); + return Promise.all([superWillStart, viewsLoaded]); + }, + /** + * @override + */ + start: function () { + this.$el.append(this.controller.$el); + this.controller.renderButtons(this.$el); + + // focus the first field + this.controller.autofocus(); + + // destroy the quick create when the user clicks outside + core.bus.on('click', this, this._onWindowClicked); + + return this._super.apply(this, arguments); + }, + /** + * Called when the quick create is appended into the DOM. + */ + on_attach_callback: function () { + if (this.controller) { + this.controller.autofocus(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Cancels the quick creation if the record isn't dirty, i.e. if no changes + * have been made yet + * + * @private + * @returns {Promise} + */ + cancel: function () { + var self = this; + return this.controller.commitChanges().then(function () { + if (!self.controller.isDirty()) { + self._cancel(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} [options] + * @param {boolean} [options.openRecord] set to true to directly open the + * newly created record in a form view (in edit mode) + */ + _add: function (options) { + var self = this; + if (this._disabled) { + // don't do anything if we are already creating a record + return; + } + // disable the widget to prevent the user from creating multiple records + // with the current values ; if the create works, the widget will be + // destroyed and another one will be instantiated, so there is no need + // to re-enable it in that case + this._disableQuickCreate(); + this.controller.commitChanges().then(function () { + var canBeSaved = self.controller.canBeSaved(); + if (canBeSaved) { + self.trigger_up('quick_create_add_record', { + openRecord: options && options.openRecord || false, + values: self.controller.getChanges(), + onFailure: self._enableQuickCreate.bind(self), + }); + } else { + self._enableQuickCreate(); + } + }).guardedCatch(this._enableQuickCreate.bind(this)); + }, + /** + * Notifies the environment that the quick creation must be cancelled + * + * @private + * @returns {Promise} + */ + _cancel: function () { + this.trigger_up('cancel_quick_create'); + }, + /** + * Disable the widget to indicate the user that it can't interact with it. + * This function must be called when a record is being created, to prevent + * it from being created twice. + * + * Note that if the record creation works as expected, there is no need to + * re-enable the widget as it will be destroyed anyway (and replaced by a + * new instance). + * + * @private + */ + _disableQuickCreate: function () { + this._disabled = true; // ensures that the record won't be created twice + this.$el.addClass('o_disabled'); + this.$('input:not(:disabled)') + .addClass('o_temporarily_disabled') + .attr('disabled', 'disabled'); + }, + /** + * Re-enable the widget to allow the user to create again. + * + * @private + */ + _enableQuickCreate: function () { + this._disabled = false; // allows to create again + this.$el.removeClass('o_disabled'); + this.$('input.o_temporarily_disabled') + .removeClass('o_temporarily_disabled') + .attr('disabled', false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onAdd: function (ev) { + ev.stopPropagation(); + this._add(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAddClicked: function (ev) { + ev.stopPropagation(); + this._add(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onCancel: function (ev) { + ev.stopPropagation(); + this._cancel(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onCancelClicked: function (ev) { + ev.stopPropagation(); + this._cancel(); + }, + /** + * Validates the quick creation and directly opens the record in a form + * view in edit mode. + * + * @private + * @param {MouseEvent} ev + */ + _onEditClicked: function (ev) { + ev.stopPropagation(); + this._add({openRecord: true}); + }, + /** + * When a click happens outside the quick create, we want to close the quick + * create. + * + * This is quite tricky, because in some cases a click is performed outside + * the quick create, but is still related to it (e.g. click in a dialog + * opened from the quick create). + * + * @param {MouseEvent} ev + */ + _onWindowClicked: function (ev) { + var mouseDownInside = this.mouseDownInside; + + this.mouseDownInside = false; + // ignore clicks if the quick create is not in the dom + if (!document.contains(this.el)) { + return; + } + + // ignore clicks on elements that open the quick create widget, to + // prevent from closing quick create widget that has just been opened + if ($(ev.target).closest('.o-kanban-button-new, .o_kanban_quick_add').length) { + return; + } + + // ignore clicks in autocomplete dropdowns + if ($(ev.target).parents('.ui-autocomplete').length) { + return; + } + + // ignore clicks in modals + if ($(ev.target).closest('.modal').length) { + return; + } + + // ignore clicks while a modal is just about to open + if ($(document.body).hasClass('modal-open')) { + return; + } + + // ignore clicks if target is no longer in dom (e.g., a click on the + // 'delete' trash icon of a m2m tag) + if (!document.contains(ev.target)) { + return; + } + + // ignore clicks if target is inside the quick create + if (this.el.contains(ev.target) || this.el === ev.target || mouseDownInside) { + return; + } + + this.cancel(); + }, + /** + * Detects if the click is originally from the quick create + * + * @private + * @param {MouseEvent} ev + */ + _onMouseDown: function(ev){ + this.mouseDownInside = true; + } +}); + +return RecordQuickCreate; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js new file mode 100644 index 00000000..dfaba0a1 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -0,0 +1,684 @@ +odoo.define('web.KanbanRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var ColumnQuickCreate = require('web.kanban_column_quick_create'); +var config = require('web.config'); +var core = require('web.core'); +var KanbanColumn = require('web.KanbanColumn'); +var KanbanRecord = require('web.KanbanRecord'); +var QWeb = require('web.QWeb'); +var session = require('web.session'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); + +var qweb = core.qweb; +var _t = core._t; + +function findInNode(node, predicate) { + if (predicate(node)) { + return node; + } + if (!node.children) { + return undefined; + } + for (var i = 0; i < node.children.length; i++) { + if (findInNode(node.children[i], predicate)) { + return node.children[i]; + } + } +} + +function qwebAddIf(node, condition) { + if (node.attrs[qweb.prefix + '-if']) { + condition = _.str.sprintf("(%s) and (%s)", node.attrs[qweb.prefix + '-if'], condition); + } + node.attrs[qweb.prefix + '-if'] = condition; +} + +function transformQwebTemplate(node, fields) { + // Process modifiers + if (node.tag && node.attrs.modifiers) { + var modifiers = node.attrs.modifiers || {}; + if (modifiers.invisible) { + qwebAddIf(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible))); + } + } + switch (node.tag) { + case 'button': + case 'a': + var type = node.attrs.type || ''; + if (_.indexOf('action,object,edit,open,delete,url,set_cover'.split(','), type) !== -1) { + _.each(node.attrs, function (v, k) { + if (_.indexOf('icon,type,name,args,string,context,states,kanban_states'.split(','), k) !== -1) { + node.attrs['data-' + k] = v; + delete(node.attrs[k]); + } + }); + if (node.attrs['data-string']) { + node.attrs.title = node.attrs['data-string']; + } + if (node.tag === 'a' && node.attrs['data-type'] !== "url") { + node.attrs.href = '#'; + } else { + node.attrs.type = 'button'; + } + + var action_classes = " oe_kanban_action oe_kanban_action_" + node.tag; + if (node.attrs['t-attf-class']) { + node.attrs['t-attf-class'] += action_classes; + } else if (node.attrs['t-att-class']) { + node.attrs['t-att-class'] += " + '" + action_classes + "'"; + } else { + node.attrs['class'] = (node.attrs['class'] || '') + action_classes; + } + } + break; + } + if (node.children) { + for (var i = 0, ii = node.children.length; i < ii; i++) { + transformQwebTemplate(node.children[i], fields); + } + } +} + +var KanbanRenderer = BasicRenderer.extend({ + className: 'o_kanban_view', + config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden) + KanbanColumn: KanbanColumn, + KanbanRecord: KanbanRecord, + }, + custom_events: _.extend({}, BasicRenderer.prototype.custom_events || {}, { + close_quick_create: '_onCloseQuickCreate', + cancel_quick_create: '_onCancelQuickCreate', + set_progress_bar_state: '_onSetProgressBarState', + start_quick_create: '_onStartQuickCreate', + quick_create_column_updated: '_onQuickCreateColumnUpdated', + }), + events:_.extend({}, BasicRenderer.prototype.events || {}, { + 'keydown .o_kanban_record' : '_onRecordKeyDown' + }), + sampleDataTargets: [ + '.o_kanban_counter', + '.o_kanban_record', + '.o_kanban_toggle_fold', + '.o_column_folded', + '.o_column_archive_records', + '.o_column_unarchive_records', + ], + + /** + * @override + * @param {Object} params + * @param {boolean} params.quickCreateEnabled set to false to disable the + * quick create feature + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.widgets = []; + this.qweb = new QWeb(config.isDebug(), {_s: session.origin}, false); + var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';}); + transformQwebTemplate(templates, state.fields); + this.qweb.add_template(utils.json_node_to_xml(templates)); + this.examples = params.examples; + this.recordOptions = _.extend({}, params.record_options, { + qweb: this.qweb, + viewType: 'kanban', + }); + this.columnOptions = _.extend({KanbanRecord: this.config.KanbanRecord}, params.column_options); + if (this.columnOptions.hasProgressBar) { + this.columnOptions.progressBarStates = {}; + } + this.quickCreateEnabled = params.quickCreateEnabled; + if (!params.readOnlyMode) { + var handleField = _.findWhere(this.state.fieldsInfo.kanban, {widget: 'handle'}); + this.handleField = handleField && handleField.name; + } + this._setState(state); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () { + this._super(...arguments); + if (this.quickCreate) { + this.quickCreate.on_attach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Displays the quick create record in the requested column (first one by + * default) + * + * @params {string} [groupId] local id of the group in which the quick create + * must be inserted + * @returns {Promise} + */ + addQuickCreate: function (groupId) { + let kanbanColumn; + if (groupId) { + kanbanColumn = this.widgets.find(column => column.db_id === groupId); + } + kanbanColumn = kanbanColumn || this.widgets[0]; + return kanbanColumn.addQuickCreate(); + }, + /** + * Focuses the first kanban record + */ + giveFocus: function () { + this.$('.o_kanban_record:first').focus(); + }, + /** + * Toggle fold/unfold the Column quick create widget + */ + quickCreateToggleFold: function () { + this.quickCreate.toggleFold(); + this._toggleNoContentHelper(); + }, + /** + * Updates a given column with its new state. + * + * @param {string} localID the column id + * @param {Object} columnState + * @param {Object} [options] + * @param {Object} [options.state] if set, this represents the new state + * @param {boolean} [options.openQuickCreate] if true, directly opens the + * QuickCreate widget in the updated column + * + * @returns {Promise} + */ + updateColumn: function (localID, columnState, options) { + var self = this; + var KanbanColumn = this.config.KanbanColumn; + var newColumn = new KanbanColumn(this, columnState, this.columnOptions, this.recordOptions); + var index = _.findIndex(this.widgets, {db_id: localID}); + var column = this.widgets[index]; + this.widgets[index] = newColumn; + if (options && options.state) { + this._setState(options.state); + } + return newColumn.appendTo(document.createDocumentFragment()).then(function () { + var def; + if (options && options.openQuickCreate) { + def = newColumn.addQuickCreate(); + } + return Promise.resolve(def).then(function () { + newColumn.$el.insertAfter(column.$el); + self._toggleNoContentHelper(); + // When a record has been quick created, the new column directly + // renders the quick create widget (to allow quick creating several + // records in a row). However, as we render this column in a + // fragment, the quick create widget can't be correctly focused. So + // we manually call on_attach_callback to focus it once in the DOM. + newColumn.on_attach_callback(); + column.destroy(); + }); + }); + }, + /** + * Updates a given record with its new state. + * + * @param {Object} recordState + * @returns {Promise} + */ + updateRecord: function (recordState) { + var isGrouped = !!this.state.groupedBy.length; + var record; + + if (isGrouped) { + // if grouped, this.widgets are kanban columns so we need to find + // the kanban record inside + _.each(this.widgets, function (widget) { + record = record || _.findWhere(widget.records, { + db_id: recordState.id, + }); + }); + } else { + record = _.findWhere(this.widgets, {db_id: recordState.id}); + } + + if (record) { + return record.update(recordState); + } + return Promise.resolve(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {DOMElement} currentColumn + */ + _focusOnNextCard: function (currentCardElement) { + var nextCard = currentCardElement.nextElementSibling; + if (nextCard) { + nextCard.focus(); + } + }, + /** + * Tries to give focus to the previous card, and returns true if successful + * + * @private + * @param {DOMElement} currentColumn + * @returns {boolean} + */ + _focusOnPreviousCard: function (currentCardElement) { + var previousCard = currentCardElement.previousElementSibling; + if (previousCard && previousCard.classList.contains("o_kanban_record")) { //previous element might be column title + previousCard.focus(); + return true; + } + }, + /** + * Returns the default columns for the kanban view example background. + * You can override this method to easily customize the column names. + * + * @private + */ + _getGhostColumns: function () { + if (this.examples && this.examples.ghostColumns) { + return this.examples.ghostColumns; + } + return _.map(_.range(1, 5), function (num) { + return _.str.sprintf(_t("Column %s"), num); + }); + }, + /** + * Render the Example Ghost Kanban card on the background + * + * @private + * @param {DocumentFragment} fragment + */ + _renderExampleBackground: function (fragment) { + var $background = $(qweb.render('KanbanView.ExamplesBackground', {ghostColumns: this._getGhostColumns()})); + $background.appendTo(fragment); + }, + /** + * Renders empty invisible divs in a document fragment. + * + * @private + * @param {DocumentFragment} fragment + * @param {integer} nbDivs the number of divs to append + * @param {Object} [options] + * @param {string} [options.inlineStyle] + */ + _renderGhostDivs: function (fragment, nbDivs, options) { + var ghostDefs = []; + for (var $ghost, i = 0; i < nbDivs; i++) { + $ghost = $('<div>').addClass('o_kanban_record o_kanban_ghost'); + if (options && options.inlineStyle) { + $ghost.attr('style', options.inlineStyle); + } + var def = $ghost.appendTo(fragment); + ghostDefs.push(def); + } + return Promise.all(ghostDefs); + }, + /** + * Renders an grouped kanban view in a fragment. + * + * @private + * @param {DocumentFragment} fragment + */ + _renderGrouped: function (fragment) { + var self = this; + + // Render columns + var KanbanColumn = this.config.KanbanColumn; + _.each(this.state.data, function (group) { + var column = new KanbanColumn(self, group, self.columnOptions, self.recordOptions); + var def; + if (!group.value) { + def = column.prependTo(fragment); // display the 'Undefined' group first + self.widgets.unshift(column); + } else { + def = column.appendTo(fragment); + self.widgets.push(column); + } + self.defs.push(def); + }); + + // remove previous sorting + if(this.$el.sortable('instance') !== undefined) { + this.$el.sortable('destroy'); + } + if (this.groupedByM2O) { + // Enable column sorting + this.$el.sortable({ + axis: 'x', + items: '> .o_kanban_group', + handle: '.o_column_title', + cursor: 'move', + revert: 150, + delay: 100, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function () { + var ids = []; + self.$('.o_kanban_group').each(function (index, u) { + // Ignore 'Undefined' column + if (_.isNumber($(u).data('id'))) { + ids.push($(u).data('id')); + } + }); + self.trigger_up('resequence_columns', {ids: ids}); + }, + }); + + if (this.createColumnEnabled) { + this.quickCreate = new ColumnQuickCreate(this, { + applyExamplesText: this.examples && this.examples.applyExamplesText, + examples: this.examples && this.examples.examples, + }); + this.defs.push(this.quickCreate.appendTo(fragment).then(function () { + // Open it directly if there is no column yet + if (!self.state.data.length) { + self.quickCreate.toggleFold(); + self._renderExampleBackground(fragment); + } + })); + } + } + }, + /** + * Renders an ungrouped kanban view in a fragment. + * + * @private + * @param {DocumentFragment} fragment + */ + _renderUngrouped: function (fragment) { + var self = this; + var KanbanRecord = this.config.KanbanRecord; + var kanbanRecord; + _.each(this.state.data, function (record) { + kanbanRecord = new KanbanRecord(self, record, self.recordOptions); + self.widgets.push(kanbanRecord); + var def = kanbanRecord.appendTo(fragment); + self.defs.push(def); + }); + + // enable record resequencing if there is a field with widget='handle' + // and if there is no orderBy (in this case we assume that the widget + // has been put on the first default order field of the model), or if + // the first orderBy field is the one with widget='handle' + var orderedBy = this.state.orderedBy; + var hasHandle = this.handleField && + (orderedBy.length === 0 || orderedBy[0].name === this.handleField); + if (hasHandle) { + this.$el.sortable({ + items: '.o_kanban_record:not(.o_kanban_ghost)', + cursor: 'move', + revert: 0, + delay: 0, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function (event, ui) { + self._moveRecord(ui.item.data('record').db_id, ui.item.index()); + }, + }); + } + + // append ghost divs to ensure that all kanban records are left aligned + var prom = Promise.all(self.defs).then(function () { + var options = {}; + if (kanbanRecord) { + options.inlineStyle = kanbanRecord.$el.attr('style'); + } + return self._renderGhostDivs(fragment, 6, options); + }); + this.defs.push(prom); + }, + /** + * @override + * @private + */ + _renderView: function () { + var self = this; + + // render the kanban view + var isGrouped = !!this.state.groupedBy.length; + var fragment = document.createDocumentFragment(); + var defs = []; + this.defs = defs; + if (isGrouped) { + this._renderGrouped(fragment); + } else { + this._renderUngrouped(fragment); + } + delete this.defs; + + return this._super.apply(this, arguments).then(function () { + return Promise.all(defs).then(function () { + self.$el.empty(); + self.$el.toggleClass('o_kanban_grouped', isGrouped); + self.$el.toggleClass('o_kanban_ungrouped', !isGrouped); + self.$el.append(fragment); + self._toggleNoContentHelper(); + }); + }); + }, + /** + * @param {boolean} [remove] if true, the nocontent helper is always removed + * @private + */ + _toggleNoContentHelper: function (remove) { + var displayNoContentHelper = + !remove && + !this._hasContent() && + !!this.noContentHelp && + !(this.quickCreate && !this.quickCreate.folded) && + !this.state.isGroupedByM2ONoColumn; + + var $noContentHelper = this.$('.o_view_nocontent'); + + if (displayNoContentHelper && !$noContentHelper.length) { + this._renderNoContentHelper(); + } + if (!displayNoContentHelper && $noContentHelper.length) { + $noContentHelper.remove(); + } + }, + /** + * Sets the current state and updates some internal attributes accordingly. + * + * @override + */ + _setState: function () { + this._super(...arguments); + + var groupByField = this.state.groupedBy[0]; + var cleanGroupByField = this._cleanGroupByField(groupByField); + var groupByFieldAttrs = this.state.fields[cleanGroupByField]; + var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField]; + // Deactivate the drag'n'drop if the groupedBy field: + // - is a date or datetime since we group by month or + // - is readonly (on the field attrs or in the view) + var draggable = true; + var grouped_by_date = false; + if (groupByFieldAttrs) { + if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") { + draggable = false; + grouped_by_date = true; + } else if (groupByFieldAttrs.readonly !== undefined) { + draggable = !(groupByFieldAttrs.readonly); + } + } + if (groupByFieldInfo) { + if (draggable && groupByFieldInfo.readonly !== undefined) { + draggable = !(groupByFieldInfo.readonly); + } + } + this.groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one'); + var relation = this.groupedByM2O && groupByFieldAttrs.relation; + var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip; + this.columnOptions = _.extend(this.columnOptions, { + draggable: draggable, + group_by_tooltip: groupByTooltip, + groupedBy: groupByField, + grouped_by_m2o: this.groupedByM2O, + grouped_by_date: grouped_by_date, + relation: relation, + quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state), + }); + this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable; + }, + /** + * Remove date/datetime magic grouping info to get proper field attrs/info from state + * ex: sent_date:month will become sent_date + * + * @private + * @param {string} groupByField + * @returns {string} + */ + _cleanGroupByField: function (groupByField) { + var cleanGroupByField = groupByField; + if (cleanGroupByField && cleanGroupByField.indexOf(':') > -1) { + cleanGroupByField = cleanGroupByField.substring(0, cleanGroupByField.indexOf(':')); + } + + return cleanGroupByField; + }, + /** + * Moves the focus on the first card of the next column in a given direction + * This ignores the folded columns and skips over the empty columns. + * In ungrouped kanban, moves the focus to the next/previous card + * + * @param {DOMElement} eventTarget the target of the keydown event + * @param {string} direction contains either 'LEFT' or 'RIGHT' + */ + _focusOnCardInColumn: function(eventTarget, direction) { + var currentColumn = eventTarget.parentElement; + var hasSelectedACard = false; + var cannotSelectAColumn = false; + while (!hasSelectedACard && !cannotSelectAColumn) { + var candidateColumn = direction === 'LEFT' ? + currentColumn.previousElementSibling : + currentColumn.nextElementSibling ; + currentColumn = candidateColumn; + if (candidateColumn) { + var allCardsOfCandidateColumn = + candidateColumn.getElementsByClassName('o_kanban_record'); + if (allCardsOfCandidateColumn.length) { + allCardsOfCandidateColumn[0].focus(); + hasSelectedACard = true; + } + } + else { // either there are no more columns in the direction or + // this is not a grouped kanban + direction === 'LEFT' ? + this._focusOnPreviousCard(eventTarget) : + this._focusOnNextCard(eventTarget); + cannotSelectAColumn = true; + } + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onCancelQuickCreate: function () { + this._toggleNoContentHelper(); + }, + /** + * Closes the opened quick create widgets in columns + * + * @private + */ + _onCloseQuickCreate: function () { + if (this.state.groupedBy.length) { + _.invoke(this.widgets, 'cancelQuickCreate'); + } + this._toggleNoContentHelper(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onQuickCreateColumnUpdated: function (ev) { + ev.stopPropagation(); + this._toggleNoContentHelper(); + this._updateExampleBackground(); + }, + /** + * @private + * @param {KeyboardEvent} e + */ + _onRecordKeyDown: function(e) { + switch(e.which) { + case $.ui.keyCode.DOWN: + this._focusOnNextCard(e.currentTarget); + e.stopPropagation(); + e.preventDefault(); + break; + case $.ui.keyCode.UP: + const previousFocused = this._focusOnPreviousCard(e.currentTarget); + if (!previousFocused) { + this.trigger_up('navigation_move', { direction: 'up' }); + } + e.stopPropagation(); + e.preventDefault(); + break; + case $.ui.keyCode.RIGHT: + this._focusOnCardInColumn(e.currentTarget, 'RIGHT'); + e.stopPropagation(); + e.preventDefault(); + break; + case $.ui.keyCode.LEFT: + this._focusOnCardInColumn(e.currentTarget, 'LEFT'); + e.stopPropagation(); + e.preventDefault(); + break; + } + }, + /** + * Updates progressbar internal states (necessary for animations) with + * received data. + * + * @private + * @param {OdooEvent} ev + */ + _onSetProgressBarState: function (ev) { + if (!this.columnOptions.progressBarStates[ev.data.columnID]) { + this.columnOptions.progressBarStates[ev.data.columnID] = {}; + } + _.extend(this.columnOptions.progressBarStates[ev.data.columnID], ev.data.values); + }, + /** + * Closes the opened quick create widgets in columns + * + * @private + */ + _onStartQuickCreate: function () { + this._toggleNoContentHelper(true); + }, + /** + * Hide or display the background example: + * - displayed when quick create column is display and there is no column else + * - hidden otherwise + * + * @private + **/ + _updateExampleBackground: function () { + var $elem = this.$('.o_kanban_example_background_container'); + if (!this.state.data.length && !$elem.length) { + this._renderExampleBackground(this.$el); + } else { + $elem.remove(); + } + }, +}); + +return KanbanRenderer; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js new file mode 100644 index 00000000..1add9169 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -0,0 +1,119 @@ +odoo.define('web.KanbanView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var KanbanController = require('web.KanbanController'); +var kanbanExamplesRegistry = require('web.kanban_examples_registry'); +var KanbanModel = require('web.KanbanModel'); +var KanbanRenderer = require('web.KanbanRenderer'); +var utils = require('web.utils'); + +var _lt = core._lt; + +var KanbanView = BasicView.extend({ + accesskey: "k", + display_name: _lt("Kanban"), + icon: 'fa-th-large', + mobile_friendly: true, + config: _.extend({}, BasicView.prototype.config, { + Model: KanbanModel, + Controller: KanbanController, + Renderer: KanbanRenderer, + }), + jsLibs: [], + viewType: 'kanban', + + /** + * @constructor + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + this.loadParams.limit = this.loadParams.limit || 40; + this.loadParams.openGroupByDefault = true; + this.loadParams.type = 'list'; + this.noDefaultGroupby = params.noDefaultGroupby; + var progressBar; + utils.traverse(this.arch, function (n) { + var isProgressBar = (n.tag === 'progressbar'); + if (isProgressBar) { + progressBar = _.clone(n.attrs); + progressBar.colors = JSON.parse(progressBar.colors); + progressBar.sum_field = progressBar.sum_field || false; + } + return !isProgressBar; + }); + if (progressBar) { + this.loadParams.progressBar = progressBar; + } + + var activeActions = this.controllerParams.activeActions; + var archAttrs = this.arch.attrs; + activeActions = _.extend(activeActions, { + group_create: this.arch.attrs.group_create ? !!JSON.parse(archAttrs.group_create) : true, + group_edit: archAttrs.group_edit ? !!JSON.parse(archAttrs.group_edit) : true, + group_delete: archAttrs.group_delete ? !!JSON.parse(archAttrs.group_delete) : true, + }); + + this.rendererParams.column_options = { + editable: activeActions.group_edit, + deletable: activeActions.group_delete, + archivable: archAttrs.archivable ? !!JSON.parse(archAttrs.archivable) : true, + group_creatable: activeActions.group_create, + quickCreateView: archAttrs.quick_create_view || null, + recordsDraggable: archAttrs.records_draggable ? !!JSON.parse(archAttrs.records_draggable) : true, + hasProgressBar: !!progressBar, + }; + this.rendererParams.record_options = { + editable: activeActions.edit, + deletable: activeActions.delete, + read_only_mode: params.readOnlyMode, + selectionMode: params.selectionMode, + }; + this.rendererParams.quickCreateEnabled = this._isQuickCreateEnabled(); + this.rendererParams.readOnlyMode = params.readOnlyMode; + var examples = archAttrs.examples; + if (examples) { + this.rendererParams.examples = kanbanExamplesRegistry.get(examples); + } + + this.controllerParams.on_create = archAttrs.on_create; + this.controllerParams.hasButtons = !params.selectionMode ? true : false; + this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} viewInfo + * @returns {boolean} true iff the quick create feature is not explicitely + * disabled (with create="False" or quick_create="False" in the arch) + */ + _isQuickCreateEnabled: function () { + if (!this.controllerParams.activeActions.create) { + return false; + } + if (this.arch.attrs.quick_create !== undefined) { + return !!JSON.parse(this.arch.attrs.quick_create); + } + return true; + }, + /** + * @override + * @private + */ + _updateMVCParams: function () { + this._super.apply(this, arguments); + if (this.searchMenuTypes.includes('groupBy') && !this.noDefaultGroupby && this.arch.attrs.default_group_by) { + this.loadParams.groupBy = [this.arch.attrs.default_group_by]; + } + }, +}); + +return KanbanView; + +}); diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js new file mode 100644 index 00000000..9286ed82 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js @@ -0,0 +1,123 @@ +odoo.define('web.QuickCreateFormView', function (require) { +"use strict"; + +/** + * This file defines the QuickCreateFormView, an extension of the FormView that + * is used by the RecordQuickCreate in Kanban views. + */ + +var BasicModel = require('web.BasicModel'); +var FormController = require('web.FormController'); +var FormRenderer = require('web.FormRenderer'); +var FormView = require('web.FormView'); +const { qweb } = require("web.core"); + +var QuickCreateFormRenderer = FormRenderer.extend({ + /** + * @override + */ + start: async function () { + await this._super.apply(this, arguments); + this.$el.addClass('o_xxs_form_view'); + this.$el.removeClass('o_xxl_form_view'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Override to do nothing so that the renderer won't resize on window resize + * + * @override + */ + _applyFormSizeClass() {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + var direction = ev.data.direction; + if (direction === 'cancel' || direction === 'next_line') { + ev.stopPropagation(); + this.trigger_up(direction === 'cancel' ? 'cancel' : 'add'); + } else { + this._super.apply(this, arguments); + } + }, +}); + +var QuickCreateFormModel = BasicModel.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object} the changes of the given resource (server commands for + * x2manys) + */ + getChanges: function (localID) { + var record = this.localData[localID]; + return this._generateChanges(record, {changesOnly: false}); + }, +}); + +var QuickCreateFormController = FormController.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks all field widgets to notify the environment with their current value + * (useful for instance for input fields that still have the focus and that + * could have not notified the environment of their changes yet). + * Synchronizes with the controller's mutex in case there would already be + * pending changes being applied. + * + * @return {Promise} + */ + commitChanges: function () { + var mutexDef = this.mutex.getUnlockedDef(); + return Promise.all([mutexDef, this.renderer.commitChanges(this.handle)]); + }, + /** + * @returns {Object} the changes done on the current record + */ + getChanges: function () { + return this.model.getChanges(this.handle); + }, + + /** + * @override + */ + renderButtons($node) { + this.$buttons = $(qweb.render('KanbanView.RecordQuickCreate.buttons')); + if ($node) { + this.$buttons.appendTo($node); + } + }, + + /** + * @override + */ + updateButtons() {/* No need to update the buttons */}, +}); + +var QuickCreateFormView = FormView.extend({ + withControlPanel: false, + config: _.extend({}, FormView.prototype.config, { + Model: QuickCreateFormModel, + Renderer: QuickCreateFormRenderer, + Controller: QuickCreateFormController, + }), +}); + +return QuickCreateFormView; + +}); diff --git a/addons/web/static/src/js/views/list/list_confirm_dialog.js b/addons/web/static/src/js/views/list/list_confirm_dialog.js new file mode 100644 index 00000000..7fba13c3 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_confirm_dialog.js @@ -0,0 +1,104 @@ +odoo.define('web.ListConfirmDialog', function (require) { +"use strict"; + +const core = require('web.core'); +const Dialog = require('web.Dialog'); +const FieldWrapper = require('web.FieldWrapper'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); +const utils = require('web.utils'); + +const _t = core._t; +const qweb = core.qweb; + +/** + * Multi edition confirmation modal for list views. + * + * Handles the display of the amount of changed records (+ valid ones) and + * of the widget representing the new value. + * + * @class + */ +const ListConfirmDialog = Dialog.extend(WidgetAdapterMixin, { + /** + * @constructor + * @override + * @param {Widget} parent + * @param {Object} record edited record with updated value + * @param {Object} changes changes registered by the list controller + * @param {Object} changes isDomainSelected true iff the user selected the + * whole domain + * @param {string} changes.fieldLabel label of the changed field + * @param {string} changes.fieldName technical name of the changed field + * @param {number} changes.nbRecords number of records (total) + * @param {number} changes.nbValidRecords number of valid records + * @param {Object} [options] + */ + init: function (parent, record, changes, options) { + options = Object.assign({}, options, { + $content: $(qweb.render('ListView.confirmModal', { changes })), + buttons: options.buttons || [{ + text: _t("Ok"), + classes: 'btn-primary', + close: true, + click: options.confirm_callback, + }, { + text: _t("Cancel"), + close: true, + click: options.cancel_callback, + }], + onForceClose: options.cancel_callback, + size: options.size || 'medium', + title: options.title || _t("Confirmation"), + }); + + this._super(parent, options); + + const Widget = record.fieldsInfo.list[changes.fieldName].Widget; + const widgetOptions = { + mode: 'readonly', + viewType: 'list', + noOpen: true, + }; + this.isLegacyWidget = !utils.isComponent(Widget); + if (this.isLegacyWidget) { + this.fieldWidget = new Widget(this, changes.fieldName, record, widgetOptions); + } else { + this.fieldWidget = new FieldWrapper(this, Widget, { + fieldName: changes.fieldName, + record, + options: widgetOptions, + }); + } + }, + /** + * @override + */ + willStart: function () { + let widgetProm; + if (this.isLegacyWidget) { + widgetProm = this.fieldWidget._widgetRenderAndInsert(function () {}); + } else { + widgetProm = this.fieldWidget.mount(document.createDocumentFragment()); + } + return Promise.all([widgetProm, this._super.apply(this, arguments)]); + }, + /** + * @override + */ + start: function () { + this.$content.find('.o_changes_widget').replaceWith(this.fieldWidget.$el); + this.fieldWidget.el.style.pointerEvents = 'none'; + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + WidgetAdapterMixin.destroy.call(this); + this._super(); + }, +}); + +return ListConfirmDialog; + +}); diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js new file mode 100644 index 00000000..a19afb6a --- /dev/null +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -0,0 +1,992 @@ +odoo.define('web.ListController', function (require) { +"use strict"; + +/** + * The List Controller controls the list renderer and the list model. Its role + * is to allow these two components to communicate properly, and also, to render + * and bind all extra buttons/pager in the control panel. + */ + +var core = require('web.core'); +var BasicController = require('web.BasicController'); +var DataExport = require('web.DataExport'); +var Dialog = require('web.Dialog'); +var ListConfirmDialog = require('web.ListConfirmDialog'); +var session = require('web.session'); +const viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +var ListController = BasicController.extend({ + /** + * This key contains the name of the buttons template to render on top of + * the list view. It can be overridden to add buttons in specific child views. + */ + buttons_template: 'ListView.buttons', + events: _.extend({}, BasicController.prototype.events, { + 'click .o_list_export_xlsx': '_onDirectExportData', + 'click .o_list_select_domain': '_onSelectDomain', + }), + custom_events: _.extend({}, BasicController.prototype.custom_events, { + activate_next_widget: '_onActivateNextWidget', + add_record: '_onAddRecord', + button_clicked: '_onButtonClicked', + group_edit_button_clicked: '_onEditGroupClicked', + edit_line: '_onEditLine', + save_line: '_onSaveLine', + selection_changed: '_onSelectionChanged', + toggle_column_order: '_onToggleColumnOrder', + toggle_group: '_onToggleGroup', + }), + /** + * @constructor + * @override + * @param {Object} params + * @param {boolean} params.editable + * @param {boolean} params.hasActionMenus + * @param {Object[]} [params.headerButtons=[]]: a list of node descriptors + * for controlPanel's action buttons + * @param {Object} params.toolbarActions + * @param {boolean} params.noLeaf + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.hasActionMenus = params.hasActionMenus; + this.headerButtons = params.headerButtons || []; + this.toolbarActions = params.toolbarActions || {}; + this.editable = params.editable; + this.noLeaf = params.noLeaf; + this.selectedRecords = params.selectedRecords || []; + this.multipleRecordsSavingPromise = null; + this.fieldChangedPrevented = false; + this.lastFieldChangedEvent = null; + this.isPageSelected = false; // true iff all records of the page are selected + this.isDomainSelected = false; // true iff the user selected all records matching the domain + this.isExportEnable = false; + }, + + willStart() { + const sup = this._super(...arguments); + const acl = session.user_has_group('base.group_allow_export').then(hasGroup => { + this.isExportEnable = hasGroup; + }); + return Promise.all([sup, acl]); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /* + * @override + */ + getOwnedQueryParams: function () { + var state = this._super.apply(this, arguments); + var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || []; + return _.extend({}, state, {orderedBy: orderedBy}); + }, + /** + * Returns the list of currently selected res_ids (with the check boxes on + * the left) + * + * @override + * + * @returns {number[]} list of res_ids + */ + getSelectedIds: function () { + return _.map(this.getSelectedRecords(), function (record) { + return record.res_id; + }); + }, + /** + * Returns the list of currently selected records (with the check boxes on + * the left) + * + * @returns {Object[]} list of records + */ + getSelectedRecords: function () { + var self = this; + return _.map(this.selectedRecords, function (db_id) { + return self.model.get(db_id, {raw: true}); + }); + }, + /** + * Display and bind all buttons in the control panel + * + * Note: clicking on the "Save" button does nothing special. Indeed, all + * editable rows are saved once left and clicking on the "Save" button does + * induce the leaving of the current row. + * + * @override + */ + renderButtons: function ($node) { + if (this.noLeaf || !this.hasButtons) { + this.hasButtons = false; + this.$buttons = $('<div>'); + } else { + this.$buttons = $(qweb.render(this.buttons_template, {widget: this})); + this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this)); + this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add')); + this.$buttons.find('.o_list_button_add').tooltip({ + delay: {show: 200, hide: 0}, + title: function () { + return qweb.render('CreateButton.tooltip'); + }, + trigger: 'manual', + }); + this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this)); + this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); + } + if ($node) { + this.$buttons.appendTo($node); + } + }, + /** + * Renders (and updates) the buttons that are described inside the `header` + * node of the list view arch. Those buttons are visible when selecting some + * records. They will be appended to the controlPanel's buttons. + * + * @private + */ + _renderHeaderButtons() { + if (this.$headerButtons) { + this.$headerButtons.remove(); + this.$headerButtons = null; + } + if (!this.headerButtons.length || !this.selectedRecords.length) { + return; + } + const btnClasses = 'btn-primary btn-secondary btn-link btn-success btn-info btn-warning btn-danger'.split(' '); + let $elms = $(); + this.headerButtons.forEach(node => { + const $btn = viewUtils.renderButtonFromNode(node); + $btn.addClass('btn'); + if (!btnClasses.some(cls => $btn.hasClass(cls))) { + $btn.addClass('btn-secondary'); + } + $btn.on("click", this._onHeaderButtonClicked.bind(this, node)); + $elms = $elms.add($btn); + }); + this.$headerButtons = $elms; + this.$headerButtons.appendTo(this.$buttons); + }, + /** + * Overrides to update the list of selected records + * + * @override + */ + update: function (params, options) { + var self = this; + let res_ids; + if (options && options.keepSelection) { + // filter out removed records from selection + res_ids = this.model.get(this.handle).res_ids; + this.selectedRecords = _.filter(this.selectedRecords, function (id) { + return _.contains(res_ids, self.model.get(id).res_id); + }); + } else { + this.selectedRecords = []; + } + if (this.selectedRecords.length === 0 || this.selectedRecords.length < res_ids.length) { + this.isDomainSelected = false; + this.isPageSelected = false; + } + + params.selectedRecords = this.selectedRecords; + return this._super.apply(this, arguments); + }, + /** + * This helper simply makes sure that the control panel buttons matches the + * current mode. + * + * @override + * @param {string} mode either 'readonly' or 'edit' + */ + updateButtons: function (mode) { + if (this.hasButtons) { + this.$buttons.toggleClass('o-editing', mode === 'edit'); + const state = this.model.get(this.handle, {raw: true}); + if (state.count) { + this.$buttons.find('.o_list_export_xlsx').show(); + } else { + this.$buttons.find('.o_list_export_xlsx').hide(); + } + } + this._updateSelectionBox(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @see BasicController._abandonRecord + * If the given abandoned record is not the main one, notifies the renderer + * to remove the appropriate subrecord (line). + * + * @override + * @private + * @param {string} [recordID] - default to the main recordID + */ + _abandonRecord: function (recordID) { + this._super.apply(this, arguments); + if ((recordID || this.handle) !== this.handle) { + var state = this.model.get(this.handle); + this.renderer.removeLine(state, recordID); + this._updatePaging(state); + } + }, + /** + * Adds a new record to the a dataPoint of type 'list'. + * Disables the buttons to prevent concurrent record creation or edition. + * + * @todo make record creation a basic controller feature + * @private + * @param {string} dataPointId a dataPoint of type 'list' (may be grouped) + * @return {Promise} + */ + _addRecord: function (dataPointId) { + var self = this; + this._disableButtons(); + return this._removeSampleData(() => { + return this.renderer.unselectRow().then(function () { + return self.model.addDefaultRecord(dataPointId, { + position: self.editable, + }); + }).then(function (recordID) { + var state = self.model.get(self.handle); + self._updateRendererState(state, { keepWidths: true }) + .then(function () { + self.renderer.editRecord(recordID); + }) + .then(() => { + self._updatePaging(state); + }); + }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }); + }, + /** + * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard + * + * @param {jQueryElement} $createButton The create button itself + */ + _assignCreateKeyboardBehavior: function($createButton) { + var self = this; + $createButton.on('keydown', function(e) { + $createButton.tooltip('hide'); + switch(e.which) { + case $.ui.keyCode.ENTER: + e.preventDefault(); + self._onCreateRecord.apply(self); + break; + case $.ui.keyCode.DOWN: + e.preventDefault(); + self._giveFocus(); + break; + case $.ui.keyCode.TAB: + if ( + !e.shiftKey && + e.target.classList.contains("btn-primary") && + !self.model.isInSampleMode() + ) { + e.preventDefault(); + $createButton.tooltip('show'); + } + break; + } + }); + }, + /** + * This function is the hook called by the field manager mixin to confirm + * that a record has been saved. + * + * @override + * @param {string} id a basicmodel valid resource handle. It is supposed to + * be a record from the list view. + * @returns {Promise} + */ + _confirmSave: function (id) { + var state = this.model.get(this.handle); + return this._updateRendererState(state, { noRender: true }) + .then(this._setMode.bind(this, 'readonly', id)); + }, + /** + * Deletes records matching the current domain. We limit the number of + * deleted records to the 'active_ids_limit' config parameter. + * + * @private + */ + _deleteRecordsInCurrentDomain: function () { + const doIt = async () => { + const state = this.model.get(this.handle, {raw: true}); + const resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit); + await this._rpc({ + model: this.modelName, + method: 'unlink', + args: [resIds], + context: state.getContext(), + }); + if (resIds.length === session.active_ids_limit) { + const msg = _.str.sprintf( + _t("Only the first %d records have been deleted (out of %d selected)"), + resIds.length, state.count + ); + this.do_notify(false, msg); + } + this.reload(); + }; + if (this.confirmOnDelete) { + Dialog.confirm(this, _t("Are you sure you want to delete these records ?"), { + confirm_callback: doIt, + }); + } else { + doIt(); + } + }, + /** + * To improve performance, list view must not be rerendered if it is asked + * to discard all its changes. Indeed, only the in-edition row needs to be + * discarded in that case. + * + * @override + * @private + * @param {string} [recordID] - default to main recordID + * @returns {Promise} + */ + _discardChanges: function (recordID) { + if ((recordID || this.handle) === this.handle) { + recordID = this.renderer.getEditableRecordID(); + if (recordID === null) { + return Promise.resolve(); + } + } + var self = this; + return this._super(recordID).then(function () { + self.updateButtons('readonly'); + }); + }, + /** + * Returns the ids of records matching the given domain. + * + * @private + * @param {Array[]} domain + * @param {integer} [limit] + * @returns {integer[]} + */ + _domainToResIds: function (domain, limit) { + return this._rpc({ + model: this.modelName, + method: 'search', + args: [domain], + kwargs: { + limit: limit, + }, + }); + }, + /** + * @returns {DataExport} the export dialog widget + * @private + */ + _getExportDialogWidget() { + let state = this.model.get(this.handle); + let defaultExportFields = this.renderer.columns.filter(field => field.tag === 'field' && state.fields[field.attrs.name].exportable !== false).map(field => field.attrs.name); + let groupedBy = this.renderer.state.groupedBy; + const domain = this.isDomainSelected && state.getDomain(); + return new DataExport(this, state, defaultExportFields, groupedBy, + domain, this.getSelectedIds()); + }, + /** + * Only display the pager when there are data to display. + * + * @override + * @private + */ + _getPagingInfo: function (state) { + if (!state.count) { + return null; + } + return this._super(...arguments); + }, + /** + * @override + * @private + */ + _getActionMenuItems: function (state) { + if (!this.hasActionMenus || !this.selectedRecords.length) { + return null; + } + const props = this._super(...arguments); + const otherActionItems = []; + if (this.isExportEnable) { + otherActionItems.push({ + description: _t("Export"), + callback: () => this._onExportData() + }); + } + if (this.archiveEnabled) { + otherActionItems.push({ + description: _t("Archive"), + callback: () => { + Dialog.confirm(this, _t("Are you sure that you want to archive all the selected records?"), { + confirm_callback: () => this._toggleArchiveState(true), + }); + } + }, { + description: _t("Unarchive"), + callback: () => this._toggleArchiveState(false) + }); + } + if (this.activeActions.delete) { + otherActionItems.push({ + description: _t("Delete"), + callback: () => this._onDeleteSelectedRecords() + }); + } + return Object.assign(props, { + items: Object.assign({}, this.toolbarActions, { other: otherActionItems }), + context: state.getContext(), + domain: state.getDomain(), + isDomainSelected: this.isDomainSelected, + }); + }, + /** + * Saves multiple records at once. This method is called by the _onFieldChanged method + * since the record must be confirmed as soon as the focus leaves a dirty cell. + * Pseudo-validation is performed with registered modifiers. + * Returns a promise that is resolved when confirming and rejected in any other case. + * + * @private + * @param {string} recordId + * @param {Object} node + * @param {Object} changes + * @returns {Promise} + */ + _saveMultipleRecords: function (recordId, node, changes) { + var fieldName = Object.keys(changes)[0]; + var value = Object.values(changes)[0]; + var recordIds = _.union([recordId], this.selectedRecords); + var validRecordIds = recordIds.reduce((result, nextRecordId) => { + var record = this.model.get(nextRecordId); + var modifiers = this.renderer._registerModifiers(node, record); + if (!modifiers.readonly && (!modifiers.required || value)) { + result.push(nextRecordId); + } + return result; + }, []); + return new Promise((resolve, reject) => { + const saveRecords = () => { + this.model.saveRecords(this.handle, recordId, validRecordIds, fieldName) + .then(async () => { + this.updateButtons('readonly'); + const state = this.model.get(this.handle); + // We need to check the current multi-editable state here + // in case the selection is changed. If there are changes + // and the list was multi-editable, we do not want to select + // the next row. + this.selectedRecords = []; + await this._updateRendererState(state, { + keepWidths: true, + selectedRecords: [], + }); + this._updateSelectionBox(); + this.renderer.focusCell(recordId, node); + resolve(!Object.keys(changes).length); + }) + .guardedCatch(discardAndReject); + }; + const discardAndReject = () => { + this.model.discardChanges(recordId); + this._confirmSave(recordId).then(() => { + this.renderer.focusCell(recordId, node); + reject(); + }); + }; + if (validRecordIds.length > 0) { + if (recordIds.length === 1) { + // Save without prompt + return saveRecords(); + } + const dialogOptions = { + confirm_callback: saveRecords, + cancel_callback: discardAndReject, + }; + const record = this.model.get(recordId); + const dialogChanges = { + isDomainSelected: this.isDomainSelected, + fieldLabel: node.attrs.string || record.fields[fieldName].string, + fieldName: node.attrs.name, + nbRecords: recordIds.length, + nbValidRecords: validRecordIds.length, + }; + new ListConfirmDialog(this, record, dialogChanges, dialogOptions) + .open({ shouldFocusButtons: true }); + } else { + Dialog.alert(this, _t("No valid record to save"), { + confirm_callback: discardAndReject, + }); + } + }); + }, + /** + * Overridden to deal with edition of multiple line. + * + * @override + * @param {string} recordId + */ + _saveRecord: function (recordId) { + var record = this.model.get(recordId, { raw: true }); + if (record.isDirty() && this.renderer.isInMultipleRecordEdition(recordId)) { + if (!this.multipleRecordsSavingPromise && this.lastFieldChangedEvent) { + this._onFieldChanged(this.lastFieldChangedEvent); + this.lastFieldChangedEvent = null; + } + // do not save the record (see _saveMultipleRecords) + const prom = this.multipleRecordsSavingPromise || Promise.reject(); + this.multipleRecordsSavingPromise = null; + return prom; + } + return this._super.apply(this, arguments); + }, + /** + * Allows to change the mode of a single row. + * + * @override + * @private + * @param {string} mode + * @param {string} [recordID] - default to main recordID + * @returns {Promise} + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) !== this.handle) { + this.mode = mode; + this.updateButtons(mode); + return this.renderer.setRowMode(recordID, mode); + } else { + return this._super.apply(this, arguments); + } + }, + /** + * @override + */ + _shouldBounceOnClick() { + const state = this.model.get(this.handle, {raw: true}); + return !state.count || state.isSample; + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the sidebar. + * + * @private + * @param {boolean} archive + * @returns {Promise} + */ + _toggleArchiveState: async function (archive) { + let resIds; + let displayNotif = false; + const state = this.model.get(this.handle, {raw: true}); + if (this.isDomainSelected) { + resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit); + displayNotif = (resIds.length === session.active_ids_limit); + } else { + resIds = this.model.localIdsToResIds(this.selectedRecords); + } + await this._archive(resIds, archive); + if (displayNotif) { + const msg = _.str.sprintf( + _t("Of the %d records selected, only the first %d have been archived/unarchived."), + state.count, resIds.length + ); + this.do_notify(_t('Warning'), msg); + } + }, + /** + * Hide the create button in non-empty grouped editable list views, as an + * 'Add an item' link is available in each group. + * + * @private + */ + _toggleCreateButton: function () { + if (this.hasButtons) { + var state = this.model.get(this.handle); + var createHidden = this.editable && state.groupedBy.length && state.data.length; + this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden); + } + }, + /** + * @override + * @returns {Promise} + */ + _update: async function () { + await this._super(...arguments); + this._toggleCreateButton(); + this.updateButtons('readonly'); + }, + /** + * When records are selected, a box is displayed in the control panel (next + * to the buttons). It indicates the number of selected records, and allows + * the user to select the whole domain instead of the current page (when the + * page is selected). This function renders and displays this box when at + * least one record is selected. + * Since header action buttons' display is dependent on the selection, we + * refresh them each time the selection is updated. + * + * @private + */ + _updateSelectionBox() { + if (this.$selectionBox) { + this.$selectionBox.remove(); + this.$selectionBox = null; + } + if (this.selectedRecords.length) { + const state = this.model.get(this.handle, {raw: true}); + this.$selectionBox = $(qweb.render('ListView.selection', { + isDomainSelected: this.isDomainSelected, + isPageSelected: this.isPageSelected, + nbSelected: this.selectedRecords.length, + nbTotal: state.count, + })); + this.$selectionBox.appendTo(this.$buttons); + } + this._renderHeaderButtons(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Triggered when navigating with TAB, when the end of the list has been + * reached. Go back to the first row in that case. + * + * @private + * @param {OdooEvent} ev + */ + _onActivateNextWidget: function (ev) { + ev.stopPropagation(); + this.renderer.editFirstRecord(ev); + }, + /** + * Add a record to the list + * + * @private + * @param {OdooEvent} ev + * @param {string} [ev.data.groupId=this.handle] the id of a dataPoint of + * type list to which the record must be added (default: main list) + */ + _onAddRecord: function (ev) { + ev.stopPropagation(); + var dataPointId = ev.data.groupId || this.handle; + if (this.activeActions.create) { + this._addRecord(dataPointId); + } else if (ev.data.onFail) { + ev.data.onFail(); + } + }, + /** + * Handles a click on a button by performing its action. + * + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + ev.stopPropagation(); + this._callButtonAction(ev.data.attrs, ev.data.record); + }, + /** + * When the user clicks on the 'create' button, two things can happen. We + * can switch to the form view with no active res_id, so it is in 'create' + * mode, or we can edit inline. + * + * @private + * @param {MouseEvent} ev + */ + _onCreateRecord: function (ev) { + // we prevent the event propagation because we don't want this event to + // trigger a click on the main bus, which would be then caught by the + // list editable renderer and would unselect the newly created row + if (ev) { + ev.stopPropagation(); + } + var state = this.model.get(this.handle, {raw: true}); + if (this.editable && !state.groupedBy.length) { + this._addRecord(this.handle); + } else { + this.trigger_up('switch_view', {view_type: 'form', res_id: undefined}); + } + }, + /** + * Called when the 'delete' action is clicked on in the side bar. + * + * @private + */ + _onDeleteSelectedRecords: async function () { + if (this.isDomainSelected) { + this._deleteRecordsInCurrentDomain(); + } else { + this._deleteRecords(this.selectedRecords); + } + }, + /** + * Handler called when the user clicked on the 'Discard' button. + * + * @param {Event} ev + */ + _onDiscard: function (ev) { + ev.stopPropagation(); // So that it is not considered as a row leaving + this._discardChanges().then(() => { + this.lastFieldChangedEvent = null; + }); + }, + /** + * Used to detect if the discard button is about to be clicked. + * Some focusout events might occur and trigger a save which + * is not always wanted when clicking "Discard". + * + * @param {MouseEvent} ev + * @private + */ + _onDiscardMousedown: function (ev) { + var self = this; + this.fieldChangedPrevented = true; + window.addEventListener('mouseup', function (mouseupEvent) { + var preventedEvent = self.fieldChangedPrevented; + self.fieldChangedPrevented = false; + // If the user starts clicking (mousedown) on the button and stops clicking + // (mouseup) outside of the button, we want to trigger the original onFieldChanged + // Event that was prevented in the meantime. + if (ev.target !== mouseupEvent.target && preventedEvent.constructor.name === 'OdooEvent') { + self._onFieldChanged(preventedEvent); + } + }, { capture: true, once: true }); + }, + /** + * Called when the user asks to edit a row -> Updates the controller buttons + * + * @param {OdooEvent} ev + */ + _onEditLine: function (ev) { + var self = this; + ev.stopPropagation(); + this.trigger_up('mutexify', { + action: function () { + self._setMode('edit', ev.data.recordId) + .then(ev.data.onSuccess); + }, + }); + }, + /** + * Opens the Export Dialog + * + * @private + */ + _onExportData: function () { + this._getExportDialogWidget().open(); + }, + /** + * Export Records in a xls file + * + * @private + */ + _onDirectExportData() { + // access rights check before exporting data + return this._rpc({ + model: 'ir.exports', + method: 'search_read', + args: [[], ['id']], + limit: 1, + }).then(() => this._getExportDialogWidget().export()) + }, + /** + * Opens the related form view. + * + * @private + * @param {OdooEvent} ev + */ + _onEditGroupClicked: function (ev) { + ev.stopPropagation(); + this.do_action({ + context: {create: false}, + type: 'ir.actions.act_window', + views: [[false, 'form']], + res_model: ev.data.record.model, + res_id: ev.data.record.res_id, + flags: {mode: 'edit'}, + }); + }, + /** + * Overridden to deal with the edition of multiple records. + * + * Note that we don't manage saving multiple records on saveLine + * because we don't want the onchanges to be applied. + * + * @private + * @override + */ + _onFieldChanged: function (ev) { + ev.stopPropagation(); + const recordId = ev.data.dataPointID; + this.lastFieldChangedEvent = ev; + + if (this.fieldChangedPrevented) { + this.fieldChangedPrevented = ev; + } else if (this.renderer.isInMultipleRecordEdition(recordId)) { + const saveMulti = () => { + // if ev.data.__originalComponent is set, it is the field Component + // that triggered the event, otherwise ev.target is the legacy field + // Widget that triggered the event + const target = ev.data.__originalComponent || ev.target; + this.multipleRecordsSavingPromise = + this._saveMultipleRecords(ev.data.dataPointID, target.__node, ev.data.changes); + }; + // deal with edition of multiple lines + ev.data.onSuccess = saveMulti; // will ask confirmation, and save + ev.data.onFailure = saveMulti; // will show the appropriate dialog + // disable onchanges as we'll save directly + ev.data.notifyChange = false; + // In multi edit mode, we will be asked if we want to write on the selected + // records, so the force_save for readonly is not necessary. + ev.data.force_save = false; + } + this._super.apply(this, arguments); + }, + /** + * @private + * @param {Object} node the button's node in the xml + * @returns {Promise} + */ + async _onHeaderButtonClicked(node) { + this._disableButtons(); + const state = this.model.get(this.handle); + try { + let resIds; + if (this.isDomainSelected) { + const limit = session.active_ids_limit; + resIds = await this._domainToResIds(state.getDomain(), limit); + } else { + resIds = this.getSelectedIds(); + } + // add the context of the button node (in the xml) and our custom one + // (active_ids and domain) to the action's execution context + const actionData = Object.assign({}, node.attrs, { + context: state.getContext({ additionalContext: node.attrs.context }), + }); + Object.assign(actionData.context, { + active_domain: state.getDomain(), + active_id: resIds[0], + active_ids: resIds, + active_model: state.model, + }); + // load the action with the correct context and record parameters (resIDs, model etc...) + const recordData = { + context: state.getContext(), + model: state.model, + resIDs: resIds, + }; + await this._executeButtonAction(actionData, recordData); + } finally { + this._enableButtons(); + } + }, + /** + * Called when the renderer displays an editable row and the user tries to + * leave it -> Saves the record associated to that line. + * + * @param {OdooEvent} ev + */ + _onSaveLine: function (ev) { + this.saveRecord(ev.data.recordID) + .then(ev.data.onSuccess) + .guardedCatch(ev.data.onFailure); + }, + /** + * @private + */ + _onSelectDomain: function (ev) { + ev.preventDefault(); + this.isDomainSelected = true; + this._updateSelectionBox(); + this._updateControlPanel(); + }, + /** + * When the current selection changes (by clicking on the checkboxes on the + * left), we need to display (or hide) the 'sidebar'. + * + * @private + * @param {OdooEvent} ev + */ + _onSelectionChanged: function (ev) { + this.selectedRecords = ev.data.selection; + this.isPageSelected = ev.data.allChecked; + this.isDomainSelected = false; + this.$('.o_list_export_xlsx').toggle(!this.selectedRecords.length); + this._updateSelectionBox(); + this._updateControlPanel(); + }, + /** + * If the record is set as dirty while in multiple record edition, + * we want to immediatly discard the change. + * + * @private + * @override + * @param {OdooEvent} ev + */ + _onSetDirty: function (ev) { + var recordId = ev.data.dataPointID; + if (this.renderer.isInMultipleRecordEdition(recordId)) { + ev.stopPropagation(); + Dialog.alert(this, _t("No valid record to save"), { + confirm_callback: async () => { + this.model.discardChanges(recordId); + await this._confirmSave(recordId); + this.renderer.focusCell(recordId, ev.target.__node); + }, + }); + } else { + this._super.apply(this, arguments); + } + }, + /** + * When the user clicks on one of the sortable column headers, we need to + * tell the model to sort itself properly, to update the pager and to + * rerender the view. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleColumnOrder: function (ev) { + ev.stopPropagation(); + var state = this.model.get(this.handle); + if (!state.groupedBy) { + this._updatePaging(state, { currentMinimum: 1 }); + } + var self = this; + this.model.setSort(state.id, ev.data.name).then(function () { + self.update({}); + }); + }, + /** + * In a grouped list view, each group can be clicked on to open/close them. + * This method just transfer the request to the model, then update the + * renderer. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleGroup: function (ev) { + ev.stopPropagation(); + var self = this; + this.model + .toggleGroup(ev.data.group.id) + .then(function () { + self.update({}, {keepSelection: true, reload: false}).then(function () { + if (ev.data.onSuccess) { + ev.data.onSuccess(); + } + }); + }); + }, +}); + +return ListController; + +}); diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js new file mode 100644 index 00000000..7afe0425 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -0,0 +1,1851 @@ +odoo.define('web.EditableListRenderer', function (require) { +"use strict"; + +/** + * Editable List renderer + * + * The list renderer is reasonably complex, so we split it in two files. This + * file simply 'includes' the basic ListRenderer to add all the necessary + * behaviors to enable editing records. + * + * Unlike Odoo v10 and before, this list renderer is independant from the form + * view. It uses the same widgets, but the code is totally stand alone. + */ +var core = require('web.core'); +var dom = require('web.dom'); +var ListRenderer = require('web.ListRenderer'); +var utils = require('web.utils'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); + +var _t = core._t; + +ListRenderer.include({ + RESIZE_DELAY: 200, + custom_events: _.extend({}, ListRenderer.prototype.custom_events, { + navigation_move: '_onNavigationMove', + }), + events: _.extend({}, ListRenderer.prototype.events, { + 'click .o_field_x2many_list_row_add a': '_onAddRecord', + 'click .o_group_field_row_add a': '_onAddRecordToGroup', + 'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord', + 'click tbody td.o_data_cell': '_onCellClick', + 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick', + 'click tfoot': '_onFooterClick', + 'click tr .o_list_record_remove': '_onRemoveIconClick', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.addCreateLine + * @param {boolean} params.addCreateLineInGroups + * @param {boolean} params.addTrashIcon + * @param {boolean} params.isMany2Many + * @param {boolean} params.isMultiEditable + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.editable = params.editable; + this.isMultiEditable = params.isMultiEditable; + this.columnWidths = false; + + // if addCreateLine (resp. addCreateLineInGroups) is true, the renderer + // will add a 'Add a line' link at the bottom of the list view (resp. + // at the bottom of each group) + this.addCreateLine = params.addCreateLine; + this.addCreateLineInGroups = params.addCreateLineInGroups; + + // Controls allow overriding "add a line" by custom controls. + + // Each <control> (only one is actually needed) is a container for (multiple) <create>. + // Each <create> will be a "add a line" button with custom text and context. + + // The following code will browse the arch to find + // all the <create> that are inside <control> + this.creates = []; + this.arch.children.forEach(child => { + if (child.tag !== 'control') { + return; + } + child.children.forEach(child => { + if (child.tag !== 'create' || child.attrs.invisible) { + return; + } + this.creates.push({ + context: child.attrs.context, + string: child.attrs.string, + }); + }); + }); + + // Add the default button if we didn't find any custom button. + if (this.creates.length === 0) { + this.creates.push({ + string: _t("Add a line"), + }); + } + + // if addTrashIcon is true, there will be a small trash icon at the end + // of each line, so the user can delete a record. + this.addTrashIcon = params.addTrashIcon; + + // replace the trash icon by X in case of many2many relations + // so that it means 'unlink' instead of 'remove' + this.isMany2Many = params.isMany2Many; + + this.currentRow = null; + this.currentFieldIndex = null; + this.isResizing = false; + this.eventListeners = []; + }, + /** + * @override + * @returns {Promise} + */ + start: function () { + core.bus.on('click', this, this._onWindowClicked.bind(this)); + core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY)); + core.bus.on('DOM_updated', this, () => this._freezeColumnWidths()); + return this._super(); + }, + /** + * Overriden to unbind all attached listeners + * + * @override + */ + destroy: function () { + this.eventListeners.forEach(listener => { + const { type, el, callback, options } = listener; + el.removeEventListener(type, callback, options); + }); + return this._super.apply(this, arguments); + }, + /** + * The list renderer needs to know if it is in the DOM, and to be notified + * when it is attached to the DOM to properly compute column widths. + * + * @override + */ + on_attach_callback: function () { + this.isInDOM = true; + this._super(); + // _freezeColumnWidths requests style information, which produces a + // repaint, so we call it after _super to prevent flickering (in case + // other code would also modify the DOM post rendering/before repaint) + this._freezeColumnWidths(); + }, + /** + * The list renderer needs to know if it is in the DOM to properly compute + * column widths. + * + * @override + */ + on_detach_callback: function () { + this.isInDOM = false; + this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * If the given recordID is the list main one (or that no recordID is + * given), then the whole view can be saved if one of the two following + * conditions is true: + * - There is no line in edition (all lines are saved so they are all valid) + * - The line in edition can be saved + * + * If the given recordID is a record in the list, toggle a className on its + * row's cells for invalid fields, so that we can style those cells + * differently. + * + * @override + * @param {string} [recordID] + * @returns {string[]} + */ + canBeSaved: function (recordID) { + if ((recordID || this.state.id) === this.state.id) { + recordID = this.getEditableRecordID(); + if (recordID === null) { + return []; + } + } + var fieldNames = this._super(recordID); + this.$('.o_selected_row .o_data_cell').removeClass('o_invalid_cell'); + this.$('.o_selected_row .o_data_cell:has(> .o_field_invalid)').addClass('o_invalid_cell'); + return fieldNames; + }, + /** + * We need to override the confirmChange method from BasicRenderer to + * reevaluate the row decorations. Since they depends on the current value + * of the row, they might have changed between each edit. + * + * @override + */ + confirmChange: function (state, recordID) { + var self = this; + return this._super.apply(this, arguments).then(function (widgets) { + if (widgets.length) { + var $row = self._getRow(recordID); + var record = self._getRecord(recordID); + self._setDecorationClasses($row, self.rowDecorations, record); + self._updateFooter(); + } + return widgets; + }); + }, + /** + * This is a specialized version of confirmChange, meant to be called when + * the change may have affected more than one line (so, for example, an + * onchange which add/remove a few lines in a x2many. This does not occur + * in a normal list view). + * + * The update is more difficult when other rows could have been changed. We + * need to potentially remove some lines, add some other lines, update some + * other lines and maybe reorder a few of them. This problem would neatly + * be solved by using a virtual dom, but we do not have this luxury yet. + * So, in the meantime, what we do is basically remove every current row + * except the 'main' one (the row which caused the update), then rerender + * every new row and add them before/after the main one. + * + * Note that this function assumes that the list isn't grouped, which is + * fine as it's never the case for x2many lists. + * + * @param {Object} state + * @param {string} id + * @param {string[]} fields + * @param {OdooEvent} ev + * @returns {Promise<AbstractField[]>} resolved with the list of widgets + * that have been reset + */ + confirmUpdate: function (state, id, fields, ev) { + var self = this; + + var oldData = this.state.data; + this._setState(state); + return this.confirmChange(state, id, fields, ev).then(function () { + // If no record with 'id' can be found in the state, the + // confirmChange method will have rerendered the whole view already, + // so no further work is necessary. + var record = self._getRecord(id); + if (!record) { + return; + } + + _.each(oldData, function (rec) { + if (rec.id !== id) { + self._destroyFieldWidgets(rec.id); + } + }); + + // re-render whole body (outside the dom) + self.defs = []; + var $newBody = self._renderBody(); + var defs = self.defs; + delete self.defs; + + return Promise.all(defs).then(function () { + // update registered modifiers to edit 'mode' because the call to + // _renderBody set baseModeByRecord as 'readonly' + _.each(self.columns, function (node) { + self._registerModifiers(node, record, null, {mode: 'edit'}); + }); + + // store the selection range to restore it once the table will + // be re-rendered, and the current cell re-selected + var currentRowID; + var currentWidget; + var focusedElement; + var selectionRange; + if (self.currentRow !== null) { + currentRowID = self._getRecordID(self.currentRow); + currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; + if (currentWidget) { + focusedElement = currentWidget.getFocusableElement().get(0); + if (currentWidget.formatType !== 'boolean' && focusedElement) { + selectionRange = dom.getSelectionRange(focusedElement); + } + } + } + + // remove all data rows except the one being edited, and insert + // data rows of the re-rendered body before and after it + var $editedRow = self._getRow(id); + $editedRow.nextAll('.o_data_row').remove(); + $editedRow.prevAll('.o_data_row').remove(); + var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]'); + $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertBefore($editedRow); + }); + $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertAfter($editedRow); + }); + + if (self.currentRow !== null) { + var newRowIndex = $editedRow.prop('rowIndex') - 1; + self.currentRow = newRowIndex; + return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true}) + .then(function () { + // restore the selection range + currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; + if (currentWidget) { + focusedElement = currentWidget.getFocusableElement().get(0); + if (selectionRange) { + dom.setSelectionRange(focusedElement, selectionRange); + } + } + }); + } + }); + }); + }, + /** + * Edit the first record in the list + */ + editFirstRecord: function (ev) { + const $borderRow = this._getBorderRow(ev.data.side || 'first'); + this._selectCell($borderRow.prop('rowIndex') - 1, ev.data.cellIndex || 0); + }, + /** + * Edit a given record in the list + * + * @param {string} recordID + */ + editRecord: function (recordID) { + var $row = this._getRow(recordID); + var rowIndex = $row.prop('rowIndex') - 1; + this._selectCell(rowIndex, 0); + }, + /** + * Gives focus to a specific cell, given its row and its related column. + * + * @param {string} recordId + * @param {Object} column + */ + focusCell: function (recordId, column) { + var $row = this._getRow(recordId); + var cellIndex = this.columns.indexOf(column); + $row.find('.o_data_cell')[cellIndex].focus(); + }, + /** + * Returns the recordID associated to the line which is currently in edition + * or null if there is no line in edition. + * + * @returns {string|null} + */ + getEditableRecordID: function () { + if (this.currentRow !== null) { + return this._getRecordID(this.currentRow); + } + return null; + }, + /** + * Returns whether the list is in multiple record edition from a given record. + * + * @private + * @param {string} recordId + * @returns {boolean} + */ + isInMultipleRecordEdition: function (recordId) { + return this.isEditable() && this.isMultiEditable && this.selection.includes(recordId); + }, + /** + * Returns whether the list can be edited. + * It's true when: + * - the list `editable` property is set, + * - or at least one record is selected (becomes partially editable) + * + * @returns {boolean} + */ + isEditable: function () { + return this.editable || (this.isMultiEditable && this.selection.length); + }, + /** + * Removes the line associated to the given recordID (the index of the row + * is found thanks to the old state), then updates the state. + * + * @param {Object} state + * @param {string} recordID + */ + removeLine: function (state, recordID) { + this._setState(state); + var $row = this._getRow(recordID); + if ($row.length === 0) { + return; + } + if ($row.prop('rowIndex') - 1 === this.currentRow) { + this.currentRow = null; + this._enableRecordSelectors(); + } + + // destroy widgets first + this._destroyFieldWidgets(recordID); + // remove the row + if (this.state.count >= 4) { + $row.remove(); + } else { + // we want to always keep at least 4 (possibly empty) rows + var $emptyRow = this._renderEmptyRow(); + $row.replaceWith($emptyRow); + // move the empty row we just inserted after last data row + const $lastDataRow = this.$('.o_data_row:last'); + if ($lastDataRow.length) { + $emptyRow.insertAfter($lastDataRow); + } + } + }, + /** + * Updates the already rendered row associated to the given recordID so that + * it fits the given mode. + * + * @param {string} recordID + * @param {string} mode + * @returns {Promise} + */ + setRowMode: function (recordID, mode) { + var self = this; + var record = self._getRecord(recordID); + if (!record) { + return Promise.resolve(); + } + + var editMode = (mode === 'edit'); + var $row = this._getRow(recordID); + this.currentRow = editMode ? $row.prop('rowIndex') - 1 : null; + var $tds = $row.children('.o_data_cell'); + var oldWidgets = _.clone(this.allFieldWidgets[record.id]); + + // Prepare options for cell rendering (this depends on the mode) + var options = { + renderInvisible: editMode, + renderWidgets: editMode, + }; + options.mode = editMode ? 'edit' : 'readonly'; + + // Switch each cell to the new mode; note: the '_renderBodyCell' + // function might fill the 'this.defs' variables with multiple promise + // so we create the array and delete it after the rendering. + var defs = []; + this.defs = defs; + _.each(this.columns, function (node, colIndex) { + var $td = $tds.eq(colIndex); + var $newTd = self._renderBodyCell(record, node, colIndex, options); + + // Widgets are unregistered of modifiers data when they are + // destroyed. This is not the case for simple buttons so we have to + // do it here. + if ($td.hasClass('o_list_button')) { + self._unregisterModifiersElement(node, recordID, $td.children()); + } + + // For edit mode we only replace the content of the cell with its + // new content (invisible fields, editable fields, ...). + // For readonly mode, we replace the whole cell so that the + // dimensions of the cell are not forced anymore. + if (editMode) { + $td.empty().append($newTd.contents()); + } else { + self._unregisterModifiersElement(node, recordID, $td); + $td.replaceWith($newTd); + } + }); + delete this.defs; + + // Destroy old field widgets + _.each(oldWidgets, this._destroyFieldWidget.bind(this, recordID)); + + // Toggle selected class here so that style is applied at the end + $row.toggleClass('o_selected_row', editMode); + if (editMode) { + this._disableRecordSelectors(); + } else { + this._enableRecordSelectors(); + } + + return Promise.all(defs).then(function () { + // mark Owl sub components as mounted + WidgetAdapterMixin.on_attach_callback.call(self); + + // necessary to trigger resize on fieldtexts + core.bus.trigger('DOM_updated'); + }); + }, + /** + * This method is called whenever we click/move outside of a row that was + * in edit mode. This is the moment we save all accumulated changes on that + * row, if needed (@see BasicController.saveRecord). + * + * Note that we have to disable the focusable elements (inputs, ...) to + * prevent subsequent editions. These edits would be lost, because the list + * view only saves records when unselecting a row. + * + * @returns {Promise} The promise resolves if the row was unselected (and + * possibly removed). If may be rejected, when the row is dirty and the + * user refuses to discard its changes. + */ + unselectRow: function () { + // Protect against calling this method when no row is selected + if (this.currentRow === null) { + return Promise.resolve(); + } + var recordID = this._getRecordID(this.currentRow); + var recordWidgets = this.allFieldWidgets[recordID]; + function toggleWidgets(disabled) { + _.each(recordWidgets, function (widget) { + var $el = widget.getFocusableElement(); + $el.prop('disabled', disabled); + }); + } + + toggleWidgets(true); + return new Promise((resolve, reject) => { + this.trigger_up('save_line', { + recordID: recordID, + onSuccess: resolve, + onFailure: reject, + }); + }).then(selectNextRow => { + this._enableRecordSelectors(); + // If any field has changed and if the list is in multiple edition, + // we send a truthy boolean to _selectRow to tell it not to select + // the following record. + return selectNextRow; + }).guardedCatch(() => { + toggleWidgets(false); + }); + }, + /** + * @override + */ + updateState: function (state, params) { + // There are some cases where a record is added to an invisible list + // e.g. set a quotation template with optionnal products + if (params.keepWidths && this.$el.is(':visible')) { + this._storeColumnWidths(); + } + if (params.noRender) { + // the state changed, but we won't do a re-rendering right now, so + // remove computed modifiers data (as they are obsolete) to force + // them to be recomputed at next (sub-)rendering + this.allModifiersData = []; + } + if ('addTrashIcon' in params) { + if (this.addTrashIcon !== params.addTrashIcon) { + this.columnWidths = false; // columns changed, so forget stored widths + } + this.addTrashIcon = params.addTrashIcon; + } + if ('addCreateLine' in params) { + this.addCreateLine = params.addCreateLine; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Used to bind event listeners so that they can be unbound when the list + * is destroyed. + * There is no reverse method (list._removeEventListener) because there is + * no issue with removing an non-existing listener. + * + * @private + * @param {string} type event name + * @param {EventTarget} el event target + * @param {Function} callback callback function to attach + * @param {Object} options event listener options + */ + _addEventListener: function (type, el, callback, options) { + el.addEventListener(type, callback, options); + this.eventListeners.push({ type, el, callback, options }); + }, + /** + * Handles the assignation of default widths for each column header. + * If the list is empty, an arbitrary absolute or relative width will be + * given to the header + * + * @see _getColumnWidth for detailed information about which width is + * given to a certain field type. + * + * @private + */ + _computeDefaultWidths: function () { + const isListEmpty = !this._hasVisibleRecords(this.state); + const relativeWidths = []; + this.columns.forEach(column => { + const th = this._getColumnHeader(column); + if (th.offsetParent === null) { + relativeWidths.push(false); + } else { + const width = this._getColumnWidth(column); + if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px) + if (isListEmpty) { + th.style.width = width; + } else { + // If there are records, we force a min-width for fields with an absolute + // width to ensure a correct rendering in edition + th.style.minWidth = width; + } + relativeWidths.push(false); + } else { // relative width expressed as a weight (e.g. 1.5) + relativeWidths.push(parseFloat(width, 10)); + } + } + }); + + // Assignation of relative widths + if (isListEmpty) { + const totalWidth = this._getColumnsTotalWidth(relativeWidths); + for (let i in this.columns) { + if (relativeWidths[i]) { + const th = this._getColumnHeader(this.columns[i]); + th.style.width = (relativeWidths[i] / totalWidth * 100) + '%'; + } + } + // Manualy assigns trash icon header width since it's not in the columns + const trashHeader = this.el.getElementsByClassName('o_list_record_remove_header')[0]; + if (trashHeader) { + trashHeader.style.width = '32px'; + } + } + }, + /** + * Destroy all field widgets corresponding to a record. Useful when we are + * removing a useless row. + * + * @param {string} recordID + */ + _destroyFieldWidgets: function (recordID) { + if (recordID in this.allFieldWidgets) { + var widgetsToDestroy = this.allFieldWidgets[recordID].slice(); + _.each(widgetsToDestroy, this._destroyFieldWidget.bind(this, recordID)); + delete this.allFieldWidgets[recordID]; + } + }, + /** + * When editing a row, we want to disable all record selectors. + * + * @private + */ + _disableRecordSelectors: function () { + this.$('.o_list_record_selector input').attr('disabled', 'disabled'); + }, + /** + * @private + */ + _enableRecordSelectors: function () { + this.$('.o_list_record_selector input').attr('disabled', false); + }, + /** + * This function freezes the column widths and forces a fixed table-layout, + * once the browser has computed the optimal width of each column according + * to the displayed records. We want to freeze widths s.t. it doesn't + * flicker when we switch a row in edition. + * + * We skip this when there is no record as we don't want to fix widths + * according to column's labels. In this case, we fallback on the 'weight' + * heuristic, which assigns to each column a fixed or relative width + * depending on the widget or field type. + * + * Note that the list must be in the DOM when this function is called. + * + * @private + */ + _freezeColumnWidths: function () { + if (!this.columnWidths && this.el.offsetParent === null) { + // there is no record nor widths to restore or the list is not visible + // -> don't force column's widths w.r.t. their label + return; + } + const thElements = [...this.el.querySelectorAll('table thead th')]; + if (!thElements.length) { + return; + } + const table = this.el.getElementsByClassName('o_list_table')[0]; + let columnWidths = this.columnWidths; + + if (!columnWidths || !columnWidths.length) { // no column widths to restore + // Set table layout auto and remove inline style to make sure that css + // rules apply (e.g. fixed width of record selector) + table.style.tableLayout = 'auto'; + thElements.forEach(th => { + th.style.width = null; + th.style.maxWidth = null; + }); + + // Resets the default widths computation now that the table is visible. + this._computeDefaultWidths(); + + // Squeeze the table by applying a max-width on largest columns to + // ensure that it doesn't overflow + columnWidths = this._squeezeTable(); + } + + thElements.forEach((th, index) => { + // Width already set by default relative width computation + if (!th.style.width) { + th.style.width = `${columnWidths[index]}px`; + } + }); + + // Set the table layout to fixed + table.style.tableLayout = 'fixed'; + }, + /** + * Returns the first or last editable row of the list + * + * @private + * @returns {integer} + */ + _getBorderRow: function (side) { + let $borderDataRow = this.$(`.o_data_row:${side}`); + if (!this._isRecordEditable($borderDataRow.data('id'))) { + $borderDataRow = this._getNearestEditableRow($borderDataRow, side === 'first'); + } + return $borderDataRow; + }, + /** + * Compute the sum of the weights for each column, given an array containing + * all relative widths. param `$thead` is useful for studio, in order to + * show column hooks. + * + * @private + * @param {jQuery} $thead + * @param {number[]} relativeWidths + * @return {integer} + */ + _getColumnsTotalWidth(relativeWidths) { + return relativeWidths.reduce((acc, width) => acc + width, 0); + }, + /** + * Returns the width of a column according the 'width' attribute set in the + * arch, the widget or the field type. A fixed width is harcoded for some + * field types (e.g. date and numeric fields). By default, the remaining + * space is evenly distributed between the other fields (with a factor '1'). + * + * This is only used when there is no record in the list (i.e. when we can't + * let the browser compute the optimal width of each column). + * + * @see _renderHeader + * @private + * @param {Object} column an arch node + * @returns {string} either a weight factor (e.g. '1.5') or a css width + * description (e.g. '120px') + */ + _getColumnWidth: function (column) { + if (column.attrs.width) { + return column.attrs.width; + } + const fieldsInfo = this.state.fieldsInfo.list; + const name = column.attrs.name; + if (!fieldsInfo[name]) { + // Unnamed columns get default value + return '1'; + } + const widget = fieldsInfo[name].Widget.prototype; + if ('widthInList' in widget) { + return widget.widthInList; + } + const field = this.state.fields[name]; + if (!field) { + // this is not a field. Probably a button or something of unknown + // width. + return '1'; + } + const fixedWidths = { + boolean: '70px', + date: '92px', + datetime: '146px', + float: '92px', + integer: '74px', + monetary: '104px', + }; + let type = field.type; + if (fieldsInfo[name].widget in fixedWidths) { + type = fieldsInfo[name].widget; + } + return fixedWidths[type] || '1'; + }, + /** + * Gets the th element corresponding to a given column. + * + * @private + * @param {Object} column + * @returns {HTMLElement} + */ + _getColumnHeader: function (column) { + const { icon, name, string } = column.attrs; + if (name) { + return this.el.querySelector(`thead th[data-name="${name}"]`); + } else if (string) { + return this.el.querySelector(`thead th[data-string="${string}"]`); + } else if (icon) { + return this.el.querySelector(`thead th[data-icon="${icon}"]`); + } + }, + /** + * Returns the nearest editable row starting from a given table row. + * If the list is grouped, jumps to the next unfolded group + * + * @private + * @param {jQuery} $row starting point + * @param {boolean} next whether the requested row should be the next or the previous one + * @return {jQuery|null} + */ + _getNearestEditableRow: function ($row, next) { + const direction = next ? 'next' : 'prev'; + let $nearestRow; + if (this.editable) { + $nearestRow = $row[direction](); + if (!$nearestRow.hasClass('o_data_row')) { + var $nextBody = $row.closest('tbody')[direction](); + while ($nextBody.length && !$nextBody.find('.o_data_row').length) { + $nextBody = $nextBody[direction](); + } + $nearestRow = $nextBody.find(`.o_data_row:${next ? 'first' : 'last'}`); + } + } else { + // In readonly lists, look directly into selected records + const recordId = $row.data('id'); + const rowSelectionIndex = this.selection.indexOf(recordId); + let nextRowIndex; + if (rowSelectionIndex < 0) { + nextRowIndex = next ? 0 : this.selection.length - 1; + } else { + nextRowIndex = rowSelectionIndex + (next ? 1 : -1); + } + // Index might be out of range, will then return an empty jQuery object + $nearestRow = this._getRow(this.selection[nextRowIndex]); + } + return $nearestRow; + }, + /** + * Returns the current number of columns. The editable renderer may add a + * trash icon on the right of a record, so we need to take this into account + * + * @override + * @returns {number} + */ + _getNumberOfCols: function () { + var n = this._super(); + if (this.addTrashIcon) { + n++; + } + return n; + }, + /** + * Traverse this.state to find and return the record with given dataPoint id + * (for grouped list views, the record could be deep down in state tree). + * + * @override + * @private + */ + _getRecord: function (recordId) { + var record; + utils.traverse_records(this.state, function (r) { + if (r.id === recordId) { + record = r; + } + }); + return record; + }, + /** + * Retrieve the record dataPoint id from a rowIndex as the row DOM element + * stores the record id in data. + * + * @private + * @param {integer} rowIndex + * @returns {string} record dataPoint id + */ + _getRecordID: function (rowIndex) { + var $tr = this.$('table.o_list_table > tbody tr').eq(rowIndex); + return $tr.data('id'); + }, + /** + * Return the jQuery tr element corresponding to the given record dataPoint + * id. + * + * @private + * @param {string} [recordId] + * @returns {jQueryElement} + */ + _getRow: function (recordId) { + return this.$('.o_data_row[data-id="' + recordId + '"]'); + }, + /** + * This function returns true iff records are visible in the list, i.e. + * if the list is ungrouped: true iff the list isn't empty; + * if the list is grouped: true iff there is at least one unfolded group + * containing records. + * + * @param {Object} list a datapoint + * @returns {boolean} + */ + _hasVisibleRecords: function (list) { + if (!list.groupedBy.length) { + return !!list.data.length; + } else { + var hasVisibleRecords = false; + for (var i = 0; i < list.data.length; i++) { + hasVisibleRecords = hasVisibleRecords || this._hasVisibleRecords(list.data[i]); + } + return hasVisibleRecords; + } + }, + /** + * Returns whether a recordID is currently editable. + * + * @param {string} recordID + * @returns {boolean} + */ + _isRecordEditable: function (recordID) { + return this.editable || (this.isMultiEditable && this.selection.includes(recordID)); + }, + /** + * Moves to the next row in the list + * + * @private + * @params {Object} [options] see @_moveToSideLine + */ + _moveToNextLine: function (options) { + this._moveToSideLine(true, options); + }, + /** + * Moves to the previous row in the list + * + * @private + * @params {Object} [options] see @_moveToSideLine + */ + _moveToPreviousLine: function (options) { + this._moveToSideLine(false, options); + }, + /** + * Moves the focus to the nearest editable row before or after the current one. + * If we arrive at the end of the list (or of a group in the grouped case) and the list + * is editable="bottom", we create a new record, otherwise, we move the + * cursor to the first row (of the next group in the grouped case). + * + * @private + * @param {number} next whether to move to the next or previous row + * @param {Object} [options] + * @param {boolean} [options.forceCreate=false] typically set to true when + * navigating with ENTER ; in this case, if the next row is the 'Add a + * row' one, always create a new record (never skip it, like TAB does + * under some conditions) + */ + _moveToSideLine: function (next, options) { + options = options || {}; + const recordID = this._getRecordID(this.currentRow); + this.commitChanges(recordID).then(() => { + const record = this._getRecord(recordID); + const multiEdit = this.isInMultipleRecordEdition(recordID); + if (!multiEdit) { + const fieldNames = this.canBeSaved(recordID); + if (fieldNames.length && (record.isDirty() || options.forceCreate)) { + // the current row is invalid, we only leave it if it is not dirty + // (we didn't make any change on this row, which is a new one) and + // we are navigating with TAB (forceCreate=false) + return; + } + } + // compute the index of the next (record) row to select, if any + const side = next ? 'first' : 'last'; + const borderRowIndex = this._getBorderRow(side).prop('rowIndex') - 1; + const cellIndex = next ? 0 : this.allFieldWidgets[recordID].length - 1; + const cellOptions = { inc: next ? 1 : -1, force: true }; + const $currentRow = this._getRow(recordID); + const $nextRow = this._getNearestEditableRow($currentRow, next); + let nextRowIndex = null; + let groupId; + + if (!this.isGrouped) { + // ungrouped case + if ($nextRow.length) { + nextRowIndex = $nextRow.prop('rowIndex') - 1; + } else if (!this.editable) { + nextRowIndex = borderRowIndex; + } else if (!options.forceCreate && !record.isDirty()) { + this.trigger_up('discard_changes', { + recordID: recordID, + onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }), + }); + return; + } + } else { + // grouped case + var $directNextRow = $currentRow.next(); + if (next && this.editable === "bottom" && $directNextRow.hasClass('o_add_record_row')) { + // the next row is the 'Add a line' row (i.e. the current one is the last record + // row of the group) + if (options.forceCreate || record.isDirty()) { + // if we modified the current record, add a row to create a new record + groupId = $directNextRow.data('group-id'); + } else { + // if we didn't change anything to the current line (e.g. we pressed TAB on + // each cell without modifying/entering any data), we discard that line (if + // it was a new one) and move to the first record of the next group + nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null; + this.trigger_up('discard_changes', { + recordID: recordID, + onSuccess: () => { + if (nextRowIndex !== null) { + if (!record.res_id) { + // the current record was a new one, so we decrement + // nextRowIndex as that row has been removed meanwhile + nextRowIndex--; + } + this._selectCell(nextRowIndex, cellIndex, cellOptions); + } else { + // we were in the last group, so go back to the top + this._selectCell(borderRowIndex, cellIndex, cellOptions); + } + }, + }); + return; + } + } else { + // there is no 'Add a line' row (i.e. the create feature is disabled), or the + // list is editable="top", we focus the first record of the next group if any, + // or we go back to the top of the list + nextRowIndex = $nextRow.length ? + ($nextRow.prop('rowIndex') - 1) : + borderRowIndex; + } + } + + // if there is a (record) row to select, select it, otherwise, add a new record (in the + // correct group, if the view is grouped) + if (nextRowIndex !== null) { + // cellOptions.force = true; + this._selectCell(nextRowIndex, cellIndex, cellOptions); + } else if (this.editable) { + // if for some reason (e.g. create feature is disabled) we can't add a new + // record, select the first record row + this.unselectRow().then(this.trigger_up.bind(this, 'add_record', { + groupId: groupId, + onFail: this._selectCell.bind(this, borderRowIndex, cellIndex, cellOptions), + })); + } + }); + }, + /** + * Override to compute the (relative or absolute) width of each column. + * + * @override + * @private + */ + _processColumns: function () { + const oldColumns = this.columns; + this._super.apply(this, arguments); + // check if stored widths still apply + if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) { + for (let i = 0; i < oldColumns.length; i++) { + if (oldColumns[i] !== this.columns[i]) { + this.columnWidths = false; // columns changed, so forget stored widths + break; + } + } + } else { + this.columnWidths = false; // columns changed, so forget stored widths + } + }, + /** + * @override + * @returns {Promise} + */ + _render: function () { + this.currentRow = null; + this.currentFieldIndex = null; + return this._super.apply(this, arguments); + }, + /** + * Override to add the 'Add an item' link to the end of last-level opened + * groups. + * + * @override + * @private + */ + _renderGroup: function (group) { + var result = this._super.apply(this, arguments); + if (!group.groupedBy.length && this.addCreateLineInGroups) { + var $groupBody = result[0]; + var $a = $('<a href="#" role="button">') + .text(_t("Add a line")) + .attr('data-group-id', group.id); + var $td = $('<td>') + .attr('colspan', this._getNumberOfCols()) + .addClass('o_group_field_row_add') + .attr('tabindex', -1) + .append($a); + var $tr = $('<tr>', {class: 'o_add_record_row'}) + .attr('data-group-id', group.id) + .append($td); + $groupBody.append($tr.prepend($('<td>').html(' '))); + } + return result; + }, + /** + * The renderer needs to support reordering lines. This is only active in + * edit mode. The handleField attribute is set when there is a sequence + * widget. + * + * @override + */ + _renderBody: function () { + var self = this; + var $body = this._super.apply(this, arguments); + if (this.hasHandle) { + $body.sortable({ + axis: 'y', + items: '> tr.o_data_row', + helper: 'clone', + handle: '.o_row_handle', + stop: function (event, ui) { + // update currentID taking moved line into account + if (self.currentRow !== null) { + var currentID = self.state.data[self.currentRow].id; + self.currentRow = self._getRow(currentID).index(); + } + self.unselectRow().then(function () { + self._moveRecord(ui.item.data('id'), ui.item.index()); + }); + }, + }); + } + return $body; + }, + /** + * @override + * @private + */ + _renderFooter: function () { + const $footer = this._super.apply(this, arguments); + if (this.addTrashIcon) { + $footer.find('tr').append($('<td>')); + } + return $footer; + }, + /** + * Override to optionally add a th in the header for the remove icon column. + * + * @override + * @private + */ + _renderHeader: function () { + var $thead = this._super.apply(this, arguments); + if (this.addTrashIcon) { + $thead.find('tr').append($('<th>', {class: 'o_list_record_remove_header'})); + } + return $thead; + }, + /** + * Overriden to add a resize handle in editable list column headers. + * Only applies to headers containing text. + * + * @override + * @private + */ + _renderHeaderCell: function () { + const $th = this._super.apply(this, arguments); + if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) { + const resizeHandle = document.createElement('span'); + resizeHandle.classList = 'o_resize'; + resizeHandle.onclick = this._onClickResize.bind(this); + resizeHandle.onmousedown = this._onStartResize.bind(this); + $th.append(resizeHandle); + } + return $th; + }, + /** + * Editable rows are possibly extended with a trash icon on their right, to + * allow deleting the corresponding record. + * For many2many editable lists, the trash bin is replaced by X. + * + * @override + * @param {any} record + * @param {any} index + * @returns {jQueryElement} + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + if (this.addTrashIcon) { + var $icon = this.isMany2Many ? + $('<button>', {'class': 'fa fa-times', 'name': 'unlink', 'aria-label': _t('Unlink row ') + (index + 1)}) : + $('<button>', {'class': 'fa fa-trash-o', 'name': 'delete', 'aria-label': _t('Delete row ') + (index + 1)}); + var $td = $('<td>', {class: 'o_list_record_remove'}).append($icon); + $row.append($td); + } + return $row; + }, + /** + * If the editable list view has the parameter addCreateLine, we need to + * add a last row with the necessary control. + * + * If the list has a handleField, we want to left-align the first button + * on the first real column. + * + * @override + * @returns {jQueryElement[]} + */ + _renderRows: function () { + var $rows = this._super(); + if (this.addCreateLine) { + var $tr = $('<tr>'); + var colspan = this._getNumberOfCols(); + + if (this.handleField) { + colspan = colspan - 1; + $tr.append('<td>'); + } + + var $td = $('<td>') + .attr('colspan', colspan) + .addClass('o_field_x2many_list_row_add'); + $tr.append($td); + $rows.push($tr); + + if (this.addCreateLine) { + _.each(this.creates, function (create, index) { + var $a = $('<a href="#" role="button">') + .attr('data-context', create.context) + .text(create.string); + if (index > 0) { + $a.addClass('ml16'); + } + $td.append($a); + }); + } + } + return $rows; + }, + /** + * @override + * @private + * @returns {Promise} this promise is resolved immediately + */ + _renderView: function () { + this.currentRow = null; + return this._super.apply(this, arguments).then(() => { + const table = this.el.getElementsByClassName('o_list_table')[0]; + if (table) { + table.classList.toggle('o_empty_list', !this._hasVisibleRecords(this.state)); + this._freezeColumnWidths(); + } + }); + }, + /** + * This is one of the trickiest method in the editable renderer. It has to + * do a lot of stuff: it has to determine which cell should be selected (if + * the target cell is readonly, we need to find another suitable cell), then + * unselect the current row, and activate the line where the selected cell + * is, if necessary. + * + * @param {integer} rowIndex + * @param {integer} fieldIndex + * @param {Object} [options] + * @param {Event} [options.event] original target of the event which + * @param {boolean} [options.wrap=true] if true and no widget could be + * triggered the cell selection + * selected from the fieldIndex to the last column, then we wrap around and + * try to select a widget starting from the beginning + * @param {boolean} [options.force=false] if true, force selecting the cell + * even if seems to be already the selected one (useful after a re- + * rendering, to reset the focus on the correct field) + * @param {integer} [options.inc=1] the increment to use when searching for + * the "next" possible cell (if the cell to select can't be selected) + * @return {Promise} fails if no cell could be selected + */ + _selectCell: function (rowIndex, fieldIndex, options) { + options = options || {}; + // Do nothing if the user tries to select current cell + if (!options.force && rowIndex === this.currentRow && fieldIndex === this.currentFieldIndex) { + return Promise.resolve(); + } + var wrap = options.wrap === undefined ? true : options.wrap; + var recordID = this._getRecordID(rowIndex); + + // Select the row then activate the widget in the correct cell + var self = this; + return this._selectRow(rowIndex).then(function () { + var record = self._getRecord(recordID); + if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) { + return Promise.reject(); + } + // _activateFieldWidget might trigger an onchange, + // which requires currentFieldIndex to be set + // so that the cursor can be restored + var oldFieldIndex = self.currentFieldIndex; + self.currentFieldIndex = fieldIndex; + fieldIndex = self._activateFieldWidget(record, fieldIndex, { + inc: options.inc || 1, + wrap: wrap, + event: options && options.event, + }); + if (fieldIndex < 0) { + self.currentFieldIndex = oldFieldIndex; + return Promise.reject(); + } + self.currentFieldIndex = fieldIndex; + }); + }, + /** + * Activates the row at the given row index. + * + * @param {integer} rowIndex + * @returns {Promise} + */ + _selectRow: function (rowIndex) { + // Do nothing if already selected + if (rowIndex === this.currentRow) { + return Promise.resolve(); + } + if (!this.columnWidths) { + // we don't want the column widths to change when selecting rows + this._storeColumnWidths(); + } + var recordId = this._getRecordID(rowIndex); + // To select a row, the currently selected one must be unselected first + var self = this; + return this.unselectRow().then((selectNextRow = true) => { + if (!selectNextRow) { + return Promise.resolve(); + } + if (!recordId) { + // The row to selected doesn't exist anymore (probably because + // an onchange triggered when unselecting the previous one + // removes rows) + return Promise.reject(); + } + // Notify the controller we want to make a record editable + return new Promise(function (resolve) { + self.trigger_up('edit_line', { + recordId: recordId, + onSuccess: function () { + self._disableRecordSelectors(); + resolve(); + }, + }); + }); + }); + }, + /** + * Set a maximum width on the largest columns in the list in case the table + * is overflowing. The idea is to shrink largest columns first, but to + * ensure that they are still the largest at the end (maybe in equal measure + * with other columns). Button columns aren't impacted by this function, as + * we assume that they can't be squeezed (we want all buttons to always be + * available, not being replaced by ellipsis). + * + * @private + * @returns {integer[]} width (in px) of each column s.t. the table doesn't + * overflow + */ + _squeezeTable: function () { + const table = this.el.getElementsByClassName('o_list_table')[0]; + + // Toggle a className used to remove style that could interfer with the ideal width + // computation algorithm (e.g. prevent text fields from being wrapped during the + // computation, to prevent them from being completely crushed) + table.classList.add('o_list_computing_widths'); + + const thead = table.getElementsByTagName('thead')[0]; + const thElements = [...thead.getElementsByTagName('th')]; + const columnWidths = thElements.map(th => th.offsetWidth); + const getWidth = th => columnWidths[thElements.indexOf(th)] || 0; + const getTotalWidth = () => thElements.reduce((tot, th, i) => tot + columnWidths[i], 0); + const shrinkColumns = (columns, width) => { + let thresholdReached = false; + columns.forEach(th => { + const index = thElements.indexOf(th); + let maxWidth = columnWidths[index] - Math.ceil(width / columns.length); + if (maxWidth < 92) { // prevent the columns from shrinking under 92px (~ date field) + maxWidth = 92; + thresholdReached = true; + } + th.style.maxWidth = `${maxWidth}px`; + columnWidths[index] = maxWidth; + }); + return thresholdReached; + }; + // Sort columns, largest first + const sortedThs = [...thead.querySelectorAll('th:not(.o_list_button)')] + .sort((a, b) => getWidth(b) - getWidth(a)); + const allowedWidth = table.parentNode.offsetWidth; + + let totalWidth = getTotalWidth(); + let stop = false; + let index = 0; + while (totalWidth > allowedWidth && !stop) { + // Find the largest columns + index++; + const largests = sortedThs.slice(0, index); + while (getWidth(largests[0]) === getWidth(sortedThs[index])) { + largests.push(sortedThs[index]); + index++; + } + + // Compute the number of px to remove from the largest columns + const nextLargest = sortedThs[index]; // largest column when omitting those in largests + const totalToRemove = totalWidth - allowedWidth; + const canRemove = (getWidth(largests[0]) - getWidth(nextLargest)) * largests.length; + + // Shrink the largests columns + stop = shrinkColumns(largests, Math.min(totalToRemove, canRemove)); + + totalWidth = getTotalWidth(); + } + + // We are no longer computing widths, so restore the normal style + table.classList.remove('o_list_computing_widths'); + + return columnWidths; + }, + /** + * @private + */ + _storeColumnWidths: function () { + this.columnWidths = this.$('thead th').toArray().map(function (th) { + return $(th).outerWidth(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method is called when we click on the 'Add a line' button in a groupby + * list view. + * + * @param {MouseEvent} ev + */ + _onAddRecordToGroup: function (ev) { + ev.preventDefault(); + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + var self = this; + // This method can be called when selecting the parent of the link. + // We need to ensure that the link is the actual target + const target = ev.target.tagName !== 'A' ? ev.target.getElementsByTagName('A')[0] : ev.target; + const groupId = target.dataset.groupId; + this.currentGroupId = groupId; + this.unselectRow().then(function () { + self.trigger_up('add_record', { + groupId: groupId, + }); + }); + }, + /** + * This method is called when we click on the 'Add a line' button in a sub + * list such as a one2many in a form view. + * + * @private + * @param {MouseEvent} ev + */ + _onAddRecord: function (ev) { + // we don't want the browser to navigate to a the # url + ev.preventDefault(); + + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + // but we do want to unselect current row + var self = this; + this.unselectRow().then(function () { + self.trigger_up('add_record', {context: ev.currentTarget.dataset.context && [ev.currentTarget.dataset.context]}); // TODO write a test, the promise was not considered + }); + }, + /** + * When the user clicks on a cell, we simply select it. + * + * @private + * @param {MouseEvent} event + */ + _onCellClick: function (event) { + // The special_click property explicitely allow events to bubble all + // the way up to bootstrap's level rather than being stopped earlier. + var $td = $(event.currentTarget); + var $tr = $td.parent(); + var rowIndex = $tr.prop('rowIndex') - 1; + if (!this._isRecordEditable($tr.data('id')) || $(event.target).prop('special_click')) { + return; + } + var fieldIndex = Math.max($tr.find('.o_field_cell').index($td), 0); + this._selectCell(rowIndex, fieldIndex, {event: event}); + }, + /** + * We want to override any default mouse behaviour when clicking on the resize handles + * + * @private + * @param {MouseEvent} ev + */ + _onClickResize: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + }, + /** + * We need to manually unselect row, because no one else would do it + */ + _onEmptyRowClick: function () { + this.unselectRow(); + }, + /** + * Clicking on a footer should unselect (and save) the currently selected + * row. It has to be done this way, because this is a click inside this.el, + * and _onWindowClicked ignore those clicks. + */ + _onFooterClick: function () { + this.unselectRow(); + }, + /** + * Manages the keyboard events on the list. If the list is not editable, when the user navigates to + * a cell using the keyboard, if he presses enter, enter the model represented by the line + * + * @private + * @param {KeyboardEvent} ev + * @override + */ + _onKeyDown: function (ev) { + const $target = $(ev.currentTarget); + const $tr = $target.closest('tr'); + const recordEditable = this._isRecordEditable($tr.data('id')); + + if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) { + // enter on a textarea for example, let it bubble + return; + } + + if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && + !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) { + ev.stopPropagation(); + ev.preventDefault(); + if ($target.closest('td').hasClass('o_group_field_row_add')) { + this._onAddRecordToGroup(ev); + } else { + this._onCellClick(ev); + } + } else { + this._super.apply(this, arguments); + } + }, + /** + * @private + * @param {KeyDownEvent} e + */ + _onKeyDownAddRecord: function (e) { + switch (e.keyCode) { + case $.ui.keyCode.ENTER: + e.stopPropagation(); + e.preventDefault(); + this._onAddRecord(e); + break; + } + }, + /** + * Handles the keyboard navigation according to events triggered by field + * widgets. + * - previous: move to the first activable cell on the left if any, if not + * move to the rightmost activable cell on the row above. + * - next: move to the first activable cell on the right if any, if not move + * to the leftmost activable cell on the row below. + * - next_line: move to leftmost activable cell on the row below. + * + * Note: moving to a line below if on the last line or moving to a line + * above if on the first line automatically creates a new line. + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + var self = this; + // Don't stop the propagation when navigating up while not editing any row + if (this.currentRow === null && ev.data.direction === 'up') { + return; + } + ev.stopPropagation(); // stop the event, the action is done by this renderer + if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) { + ev.data.originalEvent.preventDefault(); + ev.data.originalEvent.stopPropagation(); + } + switch (ev.data.direction) { + case 'previous': + if (this.currentFieldIndex > 0) { + this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false}) + .guardedCatch(this._moveToPreviousLine.bind(this)); + } else { + this._moveToPreviousLine(); + } + break; + case 'next': + if (this.currentFieldIndex + 1 < this.columns.length) { + this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false}) + .guardedCatch(this._moveToNextLine.bind(this)); + } else { + this._moveToNextLine(); + } + break; + case 'next_line': + // If the list is readonly and the current is the only record editable, we unselect the line + if (!this.editable && this.selection.length === 1 && + this._getRecordID(this.currentRow) === ev.target.dataPointID) { + this.unselectRow(); + } else { + this._moveToNextLine({ forceCreate: true }); + } + break; + case 'cancel': + // stop the original event (typically an ESCAPE keydown), to + // prevent from closing the potential dialog containing this list + // also auto-focus the 1st control, if any. + ev.data.originalEvent.stopPropagation(); + var rowIndex = this.currentRow; + var cellIndex = this.currentFieldIndex + 1; + this.trigger_up('discard_changes', { + recordID: ev.target.dataPointID, + onSuccess: function () { + self._enableRecordSelectors(); + var recordId = self._getRecordID(rowIndex); + if (recordId) { + var correspondingRow = self._getRow(recordId); + correspondingRow.children().eq(cellIndex).focus(); + } else if (self.currentGroupId) { + self.$('a[data-group-id="' + self.currentGroupId + '"]').focus(); + } else { + self.$('.o_field_x2many_list_row_add a:first').focus(); // FIXME + } + } + }); + break; + } + }, + /** + * Triggers a remove event. I don't know why we stop the propagation of the + * event. + * + * @param {MouseEvent} event + */ + _onRemoveIconClick: function (event) { + event.stopPropagation(); + var $row = $(event.target).closest('tr'); + var id = $row.data('id'); + if ($row.hasClass('o_selected_row')) { + this.trigger_up('list_record_remove', {id: id}); + } else { + var self = this; + this.unselectRow().then(function () { + self.trigger_up('list_record_remove', {id: id}); + }); + } + }, + /** + * React to window resize events by recomputing the width of each column. + * + * @private + */ + _onResize: function () { + this.columnWidths = false; + this._freezeColumnWidths(); + }, + /** + * If the list view editable, just let the event bubble. We don't want to + * open the record in this case anyway. + * + * @override + * @private + */ + _onRowClicked: function (ev) { + if (!this._isRecordEditable(ev.currentTarget.dataset.id)) { + // If there is an edited record, tries to save it and do not open the clicked record + if (this.getEditableRecordID()) { + this.unselectRow(); + } else { + this._super.apply(this, arguments); + } + } + }, + /** + * Overrides to prevent from sorting if we are currently editing a record. + * + * @override + * @private + */ + _onSortColumn: function () { + if (this.currentRow === null && !this.isResizing) { + this._super.apply(this, arguments); + } + }, + /** + * Handles the resize feature on the column headers + * + * @private + * @param {MouseEvent} ev + */ + _onStartResize: function (ev) { + // Only triggered by left mouse button + if (ev.which !== 1) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + + this.isResizing = true; + + const table = this.el.getElementsByClassName('o_list_table')[0]; + const th = ev.target.closest('th'); + table.style.width = `${table.offsetWidth}px`; + const thPosition = [...th.parentNode.children].indexOf(th); + const resizingColumnElements = [...table.getElementsByTagName('tr')] + .filter(tr => tr.children.length === th.parentNode.children.length) + .map(tr => tr.children[thPosition]); + const optionalDropdown = this.el.getElementsByClassName('o_optional_columns')[0]; + const initialX = ev.pageX; + const initialWidth = th.offsetWidth; + const initialTableWidth = table.offsetWidth; + const initialDropdownX = optionalDropdown ? optionalDropdown.offsetLeft : null; + const resizeStoppingEvents = [ + 'keydown', + 'mousedown', + 'mouseup', + ]; + + // Fix container width to prevent the table from overflowing when being resized + if (!this.el.style.width) { + this.el.style.width = `${this.el.offsetWidth}px`; + } + + // Apply classes to table and selected column + table.classList.add('o_resizing'); + resizingColumnElements.forEach(el => el.classList.add('o_column_resizing')); + + // Mousemove event : resize header + const resizeHeader = ev => { + ev.preventDefault(); + ev.stopPropagation(); + const delta = ev.pageX - initialX; + const newWidth = Math.max(10, initialWidth + delta); + const tableDelta = newWidth - initialWidth; + th.style.width = `${newWidth}px`; + th.style.maxWidth = `${newWidth}px`; + table.style.width = `${initialTableWidth + tableDelta}px`; + if (optionalDropdown) { + optionalDropdown.style.left = `${initialDropdownX + tableDelta}px`; + } + }; + this._addEventListener('mousemove', window, resizeHeader); + + // Mouse or keyboard events : stop resize + const stopResize = ev => { + // Ignores the initial 'left mouse button down' event in order + // to not instantly remove the listener + if (ev.type === 'mousedown' && ev.which === 1) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + // We need a small timeout to not trigger a click on column header + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + this.isResizing = false; + }, 100); + window.removeEventListener('mousemove', resizeHeader); + table.classList.remove('o_resizing'); + resizingColumnElements.forEach(el => el.classList.remove('o_column_resizing')); + resizeStoppingEvents.forEach(stoppingEvent => { + window.removeEventListener(stoppingEvent, stopResize); + }); + + // we remove the focus to make sure that the there is no focus inside + // the tr. If that is the case, there is some css to darken the whole + // thead, and it looks quite weird with the small css hover effect. + document.activeElement.blur(); + }; + // We have to listen to several events to properly stop the resizing function. Those are: + // - mousedown (e.g. pressing right click) + // - mouseup : logical flow of the resizing feature (drag & drop) + // - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key) + resizeStoppingEvents.forEach(stoppingEvent => { + this._addEventListener(stoppingEvent, window, stopResize); + }); + }, + /** + * Unselect the row before adding the optional column to the listview + * + * @override + * @private + */ + _onToggleOptionalColumnDropdown: function (ev) { + this.unselectRow().then(this._super.bind(this, ev)); + }, + /** + * When a click happens outside the list view, or outside a currently + * selected row, we want to unselect it. + * + * This is quite tricky, because in many cases, such as an autocomplete + * dropdown opened by a many2one in a list editable row, we actually don't + * want to unselect (and save) the current row. + * + * So, we try to ignore clicks on subelements of the renderer that are + * appended in the body, outside the table) + * + * @param {MouseEvent} event + */ + _onWindowClicked: function (event) { + // ignore clicks on readonly lists with no selected rows + if (!this.isEditable()) { + return; + } + + // ignore clicks if this renderer is not in the dom. + if (!document.contains(this.el)) { + return; + } + + // there is currently no selected row + if (this.currentRow === null) { + return; + } + + // ignore clicks in autocomplete dropdowns + if ($(event.target).closest('.ui-autocomplete').length) { + return; + } + + // ignore clicks if there is a modal, except if the list is in the last + // (active) modal + var $modal = $('body > .modal:last'); + if ($modal.length) { + var $listModal = this.$el.closest('.modal'); + if ($modal.prop('id') !== $listModal.prop('id')) { + return; + } + } + + // ignore clicks if target is no longer in dom. For example, a click on + // the 'delete' trash icon of a m2m tag. + if (!document.contains(event.target)) { + return; + } + + // ignore clicks if target is inside the list. In that case, they are + // handled directly by the renderer. + if (this.el.contains(event.target) && this.el !== event.target) { + return; + } + + // ignore click if search facet is removed as it will re-render whole + // listview again + if ($(event.target).hasClass('o_facet_remove')) { + return; + } + + this.unselectRow(); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js new file mode 100644 index 00000000..b119e7da --- /dev/null +++ b/addons/web/static/src/js/views/list/list_model.js @@ -0,0 +1,175 @@ +odoo.define('web.ListModel', function (require) { + "use strict"; + + var BasicModel = require('web.BasicModel'); + + var ListModel = BasicModel.extend({ + + /** + * @override + * @param {Object} params.groupbys + */ + init: function (parent, params) { + this._super.apply(this, arguments); + + this.groupbys = params.groupbys; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * overridden to add `groupData` when performing get on list datapoints. + * + * @override + * @see _readGroupExtraFields + */ + __get: function () { + var result = this._super.apply(this, arguments); + var dp = result && this.localData[result.id]; + if (dp && dp.groupData) { + result.groupData = this.get(dp.groupData); + } + return result; + }, + /** + * For a list of records, performs a write with all changes and fetches + * all data. + * + * @param {string} listDatapointId id of the parent list + * @param {string} referenceRecordId the record datapoint used to + * generate the changes to apply to recordIds + * @param {string[]} recordIds a list of record datapoint ids + * @param {string} fieldName the field to write + */ + saveRecords: function (listDatapointId, referenceRecordId, recordIds, fieldName) { + var self = this; + var referenceRecord = this.localData[referenceRecordId]; + var list = this.localData[listDatapointId]; + // generate all record values to ensure that we'll write something + // (e.g. 2 records selected, edit a many2one in the first one, but + // reset same value, we still want to save this value on the other + // record) + var allChanges = this._generateChanges(referenceRecord, {changesOnly: false}); + var changes = _.pick(allChanges, fieldName); + var records = recordIds.map(function (recordId) { + return self.localData[recordId]; + }); + var model = records[0].model; + var recordResIds = _.pluck(records, 'res_id'); + var fieldNames = records[0].getFieldNames(); + var context = records[0].getContext(); + + return this._rpc({ + model: model, + method: 'write', + args: [recordResIds, changes], + context: context, + }).then(function () { + return self._rpc({ + model: model, + method: 'read', + args: [recordResIds, fieldNames], + context: context, + }); + }).then(function (results) { + results.forEach(function (data) { + var record = _.findWhere(records, {res_id: data.id}); + record.data = _.extend({}, record.data, data); + record._changes = {}; + record._isDirty = false; + self._parseServerData(fieldNames, record, record.data); + }); + }).then(function () { + if (!list.groupedBy.length) { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + } else { + return Promise.all([ + self._fetchX2ManysSingleBatch(list), + self._fetchReferencesSingleBatch(list) + ]); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @override + * @private + */ + _readGroup: function (list, options) { + var self = this; + options = options || {}; + options.fetchRecordsWithGroups = true; + return this._super(list, options).then(function (result) { + return self._readGroupExtraFields(list).then(_.constant(result)); + }); + }, + /** + * Fetches group specific fields on the group by relation and stores it + * in the column datapoint in a special key `groupData`. + * Data for the groups are fetched in batch for all groups, to avoid + * doing multiple calls. + * Note that the option is only for m2o fields. + * + * @private + * @param {Object} list + * @returns {Promise} + */ + _readGroupExtraFields: function (list) { + var self = this; + var groupByFieldName = list.groupedBy[0].split(':')[0]; + var groupedByField = list.fields[groupByFieldName]; + if (groupedByField.type !== 'many2one' || !this.groupbys[groupByFieldName]) { + return Promise.resolve(); + } + var groupIds = _.reduce(list.data, function (groupIds, id) { + var resId = self.get(id, { raw: true }).res_id; + if (resId) { // the field might be undefined when grouping + groupIds.push(resId); + } + return groupIds; + }, []); + var groupFields = Object.keys(this.groupbys[groupByFieldName].viewFields); + var prom; + if (groupIds.length && groupFields.length) { + prom = this._rpc({ + model: groupedByField.relation, + method: 'read', + args: [groupIds, groupFields], + context: list.context, + }); + } + return Promise.resolve(prom).then(function (result) { + var fvg = self.groupbys[groupByFieldName]; + _.each(list.data, function (id) { + var dp = self.localData[id]; + var groupData = result && _.findWhere(result, { + id: dp.res_id, + }); + var groupDp = self._makeDataPoint({ + context: dp.context, + data: groupData, + fields: fvg.fields, + fieldsInfo: fvg.fieldsInfo, + modelName: groupedByField.relation, + parentID: dp.id, + res_id: dp.res_id, + viewType: 'groupby', + }); + dp.groupData = groupDp.id; + self._parseServerData(groupFields, groupDp, groupDp.data); + }); + }); + }, + }); + return ListModel; +}); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js new file mode 100644 index 00000000..4e24ce54 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -0,0 +1,1470 @@ +odoo.define('web.ListRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +const { ComponentWrapper } = require('web.OwlCompatibility'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +var field_utils = require('web.field_utils'); +var Pager = require('web.Pager'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; + +// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes +var DECORATIONS = [ + 'decoration-bf', + 'decoration-it', + 'decoration-danger', + 'decoration-info', + 'decoration-muted', + 'decoration-primary', + 'decoration-success', + 'decoration-warning' +]; + +var FIELD_CLASSES = { + char: 'o_list_char', + float: 'o_list_number', + integer: 'o_list_number', + monetary: 'o_list_number', + text: 'o_list_text', + many2one: 'o_list_many2one', +}; + +var ListRenderer = BasicRenderer.extend({ + className: 'o_list_view', + events: { + "mousedown": "_onMouseDown", + "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn", + "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown", + 'click tbody tr': '_onRowClicked', + 'change tbody .o_list_record_selector': '_onSelectRecord', + 'click thead th.o_column_sortable': '_onSortColumn', + 'click .o_list_record_selector': '_onToggleCheckbox', + 'click .o_group_header': '_onToggleGroup', + 'change thead .o_list_record_selector input': '_onToggleSelection', + 'keypress thead tr td': '_onKeyPress', + 'keydown td': '_onKeyDown', + 'keydown th': '_onKeyDown', + }, + sampleDataTargets: [ + '.o_data_row', + '.o_group_header', + '.o_list_table > tfoot', + '.o_list_table > thead .o_list_record_selector', + ], + /** + * @constructor + * @param {Widget} parent + * @param {any} state + * @param {Object} params + * @param {boolean} params.hasSelectors + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this._preprocessColumns(); + this.columnInvisibleFields = params.columnInvisibleFields || {}; + this.rowDecorations = this._extractDecorationAttrs(this.arch); + this.fieldDecorations = {}; + for (const field of this.arch.children.filter(c => c.tag === 'field')) { + const decorations = this._extractDecorationAttrs(field); + this.fieldDecorations[field.attrs.name] = decorations; + } + this.hasSelectors = params.hasSelectors; + this.selection = params.selectedRecords || []; + this.pagers = []; // instantiated pagers (only for grouped lists) + this.isGrouped = this.state.groupedBy.length > 0; + this.groupbys = params.groupbys; + }, + /** + * Compute columns visilibity. This can't be done earlier as we need the + * controller to respond to the load_optional_fields call of processColumns. + * + * @override + */ + willStart: function () { + this._processColumns(this.columnInvisibleFields); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Order to focus to be given to the content of the current view + * + * @override + */ + giveFocus: function () { + this.$('th:eq(0) input, th:eq(1)').first().focus(); + }, + /** + * @override + */ + updateState: function (state, params) { + this._setState(state); + this.isGrouped = this.state.groupedBy.length > 0; + this.columnInvisibleFields = params.columnInvisibleFields || {}; + this._processColumns(this.columnInvisibleFields); + if (params.selectedRecords) { + this.selection = params.selectedRecords; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method does a in-memory computation of the aggregate values, for + * each columns that corresponds to a numeric field with a proper aggregate + * function. + * + * The result of these computations is stored in the 'aggregate' key of each + * column of this.columns. This will be then used by the _renderFooter + * method to display the appropriate amount. + * + * @private + */ + _computeAggregates: function () { + var self = this; + var data = []; + if (this.selection.length) { + utils.traverse_records(this.state, function (record) { + if (_.contains(self.selection, record.id)) { + data.push(record); // find selected records + } + }); + } else { + data = this.state.data; + } + + _.each(this.columns, this._computeColumnAggregates.bind(this, data)); + }, + /** + * Compute the aggregate values for a given column and a set of records. + * The aggregate values are then written, if applicable, in the 'aggregate' + * key of the column object. + * + * @private + * @param {Object[]} data a list of selected/all records + * @param {Object} column + */ + _computeColumnAggregates: function (data, column) { + var attrs = column.attrs; + var field = this.state.fields[attrs.name]; + if (!field) { + return; + } + var type = field.type; + if (type !== 'integer' && type !== 'float' && type !== 'monetary') { + return; + } + var func = (attrs.sum && 'sum') || (attrs.avg && 'avg') || + (attrs.max && 'max') || (attrs.min && 'min'); + if (func) { + var count = 0; + var aggregateValue = 0; + if (func === 'max') { + aggregateValue = -Infinity; + } else if (func === 'min') { + aggregateValue = Infinity; + } + _.each(data, function (d) { + count += 1; + var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name]; + if (func === 'avg') { + aggregateValue += value; + } else if (func === 'sum') { + aggregateValue += value; + } else if (func === 'max') { + aggregateValue = Math.max(aggregateValue, value); + } else if (func === 'min') { + aggregateValue = Math.min(aggregateValue, value); + } + }); + if (func === 'avg') { + aggregateValue = count ? aggregateValue / count : aggregateValue; + } + column.aggregate = { + help: attrs[func], + value: aggregateValue, + }; + } + }, + /** + * Extract the decoration attributes (e.g. decoration-danger) of a node. The + * condition is processed such that it is ready to be evaluated. + * + * @private + * @param {Object} node the <tree> or a <field> node + * @returns {Object} + */ + _extractDecorationAttrs: function (node) { + const decorations = {}; + for (const [key, expr] of Object.entries(node.attrs)) { + if (DECORATIONS.includes(key)) { + const cssClass = key.replace('decoration', 'text'); + decorations[cssClass] = py.parse(py.tokenize(expr)); + } + } + return decorations; + }, + /** + * + * @private + * @param {jQuery} $cell + * @param {string} direction + * @param {integer} colIndex + * @returns {jQuery|null} + */ + _findConnectedCell: function ($cell, direction, colIndex) { + var $connectedRow = $cell.closest('tr')[direction]('tr'); + + if (!$connectedRow.length) { + // Is there another group ? Look at our parent's sibling + // We can have th in tbody so we can't simply look for thead + // if cell is a th and tbody instead + var tbody = $cell.closest('tbody, thead'); + var $connectedGroup = tbody[direction]('tbody, thead'); + if ($connectedGroup.length) { + // Found another group + var $connectedRows = $connectedGroup.find('tr'); + var rowIndex; + if (direction === 'prev') { + rowIndex = $connectedRows.length - 1; + } else { + rowIndex = 0; + } + $connectedRow = $connectedRows.eq(rowIndex); + } else { + // End of the table + return; + } + } + + var $connectedCell; + if ($connectedRow.hasClass('o_group_header')) { + $connectedCell = $connectedRow.children(); + this.currentColIndex = colIndex; + } else if ($connectedRow.has('td.o_group_field_row_add').length) { + $connectedCell = $connectedRow.find('.o_group_field_row_add'); + this.currentColIndex = colIndex; + } else { + var connectedRowChildren = $connectedRow.children(); + if (colIndex === -1) { + colIndex = connectedRowChildren.length - 1; + } + $connectedCell = connectedRowChildren.eq(colIndex); + } + + return $connectedCell; + }, + /** + * return the number of visible columns. Note that this number depends on + * the state of the renderer. For example, in editable mode, it could be + * one more that in non editable mode, because there may be a visible 'trash + * icon'. + * + * @private + * @returns {integer} + */ + _getNumberOfCols: function () { + var n = this.columns.length; + return this.hasSelectors ? n + 1 : n; + }, + /** + * Returns the local storage key for stored enabled optional columns + * + * @private + * @returns {string} + */ + _getOptionalColumnsStorageKeyParts: function () { + var self = this; + return { + fields: _.map(this.state.fieldsInfo[this.viewType], function (_, fieldName) { + return {name: fieldName, type: self.state.fields[fieldName].type}; + }), + }; + }, + /** + * Adjacent buttons (in the arch) are displayed in a single column. This + * function iterates over the arch's nodes and replaces "button" nodes by + * "button_group" nodes, with a single "button_group" node for adjacent + * "button" nodes. A "button_group" node has a "children" attribute + * containing all "button" nodes in the group. + * + * @private + */ + _groupAdjacentButtons: function () { + const children = []; + let groupId = 0; + let buttonGroupNode = null; + for (const c of this.arch.children) { + if (c.tag === 'button') { + if (!buttonGroupNode) { + buttonGroupNode = { + tag: 'button_group', + children: [c], + attrs: { + name: `button_group_${groupId++}`, + modifiers: {}, + }, + }; + children.push(buttonGroupNode); + } else { + buttonGroupNode.children.push(c); + } + } else { + buttonGroupNode = null; + children.push(c); + } + } + this.arch.children = children; + }, + /** + * Processes arch's child nodes for the needs of the list view: + * - detects oe_read_only/oe_edit_only classnames + * - groups adjacent buttons in a single column. + * This function is executed only once, at initialization. + * + * @private + */ + _preprocessColumns: function () { + this._processModeClassNames(); + this._groupAdjacentButtons(); + + // set as readOnly (resp. editOnly) button groups containing only + // readOnly (resp. editOnly) buttons, s.t. no column is rendered + this.arch.children.filter(c => c.tag === 'button_group').forEach(c => { + c.attrs.editOnly = c.children.every(n => n.attrs.editOnly); + c.attrs.readOnly = c.children.every(n => n.attrs.readOnly); + }); + }, + /** + * Removes the columns which should be invisible. This function is executed + * at each (re-)rendering of the list. + * + * @param {Object} columnInvisibleFields contains the column invisible modifier values + */ + _processColumns: function (columnInvisibleFields) { + var self = this; + this.handleField = null; + this.columns = []; + this.optionalColumns = []; + this.optionalColumnsEnabled = []; + var storedOptionalColumns; + this.trigger_up('load_optional_fields', { + keyParts: this._getOptionalColumnsStorageKeyParts(), + callback: function (res) { + storedOptionalColumns = res; + }, + }); + _.each(this.arch.children, function (c) { + if (c.tag !== 'control' && c.tag !== 'groupby' && c.tag !== 'header') { + var reject = c.attrs.modifiers.column_invisible; + // If there is an evaluated domain for the field we override the node + // attribute to have the evaluated modifier value. + if (c.tag === "button_group") { + // FIXME: 'column_invisible' attribute is available for fields *and* buttons, + // so 'columnInvisibleFields' variable name is misleading, it should be renamed + reject = c.children.every(child => columnInvisibleFields[child.attrs.name]); + } else if (c.attrs.name in columnInvisibleFields) { + reject = columnInvisibleFields[c.attrs.name]; + } + if (!reject && c.attrs.widget === 'handle') { + self.handleField = c.attrs.name; + if (self.isGrouped) { + reject = true; + } + } + + if (!reject && c.attrs.optional) { + self.optionalColumns.push(c); + var enabled; + if (storedOptionalColumns === undefined) { + enabled = c.attrs.optional === 'show'; + } else { + enabled = _.contains(storedOptionalColumns, c.attrs.name); + } + if (enabled) { + self.optionalColumnsEnabled.push(c.attrs.name); + } + reject = !enabled; + } + + if (!reject) { + self.columns.push(c); + } + } + }); + }, + /** + * Classnames "oe_edit_only" and "oe_read_only" aim to only display the cell + * in the corresponding mode. This only concerns lists inside form views + * (for x2many fields). This function detects the className and stores a + * flag on the node's attrs accordingly, to ease further computations. + * + * @private + */ + _processModeClassNames: function () { + this.arch.children.forEach(c => { + if (c.attrs.class) { + c.attrs.editOnly = /\boe_edit_only\b/.test(c.attrs.class); + c.attrs.readOnly = /\boe_read_only\b/.test(c.attrs.class); + } + }); + }, + /** + * Render a list of <td>, with aggregates if available. It can be displayed + * in the footer, or for each open groups. + * + * @private + * @param {any} aggregateValues + * @returns {jQueryElement[]} a list of <td> with the aggregate values + */ + _renderAggregateCells: function (aggregateValues) { + var self = this; + + return _.map(this.columns, function (column) { + var $cell = $('<td>'); + if (config.isDebug()) { + $cell.addClass(column.attrs.name); + } + if (column.attrs.editOnly) { + $cell.addClass('oe_edit_only'); + } + if (column.attrs.readOnly) { + $cell.addClass('oe_read_only'); + } + if (column.attrs.name in aggregateValues) { + var field = self.state.fields[column.attrs.name]; + var value = aggregateValues[column.attrs.name].value; + var help = aggregateValues[column.attrs.name].help; + var formatFunc = field_utils.format[column.attrs.widget]; + if (!formatFunc) { + formatFunc = field_utils.format[field.type]; + } + var formattedValue = formatFunc(value, field, { + escape: true, + digits: column.attrs.digits ? JSON.parse(column.attrs.digits) : undefined, + }); + $cell.addClass('o_list_number').attr('title', help).html(formattedValue); + } + return $cell; + }); + }, + /** + * Render the main body of the table, with all its content. Note that it + * has been decided to always render at least 4 rows, even if we have less + * data. The reason is that lists with 0 or 1 lines don't really look like + * a table. + * + * @private + * @returns {jQueryElement} a jquery element <tbody> + */ + _renderBody: function () { + var self = this; + var $rows = this._renderRows(); + while ($rows.length < 4) { + $rows.push(self._renderEmptyRow()); + } + return $('<tbody>').append($rows); + }, + /** + * Render a cell for the table. For most cells, we only want to display the + * formatted value, with some appropriate css class. However, when the + * node was explicitely defined with a 'widget' attribute, then we + * instantiate the corresponding widget. + * + * @private + * @param {Object} record + * @param {Object} node + * @param {integer} colIndex + * @param {Object} [options] + * @param {Object} [options.mode] + * @param {Object} [options.renderInvisible=false] + * force the rendering of invisible cell content + * @param {Object} [options.renderWidgets=false] + * force the rendering of the cell value thanks to a widget + * @returns {jQueryElement} a <td> element + */ + _renderBodyCell: function (record, node, colIndex, options) { + var tdClassName = 'o_data_cell'; + if (node.tag === 'button_group') { + tdClassName += ' o_list_button'; + } else if (node.tag === 'field') { + tdClassName += ' o_field_cell'; + var typeClass = FIELD_CLASSES[this.state.fields[node.attrs.name].type]; + if (typeClass) { + tdClassName += (' ' + typeClass); + } + if (node.attrs.widget) { + tdClassName += (' o_' + node.attrs.widget + '_cell'); + } + } + if (node.attrs.editOnly) { + tdClassName += ' oe_edit_only'; + } + if (node.attrs.readOnly) { + tdClassName += ' oe_read_only'; + } + var $td = $('<td>', { class: tdClassName, tabindex: -1 }); + + // We register modifiers on the <td> element so that it gets the correct + // modifiers classes (for styling) + var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode')); + // If the invisible modifiers is true, the <td> element is left empty. + // Indeed, if the modifiers was to change the whole cell would be + // rerendered anyway. + if (modifiers.invisible && !(options && options.renderInvisible)) { + return $td; + } + + if (node.tag === 'button_group') { + for (const buttonNode of node.children) { + if (!this.columnInvisibleFields[buttonNode.attrs.name]) { + $td.append(this._renderButton(record, buttonNode)); + } + } + return $td; + } else if (node.tag === 'widget') { + return $td.append(this._renderWidget(record, node)); + } + if (node.attrs.widget || (options && options.renderWidgets)) { + var $el = this._renderFieldWidget(node, record, _.pick(options, 'mode')); + return $td.append($el); + } + this._handleAttributes($td, node); + this._setDecorationClasses($td, this.fieldDecorations[node.attrs.name], record); + + var name = node.attrs.name; + var field = this.state.fields[name]; + var value = record.data[name]; + var formatter = field_utils.format[field.type]; + var formatOptions = { + escape: true, + data: record.data, + isPassword: 'password' in node.attrs, + digits: node.attrs.digits && JSON.parse(node.attrs.digits), + }; + var formattedValue = formatter(value, field, formatOptions); + var title = ''; + if (field.type !== 'boolean') { + title = formatter(value, field, _.extend(formatOptions, {escape: false})); + } + return $td.html(formattedValue).attr('title', title); + }, + /** + * Renders the button element associated to the given node and record. + * + * @private + * @param {Object} record + * @param {Object} node + * @returns {jQuery} a <button> element + */ + _renderButton: function (record, node) { + var self = this; + var nodeWithoutWidth = Object.assign({}, node); + delete nodeWithoutWidth.attrs.width; + + let extraClass = ''; + if (node.attrs.icon) { + // if there is an icon, we force the btn-link style, unless a btn-xxx + // style class is explicitely provided + const btnStyleRegex = /\bbtn-[a-z]+\b/; + if (!btnStyleRegex.test(nodeWithoutWidth.attrs.class)) { + extraClass = 'btn-link o_icon_button'; + } + } + var $button = viewUtils.renderButtonFromNode(nodeWithoutWidth, { + extraClass: extraClass, + }); + this._handleAttributes($button, node); + this._registerModifiers(node, record, $button); + + if (record.res_id) { + // TODO this should be moved to a handler + $button.on("click", function (e) { + e.stopPropagation(); + self.trigger_up('button_clicked', { + attrs: node.attrs, + record: record, + }); + }); + } else { + if (node.attrs.options.warn) { + $button.on("click", function (e) { + e.stopPropagation(); + self.do_warn(false, _t('Please click on the "save" button first')); + }); + } else { + $button.prop('disabled', true); + } + } + + return $button; + }, + /** + * Render a complete empty row. This is used to fill in the blanks when we + * have less than 4 lines to display. + * + * @private + * @returns {jQueryElement} a <tr> element + */ + _renderEmptyRow: function () { + var $td = $('<td> </td>').attr('colspan', this._getNumberOfCols()); + return $('<tr>').append($td); + }, + /** + * Render the footer. It is a <tfoot> with a single row, containing all + * aggregates, if applicable. + * + * @private + * @returns {jQueryElement} a <tfoot> element + */ + _renderFooter: function () { + var aggregates = {}; + _.each(this.columns, function (column) { + if ('aggregate' in column) { + aggregates[column.attrs.name] = column.aggregate; + } + }); + var $cells = this._renderAggregateCells(aggregates); + if (this.hasSelectors) { + $cells.unshift($('<td>')); + } + return $('<tfoot>').append($('<tr>').append($cells)); + }, + /** + * Renders the group button element. + * + * @private + * @param {Object} record + * @param {Object} group + * @returns {jQuery} a <button> element + */ + _renderGroupButton: function (list, node) { + var $button = viewUtils.renderButtonFromNode(node, { + extraClass: node.attrs.icon ? 'o_icon_button' : undefined, + textAsTitle: !!node.attrs.icon, + }); + this._handleAttributes($button, node); + this._registerModifiers(node, list.groupData, $button); + + // TODO this should be moved to event handlers + $button.on("click", this._onGroupButtonClicked.bind(this, list.groupData, node)); + $button.on("keydown", this._onGroupButtonKeydown.bind(this)); + + return $button; + }, + /** + * Renders the group buttons. + * + * @private + * @param {Object} record + * @param {Object} group + * @returns {jQuery} a <button> element + */ + _renderGroupButtons: function (list, group) { + var self = this; + var $buttons = $(); + if (list.value) { + // buttons make no sense for 'Undefined' group + group.arch.children.forEach(function (child) { + if (child.tag === 'button') { + $buttons = $buttons.add(self._renderGroupButton(list, child)); + } + }); + } + return $buttons; + }, + /** + * Render the row that represent a group + * + * @private + * @param {Object} group + * @param {integer} groupLevel the nesting level (0 for root groups) + * @returns {jQueryElement} a <tr> element + */ + _renderGroupRow: function (group, groupLevel) { + var cells = []; + + var name = group.value === undefined ? _t('Undefined') : group.value; + var groupBy = this.state.groupedBy[groupLevel]; + if (group.fields[groupBy.split(':')[0]].type !== 'boolean') { + name = name || _t('Undefined'); + } + var $th = $('<th>') + .addClass('o_group_name') + .attr('tabindex', -1) + .text(name + ' (' + group.count + ')'); + var $arrow = $('<span>') + .css('padding-left', 2 + (groupLevel * 20) + 'px') + .css('padding-right', '5px') + .addClass('fa'); + if (group.count > 0) { + $arrow.toggleClass('fa-caret-right', !group.isOpen) + .toggleClass('fa-caret-down', group.isOpen); + } + $th.prepend($arrow); + cells.push($th); + + var aggregateKeys = Object.keys(group.aggregateValues); + var aggregateValues = _.mapObject(group.aggregateValues, function (value) { + return { value: value }; + }); + var aggregateCells = this._renderAggregateCells(aggregateValues); + var firstAggregateIndex = _.findIndex(this.columns, function (column) { + return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name); + }); + var colspanBeforeAggregate; + if (firstAggregateIndex !== -1) { + // if there are aggregates, the first $th goes until the first + // aggregate then all cells between aggregates are rendered + colspanBeforeAggregate = firstAggregateIndex; + var lastAggregateIndex = _.findLastIndex(this.columns, function (column) { + return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name); + }); + cells = cells.concat(aggregateCells.slice(firstAggregateIndex, lastAggregateIndex + 1)); + var colSpan = this.columns.length - 1 - lastAggregateIndex; + if (colSpan > 0) { + cells.push($('<th>').attr('colspan', colSpan)); + } + } else { + var colN = this.columns.length; + colspanBeforeAggregate = colN > 1 ? colN - 1 : 1; + if (colN > 1) { + cells.push($('<th>')); + } + } + if (this.hasSelectors) { + colspanBeforeAggregate += 1; + } + $th.attr('colspan', colspanBeforeAggregate); + + if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) { + const lastCell = cells[cells.length - 1][0]; + this._renderGroupPager(group, lastCell); + } + if (group.isOpen && this.groupbys[groupBy]) { + var $buttons = this._renderGroupButtons(group, this.groupbys[groupBy]); + if ($buttons.length) { + var $buttonSection = $('<div>', { + class: 'o_group_buttons', + }).append($buttons); + $th.append($buttonSection); + } + } + return $('<tr>') + .addClass('o_group_header') + .toggleClass('o_group_open', group.isOpen) + .toggleClass('o_group_has_content', group.count > 0) + .data('group', group) + .append(cells); + }, + /** + * Render the content of a given opened group. + * + * @private + * @param {Object} group + * @param {integer} groupLevel the nesting level (0 for root groups) + * @returns {jQueryElement} a <tr> element + */ + _renderGroup: function (group, groupLevel) { + var self = this; + if (group.groupedBy.length) { + // the opened group contains subgroups + return this._renderGroups(group.data, groupLevel + 1); + } else { + // the opened group contains records + var $records = _.map(group.data, function (record) { + return self._renderRow(record); + }); + return [$('<tbody>').append($records)]; + } + }, + /** + * Renders the pager for a given group + * + * @private + * @param {Object} group + * @param {HTMLElement} target + */ + _renderGroupPager: function (group, target) { + const currentMinimum = group.offset + 1; + const limit = group.limit; + const size = group.count; + if (!this._shouldRenderPager(currentMinimum, limit, size)) { + return; + } + const pager = new ComponentWrapper(this, Pager, { currentMinimum, limit, size }); + const pagerMounting = pager.mount(target).then(() => { + // Event binding is done here to get the related group and wrapper. + pager.el.addEventListener('pager-changed', ev => this._onPagerChanged(ev, group)); + // Prevent pager clicks to toggle the group. + pager.el.addEventListener('click', ev => ev.stopPropagation()); + }); + this.defs.push(pagerMounting); + this.pagers.push(pager); + }, + /** + * Render all groups in the view. We assume that the view is in grouped + * mode. + * + * Note that each group is rendered inside a <tbody>, which contains a group + * row, then possibly a bunch of rows for each record. + * + * @private + * @param {Object} data the dataPoint containing the groups + * @param {integer} [groupLevel=0] the nesting level. 0 is for the root group + * @returns {jQueryElement[]} a list of <tbody> + */ + _renderGroups: function (data, groupLevel) { + var self = this; + groupLevel = groupLevel || 0; + var result = []; + var $tbody = $('<tbody>'); + _.each(data, function (group) { + if (!$tbody) { + $tbody = $('<tbody>'); + } + $tbody.append(self._renderGroupRow(group, groupLevel)); + if (group.data.length) { + result.push($tbody); + result = result.concat(self._renderGroup(group, groupLevel)); + $tbody = null; + } + }); + if ($tbody) { + result.push($tbody); + } + return result; + }, + /** + * Render the main header for the list view. It is basically just a <thead> + * with the name of each fields + * + * @private + * @returns {jQueryElement} a <thead> element + */ + _renderHeader: function () { + var $tr = $('<tr>') + .append(_.map(this.columns, this._renderHeaderCell.bind(this))); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('th')); + } + return $('<thead>').append($tr); + }, + /** + * Render a single <th> with the informations for a column. If it is not a + * field or nolabel attribute is set to "1", the th will be empty. + * Otherwise, it will contains all relevant information for the field. + * + * @private + * @param {Object} node + * @returns {jQueryElement} a <th> element + */ + _renderHeaderCell: function (node) { + const { icon, name, string } = node.attrs; + var order = this.state.orderedBy; + var isNodeSorted = order[0] && order[0].name === name; + var field = this.state.fields[name]; + var $th = $('<th>'); + if (name) { + $th.attr('data-name', name); + } else if (string) { + $th.attr('data-string', string); + } else if (icon) { + $th.attr('data-icon', icon); + } + if (node.attrs.editOnly) { + $th.addClass('oe_edit_only'); + } + if (node.attrs.readOnly) { + $th.addClass('oe_read_only'); + } + if (node.tag === 'button_group') { + $th.addClass('o_list_button'); + } + if (!field || node.attrs.nolabel === '1') { + return $th; + } + var description = string || field.string; + if (node.attrs.widget) { + $th.addClass(' o_' + node.attrs.widget + '_cell'); + const FieldWidget = this.state.fieldsInfo.list[name].Widget; + if (FieldWidget.prototype.noLabel) { + description = ''; + } else if (FieldWidget.prototype.label) { + description = FieldWidget.prototype.label; + } + } + $th.text(description) + .attr('tabindex', -1) + .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false) + .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false) + .addClass((field.sortable || this.state.fieldsInfo.list[name].options.allow_order || false) && 'o_column_sortable'); + + if (isNodeSorted) { + $th.attr('aria-sort', order[0].asc ? 'ascending' : 'descending'); + } + + if (field.type === 'float' || field.type === 'integer' || field.type === 'monetary') { + $th.addClass('o_list_number_th'); + } + + if (config.isDebug()) { + var fieldDescr = { + field: field, + name: name, + string: description || name, + record: this.state, + attrs: _.extend({}, node.attrs, this.state.fieldsInfo.list[name]), + }; + this._addFieldTooltip(fieldDescr, $th); + } else { + $th.attr('title', description); + } + return $th; + }, + /** + * Render a row, corresponding to a record. + * + * @private + * @param {Object} record + * @returns {jQueryElement} a <tr> element + */ + _renderRow: function (record) { + var self = this; + var $cells = this.columns.map(function (node, index) { + return self._renderBodyCell(record, node, index, { mode: 'readonly' }); + }); + + var $tr = $('<tr/>', { class: 'o_data_row' }) + .attr('data-id', record.id) + .append($cells); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('td', !record.res_id)); + } + this._setDecorationClasses($tr, this.rowDecorations, record); + return $tr; + }, + /** + * Render all rows. This method should only called when the view is not + * grouped. + * + * @private + * @returns {jQueryElement[]} a list of <tr> + */ + _renderRows: function () { + return this.state.data.map(this._renderRow.bind(this)); + }, + /** + * Render a single <th> with dropdown menu to display optional columns of view. + * + * @private + * @returns {jQueryElement} a <th> element + */ + _renderOptionalColumnsDropdown: function () { + var self = this; + var $optionalColumnsDropdown = $('<div>', { + class: 'o_optional_columns text-center dropdown', + }); + var $a = $("<a>", { + 'class': "dropdown-toggle text-dark o-no-caret", + 'href': "#", + 'role': "button", + 'data-toggle': "dropdown", + 'data-display': "static", + 'aria-expanded': false, + 'aria-label': _t('Optional columns'), + }); + $a.appendTo($optionalColumnsDropdown); + + // Set the expansion direction of the dropdown + // The button is located at the end of the list headers + // We want the dropdown to expand towards the list rather than away from it + // https://getbootstrap.com/docs/4.0/components/dropdowns/#menu-alignment + var direction = _t.database.parameters.direction; + var dropdownMenuClass = direction === 'rtl' ? 'dropdown-menu-left' : 'dropdown-menu-right'; + var $dropdown = $("<div>", { + class: 'dropdown-menu o_optional_columns_dropdown ' + dropdownMenuClass, + role: 'menu', + }); + this.optionalColumns.forEach(function (col) { + var txt = (col.attrs.string || self.state.fields[col.attrs.name].string) + + (config.isDebug() ? (' (' + col.attrs.name + ')') : ''); + var $checkbox = dom.renderCheckbox({ + text: txt, + role: "menuitemcheckbox", + prop: { + name: col.attrs.name, + checked: _.contains(self.optionalColumnsEnabled, col.attrs.name), + } + }); + $dropdown.append($("<div>", { + class: "dropdown-item", + }).append($checkbox)); + }); + $dropdown.appendTo($optionalColumnsDropdown); + return $optionalColumnsDropdown; + }, + /** + * A 'selector' is the small checkbox on the left of a record in a list + * view. This is rendered as an input inside a div, so we can properly + * style it. + * + * Note that it takes a tag in argument, because selectors in the header + * are renderd in a th, and those in the tbody are in a td. + * + * @private + * @param {string} tag either th or td + * @param {boolean} disableInput if true, the input generated will be disabled + * @returns {jQueryElement} + */ + _renderSelector: function (tag, disableInput) { + var $content = dom.renderCheckbox(); + if (disableInput) { + $content.find("input[type='checkbox']").prop('disabled', disableInput); + } + return $('<' + tag + '>') + .addClass('o_list_record_selector') + .append($content); + }, + /** + * Main render function for the list. It is rendered as a table. For now, + * this method does not wait for the field widgets to be ready. + * + * @override + * @returns {Promise} resolved when the view has been rendered + */ + async _renderView() { + const oldPagers = this.pagers; + let prom; + let tableWrapper; + if (this.state.count > 0 || !this.noContentHelp) { + // render a table if there are records, or if there is no no content + // helper (empty table in this case) + this.pagers = []; + + const orderedBy = this.state.orderedBy; + this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField; + this._computeAggregates(); + + const $table = $( + '<table class="o_list_table table table-sm table-hover table-striped"/>' + ); + $table.toggleClass('o_list_table_grouped', this.isGrouped); + $table.toggleClass('o_list_table_ungrouped', !this.isGrouped); + const defs = []; + this.defs = defs; + if (this.isGrouped) { + $table.append(this._renderHeader()); + $table.append(this._renderGroups(this.state.data)); + $table.append(this._renderFooter()); + + } else { + $table.append(this._renderHeader()); + $table.append(this._renderBody()); + $table.append(this._renderFooter()); + } + tableWrapper = Object.assign(document.createElement('div'), { + className: 'table-responsive', + }); + tableWrapper.appendChild($table[0]); + delete this.defs; + prom = Promise.all(defs); + } + + await Promise.all([this._super.apply(this, arguments), prom]); + + this.el.innerHTML = ""; + this.el.classList.remove('o_list_optional_columns'); + + // destroy the previously instantiated pagers, if any + oldPagers.forEach(pager => pager.destroy()); + + // append the table (if any) to the main element + if (tableWrapper) { + this.el.appendChild(tableWrapper); + if (document.body.contains(this.el)) { + this.pagers.forEach(pager => pager.on_attach_callback()); + } + if (this.optionalColumns.length) { + this.el.classList.add('o_list_optional_columns'); + this.$('table').append( + $('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>') + ); + this.$el.append(this._renderOptionalColumnsDropdown()); + } + if (this.selection.length) { + const $checked_rows = this.$('tr').filter( + (i, el) => this.selection.includes(el.dataset.id) + ); + $checked_rows.find('.o_list_record_selector input').prop('checked', true); + if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked + this.$('thead .o_list_record_selector input').prop('checked', true); + } + } + } + + // display the no content helper if necessary + if (!this._hasContent() && !!this.noContentHelp) { + this._renderNoContentHelper(); + } + }, + /** + * Each line or cell can be decorated according to a few simple rules. The + * arch description of the list or the field nodes may have one of the + * decoration-X attributes with a python expression as value. Then, for each + * record, we evaluate the python expression, and conditionnaly add the + * text-X css class to the element. This method is concerned with the + * computation of the list of css classes for a given record. + * + * @private + * @param {jQueryElement} $el the element to which to add the classes (a tr + * or td) + * @param {Object} decorations keys are the decoration classes (e.g. + * 'text-bf') and values are the python expressions to evaluate + * @param {Object} record a basic model record + */ + _setDecorationClasses: function ($el, decorations, record) { + for (const [cssClass, expr] of Object.entries(decorations)) { + $el.toggleClass(cssClass, py.PY_isTrue(py.evaluate(expr, record.evalContext))); + } + }, + /** + * @private + * @returns {boolean} + */ + _shouldRenderPager: function (currentMinimum, limit, size) { + if (!limit || !size) { + return false; + } + const maximum = Math.min(currentMinimum + limit - 1, size); + const singlePage = (1 === currentMinimum) && (maximum === size); + return !singlePage; + }, + /** + * Update the footer aggregate values. This method should be called each + * time the state of some field is changed, to make sure their sum are kept + * in sync. + * + * @private + */ + _updateFooter: function () { + this._computeAggregates(); + this.$('tfoot').replaceWith(this._renderFooter()); + }, + /** + * Whenever we change the state of the selected rows, we need to call this + * method to keep the this.selection variable in sync, and also to recompute + * the aggregates. + * + * @private + */ + _updateSelection: function () { + const previousSelection = JSON.stringify(this.selection); + this.selection = []; + var self = this; + var $inputs = this.$('tbody .o_list_record_selector input:visible:not(:disabled)'); + var allChecked = $inputs.length > 0; + $inputs.each(function (index, input) { + if (input.checked) { + self.selection.push($(input).closest('tr').data('id')); + } else { + allChecked = false; + } + }); + this.$('thead .o_list_record_selector input').prop('checked', allChecked); + if (JSON.stringify(this.selection) !== previousSelection) { + this.trigger_up('selection_changed', { allChecked, selection: this.selection }); + } + this._updateFooter(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} record a record dataPoint on which the button applies + * @param {Object} node arch node of the button + * @param {Object} node.attrs the attrs of the button in the arch + * @param {jQueryEvent} ev + */ + _onGroupButtonClicked: function (record, node, ev) { + ev.stopPropagation(); + if (node.attrs.type === 'edit') { + this.trigger_up('group_edit_button_clicked', { + record: record, + }); + } else { + this.trigger_up('button_clicked', { + attrs: node.attrs, + record: record, + }); + } + }, + /** + * If the user presses ENTER on a group header button, we want to execute + * the button action. This is done automatically as the click handler is + * called. However, we have to stop the propagation of the event to prevent + * another handler from closing the group (see _onKeyDown). + * + * @private + * @param {jQueryEvent} ev + */ + _onGroupButtonKeydown: function (ev) { + if (ev.keyCode === $.ui.keyCode.ENTER) { + ev.stopPropagation(); + } + }, + /** + * When the user clicks on the checkbox in optional fields dropdown the + * column is added to listview and displayed + * + * @private + * @param {MouseEvent} ev + */ + _onToggleOptionalColumn: function (ev) { + var self = this; + ev.stopPropagation(); + // when the input's label is clicked, the click event is also raised on the + // input itself (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label), + // so this handler is executed twice (except if the rendering is quick enough, + // as when we render, we empty the HTML) + ev.preventDefault(); + var input = ev.currentTarget.querySelector('input'); + var fieldIndex = this.optionalColumnsEnabled.indexOf(input.name); + if (fieldIndex >= 0) { + this.optionalColumnsEnabled.splice(fieldIndex, 1); + } else { + this.optionalColumnsEnabled.push(input.name); + } + this.trigger_up('save_optional_fields', { + keyParts: this._getOptionalColumnsStorageKeyParts(), + optionalColumnsEnabled: this.optionalColumnsEnabled, + }); + this._processColumns(this.columnInvisibleFields); + this._render().then(function () { + self._onToggleOptionalColumnDropdown(ev); + }); + }, + /** + * When the user clicks on the three dots (ellipsis), toggle the optional + * fields dropdown. + * + * @private + */ + _onToggleOptionalColumnDropdown: function (ev) { + // The dropdown toggle is inside the overflow hidden container because + // the ellipsis is always in the last column, but we want the actual + // dropdown to be outside of the overflow hidden container since it + // could easily have a higher height than the table. However, separating + // the toggle and the dropdown itself is not supported by popper.js by + // default, which is why we need to toggle the dropdown manually. + ev.stopPropagation(); + this.$('.o_optional_columns .dropdown-toggle').dropdown('toggle'); + // Explicitly set left/right of the optional column dropdown as it is pushed + // inside this.$el, so we need to position it at the end of top left corner. + var position = (this.$(".table-responsive").css('overflow') === "auto" ? this.$el.width() : + this.$('table').width()); + var direction = "left"; + if (_t.database.parameters.direction === 'rtl') { + position = position - this.$('.o_optional_columns .o_optional_columns_dropdown').width(); + direction = "right"; + } + this.$('.o_optional_columns').css(direction, position); + }, + /** + * Manages the keyboard events on the list. If the list is not editable, when the user navigates to + * a cell using the keyboard, if he presses enter, enter the model represented by the line + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyDown: function (ev) { + var $cell = $(ev.currentTarget); + var $tr; + var $futureCell; + var colIndex; + if (this.state.isSample) { + return; // we disable keyboard navigation inside the table in "sample" mode + } + switch (ev.keyCode) { + case $.ui.keyCode.LEFT: + ev.preventDefault(); + $tr = $cell.closest('tr'); + $tr.closest('tbody').addClass('o_keyboard_navigation'); + if ($tr.hasClass('o_group_header') && $tr.hasClass('o_group_open')) { + this._onToggleGroup(ev); + } else { + $futureCell = $cell.prev(); + } + break; + case $.ui.keyCode.RIGHT: + ev.preventDefault(); + $tr = $cell.closest('tr'); + $tr.closest('tbody').addClass('o_keyboard_navigation'); + if ($tr.hasClass('o_group_header') && !$tr.hasClass('o_group_open')) { + this._onToggleGroup(ev); + } else { + $futureCell = $cell.next(); + } + break; + case $.ui.keyCode.UP: + ev.preventDefault(); + $cell.closest('tbody').addClass('o_keyboard_navigation'); + colIndex = this.currentColIndex || $cell.index(); + $futureCell = this._findConnectedCell($cell, 'prev', colIndex); + if (!$futureCell) { + this.trigger_up('navigation_move', { direction: 'up' }); + } + break; + case $.ui.keyCode.DOWN: + ev.preventDefault(); + $cell.closest('tbody').addClass('o_keyboard_navigation'); + colIndex = this.currentColIndex || $cell.index(); + $futureCell = this._findConnectedCell($cell, 'next', colIndex); + break; + case $.ui.keyCode.ENTER: + ev.preventDefault(); + $tr = $cell.closest('tr'); + if ($tr.hasClass('o_group_header')) { + this._onToggleGroup(ev); + } else { + var id = $tr.data('id'); + if (id) { + this.trigger_up('open_record', { id: id, target: ev.target }); + } + } + break; + } + if ($futureCell) { + // If the cell contains activable elements, focus them instead (except if it is in a + // group header, in which case we want to activate the whole header, so that we can + // open/close it with RIGHT/LEFT keystrokes) + var isInGroupHeader = $futureCell.closest('tr').hasClass('o_group_header'); + var $activables = !isInGroupHeader && $futureCell.find(':focusable'); + if ($activables.length) { + $activables[0].focus(); + } else { + $futureCell.focus(); + } + } + }, + /** + * @private + */ + _onMouseDown: function () { + $('.o_keyboard_navigation').removeClass('o_keyboard_navigation'); + }, + /** + * @private + * @param {OwlEvent} ev + * @param {Object} group + */ + _onPagerChanged: async function (ev, group) { + ev.stopPropagation(); + const { currentMinimum, limit } = ev.detail; + this.trigger_up('load', { + id: group.id, + limit: limit, + offset: currentMinimum - 1, + on_success: reloadedGroup => { + Object.assign(group, reloadedGroup); + this._render(); + }, + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onRowClicked: function (ev) { + // The special_click property explicitely allow events to bubble all + // the way up to bootstrap's level rather than being stopped earlier. + if (!ev.target.closest('.o_list_record_selector') && !$(ev.target).prop('special_click')) { + var id = $(ev.currentTarget).data('id'); + if (id) { + this.trigger_up('open_record', { id: id, target: ev.target }); + } + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onSelectRecord: function (ev) { + ev.stopPropagation(); + this._updateSelection(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onSortColumn: function (ev) { + var name = $(ev.currentTarget).data('name'); + this.trigger_up('toggle_column_order', { id: this.state.id, name: name }); + }, + /** + * When the user clicks on the whole record selector cell, we want to toggle + * the checkbox, to make record selection smooth. + * + * @private + * @param {MouseEvent} ev + */ + _onToggleCheckbox: function (ev) { + const $recordSelector = $(ev.target).find('input[type=checkbox]:not(":disabled")'); + $recordSelector.prop('checked', !$recordSelector.prop("checked")); + $recordSelector.change(); // s.t. th and td checkbox cases are handled by their own handler + }, + /** + * @private + * @param {DOMEvent} ev + */ + _onToggleGroup: function (ev) { + ev.preventDefault(); + var group = $(ev.currentTarget).closest('tr').data('group'); + if (group.count) { + this.trigger_up('toggle_group', { + group: group, + onSuccess: () => { + this._updateSelection(); + // Refocus the header after re-render unless the user + // already focused something else by now + if (document.activeElement.tagName === 'BODY') { + var groupHeaders = $('tr.o_group_header:data("group")'); + var header = groupHeaders.filter(function () { + return $(this).data('group').id === group.id; + }); + header.find('.o_group_name').focus(); + } + }, + }); + } + }, + /** + * When the user clicks on the row selection checkbox in the header, we + * need to update the checkbox of the row selection checkboxes in the body. + * + * @private + * @param {MouseEvent} ev + */ + _onToggleSelection: function (ev) { + var checked = $(ev.currentTarget).prop('checked') || false; + this.$('tbody .o_list_record_selector input:not(":disabled")').prop('checked', checked); + this._updateSelection(); + }, +}); + +return ListRenderer; +}); diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js new file mode 100644 index 00000000..5c36d01e --- /dev/null +++ b/addons/web/static/src/js/views/list/list_view.js @@ -0,0 +1,137 @@ +odoo.define('web.ListView', function (require) { +"use strict"; + +/** + * The list view is one of the core and most basic view: it is used to look at + * a list of records in a table. + * + * Note that a list view is not instantiated to display a one2many field in a + * form view. Only a ListRenderer is used in that case. + */ + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var ListModel = require('web.ListModel'); +var ListRenderer = require('web.ListRenderer'); +var ListController = require('web.ListController'); +var pyUtils = require('web.py_utils'); + +var _lt = core._lt; + +var ListView = BasicView.extend({ + accesskey: "l", + display_name: _lt('List'), + icon: 'fa-list-ul', + config: _.extend({}, BasicView.prototype.config, { + Model: ListModel, + Renderer: ListRenderer, + Controller: ListController, + }), + viewType: 'list', + /** + * @override + * + * @param {Object} viewInfo + * @param {Object} params + * @param {boolean} params.hasActionMenus + * @param {boolean} [params.hasSelectors=true] + */ + init: function (viewInfo, params) { + var self = this; + this._super.apply(this, arguments); + var selectedRecords = []; // there is no selected records by default + + var pyevalContext = py.dict.fromJSON(_.pick(params.context, function(value, key, object) {return !_.isUndefined(value)}) || {}); + var expandGroups = !!JSON.parse(pyUtils.py_eval(this.arch.attrs.expand || "0", {'context': pyevalContext})); + + this.groupbys = {}; + this.headerButtons = []; + this.arch.children.forEach(function (child) { + if (child.tag === 'groupby') { + self._extractGroup(child); + } + if (child.tag === 'header') { + self._extractHeaderButtons(child); + } + }); + + let editable = false; + if ((!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) && !params.readonly) { + editable = this.arch.attrs.editable; + } + + this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true; + this.controllerParams.editable = editable; + this.controllerParams.hasActionMenus = params.hasActionMenus; + this.controllerParams.headerButtons = this.headerButtons; + this.controllerParams.toolbarActions = viewInfo.toolbar; + this.controllerParams.mode = 'readonly'; + this.controllerParams.selectedRecords = selectedRecords; + + this.rendererParams.arch = this.arch; + this.rendererParams.groupbys = this.groupbys; + this.rendererParams.hasSelectors = + 'hasSelectors' in params ? params.hasSelectors : true; + this.rendererParams.editable = editable; + this.rendererParams.selectedRecords = selectedRecords; + this.rendererParams.addCreateLine = false; + this.rendererParams.addCreateLineInGroups = editable && this.controllerParams.activeActions.create; + this.rendererParams.isMultiEditable = this.arch.attrs.multi_edit && !!JSON.parse(this.arch.attrs.multi_edit); + + this.modelParams.groupbys = this.groupbys; + + this.loadParams.limit = this.loadParams.limit || 80; + this.loadParams.openGroupByDefault = expandGroups; + this.loadParams.type = 'list'; + var groupsLimit = parseInt(this.arch.attrs.groups_limit, 10); + this.loadParams.groupsLimit = groupsLimit || (expandGroups ? 10 : 80); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} node + */ + _extractGroup: function (node) { + var innerView = this.fields[node.attrs.name].views.groupby; + this.groupbys[node.attrs.name] = this._processFieldsView(innerView, 'groupby'); + }, + /** + * Extracts action buttons definitions from the <header> node of the list + * view definition + * + * @private + * @param {Object} node + */ + _extractHeaderButtons(node) { + node.children.forEach(child => { + if (child.tag === 'button' && !child.attrs.modifiers.invisible) { + this.headerButtons.push(child); + } + }); + }, + /** + * @override + */ + _extractParamsFromAction: function (action) { + var params = this._super.apply(this, arguments); + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + params.hasActionMenus = !inDialog && !inline; + return params; + }, + /** + * @override + */ + _updateMVCParams: function () { + this._super.apply(this, arguments); + this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; + }, +}); + +return ListView; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js new file mode 100644 index 00000000..11387781 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -0,0 +1,325 @@ +odoo.define('web.PivotController', function (require) { + "use strict"; + /** + * Odoo Pivot Table Controller + * + * This class is the Controller for the pivot table view. It has to coordinate + * the actions coming from the search view (through the update method), from + * the renderer, from the model, and from the control panel. + * + * It can display action buttons in the control panel, to select a different + * measure, or to perform some other actions such as download/expand/flip the + * view. + */ + + const AbstractController = require('web.AbstractController'); + const core = require('web.core'); + const framework = require('web.framework'); + const session = require('web.session'); + + const _t = core._t; + const QWeb = core.qweb; + + const PivotController = AbstractController.extend({ + custom_events: Object.assign({}, AbstractController.prototype.custom_events, { + closed_header_click: '_onClosedHeaderClicked', + open_view: '_onOpenView', + opened_header_click: '_onOpenedHeaderClicked', + sort_rows: '_onSortRows', + groupby_menu_selection: '_onGroupByMenuSelection', + }), + + /** + * @override + * @param parent + * @param model + * @param renderer + * @param {Object} params + * @param {Object} params.groupableFields a map from field names to field + * props + */ + init: function (parent, model, renderer, params) { + this._super(...arguments); + + this.disableLinking = params.disableLinking; + this.measures = params.measures; + this.title = params.title; + // views to use in the action triggered when a data cell is clicked + this.views = params.views; + this.groupSelected = null; + }, + /** + * @override + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off(); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the current measures and groupbys, so we can restore the view + * when we save the current state in the search view, or when we add it to + * the dashboard. + * + * @override method from AbstractController + * @returns {Object} + */ + getOwnedQueryParams: function () { + const state = this.model.get({ raw: true }); + return { + context: { + pivot_measures: state.measures, + pivot_column_groupby: state.colGroupBys, + pivot_row_groupby: state.rowGroupBys, + } + }; + }, + /** + * Render the buttons according to the PivotView.buttons template and + * add listeners on it. + * Set this.$buttons with the produced jQuery element + * + * @override + * @param {jQuery} [$node] a jQuery node where the rendered buttons should + * be inserted. $node may be undefined, in which case the PivotView + * does nothing + */ + renderButtons: function ($node) { + const context = this._getRenderButtonContext(); + this.$buttons = $(QWeb.render('PivotView.buttons', context)); + this.$buttons.click(this._onButtonClick.bind(this)); + this.$buttons.find('button').tooltip(); + if ($node) { + this.$buttons.appendTo($node); + } + }, + /** + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + const state = this.model.get({ raw: true }); + Object.entries(this.measures).forEach(elt => { + const name = elt[0]; + const isSelected = state.measures.includes(name); + this.$buttons.find('.dropdown-item[data-field="' + name + '"]') + .toggleClass('selected', isSelected); + + }); + const noDataDisplayed = !state.hasData || !state.measures.length; + this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed); + this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed); + this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Export the current pivot table data in a xls file. For this, we have to + * serialize the current state, then call the server /web/pivot/export_xlsx. + * Force a reload before exporting to ensure to export up-to-date data. + * + * @private + */ + _downloadTable: function () { + if (this.model.getTableWidth() > 16384) { + this.call('crash_manager', 'show_message', _t("For Excel compatibility, data cannot be exported if there are more than 16384 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + framework.unblockUI(); + return; + } + const table = this.model.exportData(); + table.title = this.title; + session.get_file({ + url: '/web/pivot/export_xlsx', + data: { data: JSON.stringify(table) }, + complete: framework.unblockUI, + error: (error) => this.call('crash_manager', 'rpc_error', error), + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This handler is called when the user clicked on a button in the control + * panel. We then have to react properly: it can either be a change in the + * current measures, or a request to flip/expand/download data. + * + * @private + * @param {MouseEvent} ev + */ + _onButtonClick: async function (ev) { + const $target = $(ev.target); + if ($target.hasClass('o_pivot_flip_button')) { + this.model.flip(); + this.update({}, { reload: false }); + } + if ($target.hasClass('o_pivot_expand_button')) { + await this.model.expandAll(); + this.update({}, { reload: false }); + } + if (ev.target.closest('.o_pivot_measures_list')) { + ev.preventDefault(); + ev.stopPropagation(); + const field = ev.target.dataset.field; + if (field) { + this.update({ measure: field }); + } + } + if ($target.hasClass('o_pivot_download')) { + this._downloadTable(); + } + + await this._addIncludedButtons(ev); + }, + + /** + * Declared to be overwritten in includes of pivot controller + * + * @param {MouseEvent} ev + * @returns {Promise<void>} + * @private + */ + _addIncludedButtons: async function(ev) {}, + /** + * Get the context of rendering of the buttons + * + * @returns {Object} + * @private + */ + _getRenderButtonContext: function () { + return { + measures: Object.entries(this.measures) + .filter(x => x[0] !== '__count') + .sort((a, b) => a[1].string.toLowerCase() > b[1].string.toLowerCase() ? 1 : -1), + }; + }, + /** + * + * @private + * @param {OdooEvent} ev + */ + _onCloseGroup: function (ev) { + this.model.closeGroup(ev.data.groupId, ev.data.type); + this.update({}, { reload: false }); + }, + /** + * @param {CustomEvent} ev + * @private + * */ + _onOpenedHeaderClicked: function (ev) { + this.model.closeGroup(ev.data.cell.groupId, ev.data.type); + this.update({}, { reload: false }); + }, + /** + * @param {CustomEvent} ev + * @private + * */ + _onClosedHeaderClicked: async function (ev) { + const cell = ev.data.cell; + const groupId = cell.groupId; + const type = ev.data.type; + + const group = { + rowValues: groupId[0], + colValues: groupId[1], + type: type + }; + + const state = this.model.get({ raw: true }); + const groupValues = type === 'row' ? groupId[0] : groupId[1]; + const groupBys = type === 'row' ? + state.rowGroupBys : + state.colGroupBys; + this.selectedGroup = group; + if (groupValues.length < groupBys.length) { + const groupBy = groupBys[groupValues.length]; + await this.model.expandGroup(this.selectedGroup, groupBy); + this.update({}, { reload: false }); + } + }, + /** + * This handler is called when the user selects a groupby in the dropdown menu. + * + * @private + * @param {CustomEvent} ev + */ + _onGroupByMenuSelection: async function (ev) { + ev.stopPropagation(); + + let groupBy = ev.data.field.name; + const interval = ev.data.interval; + if (interval) { + groupBy = groupBy + ':' + interval; + } + this.model.addGroupBy(groupBy, this.selectedGroup.type); + await this.model.expandGroup(this.selectedGroup, groupBy); + this.update({}, { reload: false }); + }, + /** + * @private + * @param {CustomEvent} ev + */ + _onOpenView: function (ev) { + ev.stopPropagation(); + const cell = ev.data; + if (cell.value === undefined || this.disableLinking) { + return; + } + + const context = Object.assign({}, this.model.data.context); + Object.keys(context).forEach(x => { + if (x === 'group_by' || x.startsWith('search_default_')) { + delete context[x]; + } + }); + + const group = { + rowValues: cell.groupId[0], + colValues: cell.groupId[1], + originIndex: cell.originIndexes[0] + }; + + const domain = this.model._getGroupDomain(group); + + this.do_action({ + type: 'ir.actions.act_window', + name: this.title, + res_model: this.modelName, + views: this.views, + view_mode: 'list', + target: 'current', + context: context, + domain: domain, + }); + }, + /** + * @private + * @param {CustomEvent} ev + */ + _onSortRows: function (ev) { + this.model.sortRows({ + groupId: ev.data.groupId, + measure: ev.data.measure, + order: (ev.data.order || 'desc') === 'asc' ? 'desc' : 'asc', + originIndexes: ev.data.originIndexes, + }); + this.update({}, { reload: false }); + }, + }); + + return PivotController; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js new file mode 100644 index 00000000..b4af34e0 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -0,0 +1,1569 @@ +odoo.define('web.PivotModel', function (require) { +"use strict"; + +/** + * Pivot Model + * + * The pivot model keeps an in-memory representation of the pivot table that is + * displayed on the screen. The exact layout of this representation is not so + * simple, because a pivot table is at its core a 2-dimensional object, but + * with a 'tree' component: some rows/cols can be expanded so we zoom into the + * structure. + * + * However, we need to be able to manipulate the data in a somewhat efficient + * way, and to transform it into a list of lines to be displayed by the renderer. + * + * Basicaly the pivot table presents aggregated values for various groups of records + * in one domain. If a comparison is asked for, two domains are considered. + * + * Let us consider a simple example and let us fix the vocabulary (let us suppose we are in June 2020): + * ___________________________________________________________________________________________________________________________________________ + * | | Total | + * | |_____________________________________________________________________________________________________________________| + * | | Sale Team 1 | Sale Team 2 | | + * | |_______________________________________|______________________________________|______________________________________| + * | | Sales total | Sales total | Sales total | + * | |_______________________________________|______________________________________|______________________________________| + * | | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * | Total | 85 | 110 | 29.4% | 40 | 30 | -25% | 125 | 140 | 12% | + * | Europe | 25 | 35 | 40% | 40 | 30 | -25% | 65 | 65 | 0% | + * | Brussels | 0 | 15 | 100% | 30 | 30 | 0% | 30 | 45 | 50% | + * | Paris | 25 | 20 | -20% | 10 | 0 | -100% | 35 | 20 | -42.8% | + * | North America | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * | Washington | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * + * + * META DATA: + * + * In the above pivot table, the records have been grouped using the fields + * + * continent_id, city_id + * + * for rows and + * + * sale_team_id + * + * for columns. + * + * The measure is the field 'sales_total'. + * + * Two domains are considered: 'May 2020' and 'June 2020'. + * + * In the model, + * + * - rowGroupBys is the list [continent_id, city_id] + * - colGroupBys is the list [sale_team_id] + * - measures is the list [sales_total] + * - domains is the list [d1, d2] with d1 and d2 domain expressions + * for say sale_date in May 2020 and June 2020, for instance + * d1 = [['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31]] + * - origins is the list ['May 2020', 'June 2020'] + * + * DATA: + * + * Recall that a group is constituted by records (in a given domain) + * that have the same (raw) values for a list of fields. + * Thus the group itself is identified by this list and the domain. + * In comparison mode, the same group (forgetting the domain part or 'originIndex') + * can be eventually found in the two domains. + * This defines the way in which the groups are identified or not. + * + * In the above table, (forgetting the domain) the following groups are found: + * + * the 'row groups' + * - Total + * - Europe + * - America + * - Europe, Brussels + * - Europe, Paris + * - America, Washington + * + * the 'col groups' + * + * - Total + * - Sale Team 1 + * - Sale Team 2 + * + * and all non trivial combinations of row groups and col groups + * + * - Europe, Sale Team 1 + * - Europe, Brussels, Sale Team 2 + * - America, Washington, Sale Team 1 + * - ... + * + * The list of fields is created from the concatenation of two lists of fields, the first in + * + * [], [f1], [f1, f2], ... [f1, f2, ..., fn] for [f1, f2, ..., fn] the full list of groupbys + * (called rowGroupBys) used to create row groups + * + * In the example: [], [continent_id], [continent_id, city_id]. + * + * and the second in + * [], [g1], [g1, g2], ... [g1, g2, ..., gm] for [g1, g2, ..., gm] the full list of groupbys + * (called colGroupBys) used to create col groups. + * + * In the example: [], [sale_team_id]. + * + * Thus there are (n+1)*(m+1) lists of fields possible. + * + * In the example: 6 lists possible, namely [], + * [continent_id], [sale_team_id], + * [continent_id, sale_team_id], [continent_id, city_id], + * [continent_id, city_id, sale_team_id] + * + * A given list is thus of the form [f1,..., fi, g1,..., gj] or better [[f1,...,fi], [g1,...,gj]] + * + * For each list of fields possible and each domain considered, one read_group is done + * and gives results of the form (an exception for list []) + * + * g = { + * f1: v1, ..., fi: vi, + * g1: w1, ..., gj: wj, + * m1: x1, ..., mk: xk, + * __count: c, + * __domain: d + * } + * + * where v1,...,vi,w1,...,Wj are 'values' for the corresponding fields and + * m1,...,mk are the fields selected as measures. + * + * For example, g = { + * continent_id: [1, 'Europe'] + * sale_team_id: [1, 'Sale Team 1'] + * sales_count: 25, + * __count: 4 + * __domain: [ + * ['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31], + * ['continent_id', '=', 1], + * ['sale_team_id', '=', 1] + * ] + * } + * + * Thus the above group g is fully determined by [[v1,...,vi], [w1,...,wj]] and the base domain + * or the corresponding 'originIndex'. + * + * When j=0, g corresponds to a row group (or also row header) and is of the form [[v1,...,vi], []] or more simply [v1,...vi] + * (not forgetting the list [v1,...vi] comes from left). + * When i=0, g corresponds to a col group (or col header) and is of the form [[], [w1,...,wj]] or more simply [w1,...,wj]. + * + * A generic group g as above [[v1,...,vi], [w1,...,wj]] corresponds to the two headers [[v1,...,vi], []] + * and [[], [w1,...,wj]]. + * + * Here is a description of the data structure manipulated by the pivot model. + * + * Five objects contain all the data from the read_groups + * + * - rowGroupTree: contains information on row headers + * the nodes correspond to the groups of the form [[v1,...,vi], []] + * The root is [[], []]. + * A node [[v1,...,vl], []] has as direct children the nodes of the form [[v1,...,vl,v], []], + * this means that a direct child is obtained by grouping records using the single field fi+1 + * + * The structure at each level is of the form + * + * { + * root: { + * values: [v1,...,vl], + * labels: [la1,...,lal] + * }, + * directSubTrees: { + * v => { + * root: { + * values: [v1,...,vl,v] + * labels: [label1,...,labell,label] + * }, + * directSubTrees: {...} + * }, + * v' => {...}, + * ... + * } + * } + * + * (directSubTrees is a Map instance) + * + * In the example, the rowGroupTree is: + * + * { + * root: { + * values: [], + * labels: [] + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1], + * labels: ['Europe'], + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1, 1], + * labels: ['Europe', 'Brussels'], + * }, + * directSubTrees: new Map(), + * }, + * 2 => { + * root: { + * values: [1, 2], + * labels: ['Europe', 'Paris'], + * }, + * directSubTrees: new Map(), + * }, + * }, + * }, + * 2 => { + * root: { + * values: [2], + * labels: ['America'], + * }, + * directSubTrees: { + * 3 => { + * root: { + * values: [2, 3], + * labels: ['America', 'Washington'], + * } + * directSubTrees: new Map(), + * }, + * }, + * }, + * }, + * } + * + * - colGroupTree: contains information on col headers + * The same as above with right instead of left + * + * - measurements: contains information on measure values for all the groups + * + * the object keys are of the form JSON.stringify([[v1,...,vi], [w1,...,wj]]) + * and values are arrays of length equal to number of origins containing objects of the form + * {m1: x1,...,mk: xk} + * The structure looks like + * + * { + * JSON.stringify([[], []]): [{m1: x1,...,mk: xk}, {m1: x1',...,mk: xk'},...] + * .... + * JSON.stringify([[v1,...,vi], [w1,...,wj]]): [{m1: y1',...,mk: yk'}, {m1: y1',...,mk: yk'},...], + * .... + * JSON.stringify([[v1,...,vn], [w1,...,wm]]): [{m1: z1',...,mk: zk'}, {m1: z1',...,mk: zk'},...], + * } + * Thus the structure contains all information for all groups and all origins on measure values. + * + * + * this.measurments["[[], []]"][0]['foo'] gives the value of the measure 'foo' for the group 'Total' and the + * first domain (origin). + * + * In the example: + * { + * "[[], []]": [{'sales_total': 125}, {'sales_total': 140}] (total/total) + * ... + * "[[1, 2], [2]]": [{'sales_total': 10}, {'sales_total': 0}] (Europe/Paris/Sale Team 2) + * ... + * } + * + * - counts: contains information on the number of records in each groups + * The structure is similar to the above but the arrays contains numbers (counts) + * - groupDomains: + * The structure is similar to the above but the arrays contains domains + * + * With this light data structures, all manipulation done by the model are eased and redundancies are limited. + * Each time a rendering or an export of the data has to be done, the pivot table is generated by the _getTable function. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); +const Domain = require('web.Domain'); +var mathUtils = require('web.mathUtils'); +var session = require('web.session'); + + +var _t = core._t; +var cartesian = mathUtils.cartesian; +var computeVariation = dataComparisonUtils.computeVariation; +var sections = mathUtils.sections; + +var PivotModel = AbstractModel.extend({ + /** + * @override + * @param {Object} params + */ + init: function () { + this._super.apply(this, arguments); + this.numbering = {}; + this.data = null; + this._loadDataDropPrevious = new concurrency.DropPrevious(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a groupBy to rowGroupBys or colGroupBys according to provided type. + * + * @param {string} groupBy + * @param {'row'|'col'} type + */ + addGroupBy: function (groupBy, type) { + if (type === 'row') { + this.data.expandedRowGroupBys.push(groupBy); + } else { + this.data.expandedColGroupBys.push(groupBy); + } + }, + /** + * Close the group with id given by groupId. A type must be specified + * in case groupId is [[], []] (the id of the group 'Total') because this + * group is present in both colGroupTree and rowGroupTree. + * + * @param {Array[]} groupId + * @param {'row'|'col'} type + */ + closeGroup: function (groupId, type) { + var groupBys; + var expandedGroupBys; + let keyPart; + var group; + var tree; + if (type === 'row') { + groupBys = this.data.rowGroupBys; + expandedGroupBys = this.data.expandedRowGroupBys; + tree = this.rowGroupTree; + group = this._findGroup(this.rowGroupTree, groupId[0]); + keyPart = 0; + } else { + groupBys = this.data.colGroupBys; + expandedGroupBys = this.data.expandedColGroupBys; + tree = this.colGroupTree; + group = this._findGroup(this.colGroupTree, groupId[1]); + keyPart = 1; + } + + const groupIdPart = groupId[keyPart]; + const range = groupIdPart.map((_, index) => index); + function keep(key) { + const idPart = JSON.parse(key)[keyPart]; + return range.some(index => groupIdPart[index] !== idPart[index]) || + idPart.length === groupIdPart.length; + } + function omitKeys(object) { + const newObject = {}; + for (const key in object) { + if (keep(key)) { + newObject[key] = object[key]; + } + } + return newObject; + } + this.measurements = omitKeys(this.measurements); + this.counts = omitKeys(this.counts); + this.groupDomains = omitKeys(this.groupDomains); + + group.directSubTrees.clear(); + delete group.sortedKeys; + var newGroupBysLength = this._getTreeHeight(tree) - 1; + if (newGroupBysLength <= groupBys.length) { + expandedGroupBys.splice(0); + groupBys.splice(newGroupBysLength); + } else { + expandedGroupBys.splice(newGroupBysLength - groupBys.length); + } + }, + /** + * Reload the view with the current rowGroupBys and colGroupBys + * This is the easiest way to expand all the groups that are not expanded + * + * @returns {Promise} + */ + expandAll: function () { + return this._loadData(); + }, + /** + * Expand a group by using groupBy to split it. + * + * @param {Object} group + * @param {string} groupBy + * @returns {Promise} + */ + expandGroup: async function (group, groupBy) { + var leftDivisors; + var rightDivisors; + + if (group.type === 'row') { + leftDivisors = [[groupBy]]; + rightDivisors = sections(this._getGroupBys().colGroupBys); + } else { + leftDivisors = sections(this._getGroupBys().rowGroupBys); + rightDivisors = [[groupBy]]; + } + var divisors = cartesian(leftDivisors, rightDivisors); + + delete group.type; + return this._subdivideGroup(group, divisors); + }, + /** + * Export model data in a form suitable for an easy encoding of the pivot + * table in excell. + * + * @returns {Object} + */ + exportData: function () { + var measureCount = this.data.measures.length; + var originCount = this.data.origins.length; + + var table = this._getTable(); + + // process headers + var headers = table.headers; + var colGroupHeaderRows; + var measureRow = []; + var originRow = []; + + function processHeader(header) { + var inTotalColumn = header.groupId[1].length === 0; + return { + title: header.title, + width: header.width, + height: header.height, + is_bold: !!header.measure && inTotalColumn + }; + } + + if (originCount > 1) { + colGroupHeaderRows = headers.slice(0, headers.length - 2); + measureRow = headers[headers.length - 2].map(processHeader); + originRow = headers[headers.length - 1].map(processHeader); + } else { + colGroupHeaderRows = headers.slice(0, headers.length - 1); + measureRow = headers[headers.length - 1].map(processHeader); + } + + // remove the empty headers on left side + colGroupHeaderRows[0].splice(0, 1); + + colGroupHeaderRows = colGroupHeaderRows.map(function (headerRow) { + return headerRow.map(processHeader); + }); + + // process rows + var tableRows = table.rows.map(function (row) { + return { + title: row.title, + indent: row.indent, + values: row.subGroupMeasurements.map(function (measurement) { + var value = measurement.value; + if (value === undefined) { + value = ""; + } else if (measurement.originIndexes.length > 1) { + // in that case the value is a variation and a + // number between 0 and 1 + value = value * 100; + } + return { + is_bold: measurement.isBold, + value: value, + }; + }), + }; + }); + + return { + col_group_headers: colGroupHeaderRows, + measure_headers: measureRow, + origin_headers: originRow, + rows: tableRows, + measure_count: measureCount, + origin_count: originCount, + }; + }, + /** + * Swap the pivot columns and the rows. It is a synchronous operation. + */ + flip: function () { + // swap the data: the main column and the main row + var temp = this.rowGroupTree; + this.rowGroupTree = this.colGroupTree; + this.colGroupTree = temp; + + // we need to update the record metadata: (expanded) row and col groupBys + temp = this.data.rowGroupBys; + this.data.groupedBy = this.data.colGroupBys; + this.data.rowGroupBys = this.data.colGroupBys; + this.data.colGroupBys = temp; + temp = this.data.expandedColGroupBys; + this.data.expandedColGroupBys = this.data.expandedRowGroupBys; + this.data.expandedRowGroupBys = temp; + + function twistKey(key) { + return JSON.stringify(JSON.parse(key).reverse()); + } + + function twist(object) { + var newObject = {}; + Object.keys(object).forEach(function (key) { + var value = object[key]; + newObject[twistKey(key)] = value; + }); + return newObject; + } + + this.measurements = twist(this.measurements); + this.counts = twist(this.counts); + this.groupDomains = twist(this.groupDomains); + }, + /** + * @override + * + * @param {Object} [options] + * @param {boolean} [options.raw=false] + * @returns {Object} + */ + __get: function (options) { + options = options || {}; + var raw = options.raw || false; + var groupBys = this._getGroupBys(); + var state = { + colGroupBys: groupBys.colGroupBys, + context: this.data.context, + domain: this.data.domain, + fields: this.fields, + hasData: this._hasData(), + isSample: this.isSampleModel, + measures: this.data.measures, + origins: this.data.origins, + rowGroupBys: groupBys.rowGroupBys, + selectionGroupBys: this._getSelectionGroupBy(groupBys), + modelName: this.modelName + }; + if (!raw && state.hasData) { + state.table = this._getTable(); + state.tree = this.rowGroupTree; + } + return state; + }, + /** + * Returns the total number of columns of the pivot table. + * + * @returns {integer} + */ + getTableWidth: function () { + var leafCounts = this._getLeafCounts(this.colGroupTree); + return leafCounts[JSON.stringify(this.colGroupTree.root.values)] + 2; + }, + /** + * @override + * + * @param {Object} params + * @param {boolean} [params.compare=false] + * @param {Object} params.context + * @param {Object} params.fields + * @param {string[]} [params.groupedBy] + * @param {string[]} params.colGroupBys + * @param {Array[]} params.domain + * @param {string[]} params.measures + * @param {string[]} params.rowGroupBys + * @param {string} [params.default_order] + * @param {string} params.modelName + * @param {Object[]} params.groupableFields + * @param {Object} params.timeRanges + * @returns {Promise} + */ + __load: function (params) { + this.initialDomain = params.domain; + this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys; + this.defaultGroupedBy = params.groupedBy; + + this.fields = params.fields; + this.modelName = params.modelName; + this.groupableFields = params.groupableFields; + const measures = this._processMeasures(params.context.pivot_measures) || + params.measures.map(m => m); + this.data = { + expandedRowGroupBys: [], + expandedColGroupBys: [], + domain: this.initialDomain, + context: _.extend({}, session.user_context, params.context), + groupedBy: params.context.pivot_row_groupby || params.groupedBy, + colGroupBys: params.context.pivot_column_groupby || params.colGroupBys, + measures, + timeRanges: params.timeRanges, + }; + this._computeDerivedParams(); + + this.data.groupedBy = this.data.groupedBy.slice(); + this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice(); + + var defaultOrder = params.default_order && params.default_order.split(' '); + if (defaultOrder) { + this.data.sortedColumn = { + groupId: [[], []], + measure: defaultOrder[0], + order: defaultOrder[1] ? defaultOrder [1] : 'asc', + }; + } + return this._loadData(); + }, + /** + * @override + * + * @param {any} handle this parameter is ignored + * @param {Object} params + * @param {boolean} [params.compare=false] + * @param {Object} params.context + * @param {string[]} [params.groupedBy] + * @param {Array[]} params.domain + * @param {string[]} params.groupBy + * @param {string[]} params.measures + * @param {Object} [params.timeRanges] + * @returns {Promise} + */ + __reload: function (handle, params) { + var self = this; + var oldColGroupBys = this.data.colGroupBys; + var oldRowGroupBys = this.data.rowGroupBys; + if ('context' in params) { + this.data.context = params.context; + this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; + this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; + this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; + this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy; + } + if ('domain' in params) { + this.data.domain = params.domain; + this.initialDomain = params.domain; + } else { + this.data.domain = this.initialDomain; + } + if ('groupBy' in params) { + this.data.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy; + } + if ('timeRanges' in params) { + this.data.timeRanges = params.timeRanges; + } + this._computeDerivedParams(); + + this.data.groupedBy = this.data.groupedBy.slice(); + this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice(); + + if (!_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { + this.data.expandedRowGroupBys = []; + } + if (!_.isEqual(oldColGroupBys, self.data.colGroupBys)) { + this.data.expandedColGroupBys = []; + } + + if ('measure' in params) { + return this._toggleMeasure(params.measure); + } + + if (!this._hasData()) { + return this._loadData(); + } + + var oldRowGroupTree = this.rowGroupTree; + var oldColGroupTree = this.colGroupTree; + return this._loadData().then(function () { + if (_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { + self._pruneTree(self.rowGroupTree, oldRowGroupTree); + } + if (_.isEqual(oldColGroupBys, self.data.colGroupBys)) { + self._pruneTree(self.colGroupTree, oldColGroupTree); + } + }); + }, + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * @param {Object} sortedColumn + * @param {number[]} sortedColumn.groupId + */ + sortRows: function (sortedColumn) { + var self = this; + var colGroupValues = sortedColumn.groupId[1]; + sortedColumn.originIndexes = sortedColumn.originIndexes || [0]; + this.data.sortedColumn = sortedColumn; + + var sortFunction = function (tree) { + return function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + var groupIntersectionId = [subTree.root.values, colGroupValues]; + var value = self._getCellValue( + groupIntersectionId, + sortedColumn.measure, + sortedColumn.originIndexes + ) || 0; + return sortedColumn.order === 'asc' ? value : -value; + }; + }; + + this._sortTree(sortFunction, this.rowGroupTree); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add labels/values in the provided groupTree. A new leaf is created in + * the groupTree with a root object corresponding to the group with given + * labels/values. + * + * @private + * @param {Object} groupTree, either this.rowGroupTree or this.colGroupTree + * @param {string[]} labels + * @param {Array} values + */ + _addGroup: function (groupTree, labels, values) { + var tree = groupTree; + // we assume here that the group with value value.slice(value.length - 2) has already been added. + values.slice(0, values.length - 1).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + tree.directSubTrees.set(values[values.length - 1], { + root: { + labels: labels, + values: values, + }, + directSubTrees: new Map(), + }); + }, + /** + * Compute what should be used as rowGroupBys by the pivot view + * + * @private + * @returns {string[]} + */ + _computeRowGroupBys: function () { + return !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys; + }, + /** + * Find a group with given values in the provided groupTree, either + * this.rowGrouptree or this.colGroupTree. + * + * @private + * @param {Object} groupTree + * @param {Array} values + * @returns {Object} + */ + _findGroup: function (groupTree, values) { + var tree = groupTree; + values.slice(0, values.length).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + return tree; + }, + /** + * In case originIndex is an array of length 1, thus a single origin + * index, returns the given measure for a group determined by the id + * groupId and the origin index. + * If originIndexes is an array of length 2, we compute the variation + * ot the measure values for the groups determined by groupId and the + * different origin indexes. + * + * @private + * @param {Array[]} groupId + * @param {string} measure + * @param {number[]} originIndexes + * @returns {number} + */ + _getCellValue: function (groupId, measure, originIndexes) { + var self = this; + var key = JSON.stringify(groupId); + if (!self.measurements[key]) { + return; + } + var values = originIndexes.map(function (originIndex) { + return self.measurements[key][originIndex][measure]; + }); + if (originIndexes.length > 1) { + return computeVariation(values[1], values[0]); + } else { + return values[0]; + } + }, + /** + * Returns the rowGroupBys and colGroupBys arrays that + * are actually used by the pivot view internally + * (for read_group or other purpose) + * + * @private + * @returns {Object} with keys colGroupBys and rowGroupBys + */ + _getGroupBys: function () { + return { + colGroupBys: this.data.colGroupBys.concat(this.data.expandedColGroupBys), + rowGroupBys: this.data.rowGroupBys.concat(this.data.expandedRowGroupBys), + }; + }, + /** + * Returns a domain representation of a group + * + * @private + * @param {Object} group + * @param {Array} group.colValues + * @param {Array} group.rowValues + * @param {number} group.originIndex + * @returns {Array[]} + */ + _getGroupDomain: function (group) { + var key = JSON.stringify([group.rowValues, group.colValues]); + return this.groupDomains[key][group.originIndex]; + }, + /** + * Returns the group sanitized labels. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {string[]} + */ + _getGroupLabels: function (group, groupBys) { + var self = this; + return groupBys.map(function (groupBy) { + return self._sanitizeLabel(group[groupBy], groupBy); + }); + }, + /** + * Returns a promise that returns the annotated read_group results + * corresponding to a partition of the given group obtained using the given + * rowGroupBy and colGroupBy. + * + * @private + * @param {Object} group + * @param {string[]} rowGroupBy + * @param {string[]} colGroupBy + * @returns {Promise} + */ + _getGroupSubdivision: function (group, rowGroupBy, colGroupBy) { + var groupDomain = this._getGroupDomain(group); + var measureSpecs = this._getMeasureSpecs(); + var groupBy = rowGroupBy.concat(colGroupBy); + return this._rpc({ + model: this.modelName, + method: 'read_group', + context: this.data.context, + domain: groupDomain, + fields: measureSpecs, + groupBy: groupBy, + lazy: false, + }).then(function (subGroups) { + return { + group: group, + subGroups: subGroups, + rowGroupBy: rowGroupBy, + colGroupBy: colGroupBy + }; + }); + }, + /** + * Returns the group sanitized values. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {Array} + */ + _getGroupValues: function (group, groupBys) { + var self = this; + return groupBys.map(function (groupBy) { + return self._sanitizeValue(group[groupBy]); + }); + }, + /** + * Returns the leaf counts of each group inside the given tree. + * + * @private + * @param {Object} tree + * @returns {Object} keys are group ids + */ + _getLeafCounts: function (tree) { + var self = this; + var leafCounts = {}; + var leafCount; + if (!tree.directSubTrees.size) { + leafCount = 1; + } else { + leafCount = [...tree.directSubTrees.values()].reduce( + function (acc, subTree) { + var subLeafCounts = self._getLeafCounts(subTree); + _.extend(leafCounts, subLeafCounts); + return acc + leafCounts[JSON.stringify(subTree.root.values)]; + }, + 0 + ); + } + + leafCounts[JSON.stringify(tree.root.values)] = leafCount; + return leafCounts; + }, + /** + * Returns the group sanitized measure values for the measures in + * this.data.measures (that migth contain '__count', not really a fieldName). + * + * @private + * @param {Object} group + * @returns {Array} + */ + _getMeasurements: function (group) { + var self = this; + return this.data.measures.reduce( + function (measurements, fieldName) { + var measurement = group[fieldName]; + if (measurement instanceof Array) { + // case field is many2one and used as measure and groupBy simultaneously + measurement = 1; + } + if (self.fields[fieldName].type === 'boolean' && measurement instanceof Boolean) { + measurement = measurement ? 1 : 0; + } + if (self.data.origins.length > 1 && !measurement) { + measurement = 0; + } + measurements[fieldName] = measurement; + return measurements; + }, + {} + ); + }, + /** + * Returns a description of the measures row of the pivot table + * + * @private + * @param {Object[]} columns for which measure cells must be generated + * @returns {Object[]} + */ + _getMeasuresRow: function (columns) { + var self = this; + var sortedColumn = this.data.sortedColumn || {}; + var measureRow = []; + + columns.forEach(function (column) { + self.data.measures.forEach(function (measure) { + var measureCell = { + groupId: column.groupId, + height: 1, + measure: measure, + title: self.fields[measure].string, + width: 2 * self.data.origins.length - 1, + }; + if (sortedColumn.measure === measure && + _.isEqual(sortedColumn.groupId, column.groupId)) { + measureCell.order = sortedColumn.order; + } + measureRow.push(measureCell); + }); + }); + + return measureRow; + }, + /** + * Returns the list of measure specs associated with data.measures, i.e. + * a measure 'fieldName' becomes 'fieldName:groupOperator' where + * groupOperator is the value specified on the field 'fieldName' for + * the key group_operator. + * + * @private + * @return {string[]} + */ + _getMeasureSpecs: function () { + var self = this; + return this.data.measures.reduce( + function (acc, measure) { + if (measure === '__count') { + acc.push(measure); + return acc; + } + var type = self.fields[measure].type; + var groupOperator = self.fields[measure].group_operator; + if (type === 'many2one') { + groupOperator = 'count_distinct'; + } + if (groupOperator === undefined) { + throw new Error("No aggregate function has been provided for the measure '" + measure + "'"); + } + acc.push(measure + ':' + groupOperator); + return acc; + }, + [] + ); + }, + /** + * Make sure that the labels of different many2one values are distinguished + * by numbering them if necessary. + * + * @private + * @param {Array} label + * @param {string} fieldName + * @returns {string} + */ + _getNumberedLabel: function (label, fieldName) { + var id = label[0]; + var name = label[1]; + this.numbering[fieldName] = this.numbering[fieldName] || {}; + this.numbering[fieldName][name] = this.numbering[fieldName][name] || {}; + var numbers = this.numbering[fieldName][name]; + numbers[id] = numbers[id] || _.size(numbers) + 1; + return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); + }, + /** + * Returns a description of the origins row of the pivot table + * + * @private + * @param {Object[]} columns for which origin cells must be generated + * @returns {Object[]} + */ + _getOriginsRow: function (columns) { + var self = this; + var sortedColumn = this.data.sortedColumn || {}; + var originRow = []; + + columns.forEach(function (column) { + var groupId = column.groupId; + var measure = column.measure; + var isSorted = sortedColumn.measure === measure && + _.isEqual(sortedColumn.groupId, groupId); + var isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1]; + var isSortedByVariation = isSorted && sortedColumn.originIndexes[1]; + + self.data.origins.forEach(function (origin, originIndex) { + var originCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex], + title: origin, + width: 1, + }; + if (isSortedByOrigin && sortedColumn.originIndexes[0] === originIndex) { + originCell.order = sortedColumn.order; + } + originRow.push(originCell); + + if (originIndex > 0) { + var variationCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex - 1, originIndex], + title: _t('Variation'), + width: 1, + }; + if (isSortedByVariation && sortedColumn.originIndexes[1] === originIndex) { + variationCell.order = sortedColumn.order; + } + originRow.push(variationCell); + } + + }); + }); + + return originRow; + }, + + /** + * Get the selection needed to display the group by dropdown + * @returns {Object[]} + * @private + */ + _getSelectionGroupBy: function (groupBys) { + let groupedFieldNames = groupBys.rowGroupBys + .concat(groupBys.colGroupBys) + .map(function (g) { + return g.split(':')[0]; + }); + + var fields = Object.keys(this.groupableFields) + .map((fieldName, index) => { + return { + name: fieldName, + field: this.groupableFields[fieldName], + active: groupedFieldNames.includes(fieldName) + } + }) + .sort((left, right) => left.field.string < right.field.string ? -1 : 1); + return fields; + }, + + /** + * Returns a description of the pivot table. + * + * @private + * @returns {Object} + */ + _getTable: function () { + var headers = this._getTableHeaders(); + return { + headers: headers, + rows: this._getTableRows(this.rowGroupTree, headers[headers.length - 1]), + }; + }, + /** + * Returns the list of header rows of the pivot table: the col group rows + * (depending on the col groupbys), the measures row and optionnaly the + * origins row (if there are more than one origins). + * + * @private + * @returns {Object[]} + */ + _getTableHeaders: function () { + var colGroupBys = this._getGroupBys().colGroupBys; + var height = colGroupBys.length + 1; + var measureCount = this.data.measures.length; + var originCount = this.data.origins.length; + var leafCounts = this._getLeafCounts(this.colGroupTree); + var headers = []; + var measureColumns = []; // used to generate the measure cells + + // 1) generate col group rows (total row + one row for each col groupby) + var colGroupRows = (new Array(height)).fill(0).map(function () { + return []; + }); + // blank top left cell + colGroupRows[0].push({ + height: height + 1 + (originCount > 1 ? 1 : 0), // + measures rows [+ origins row] + title: "", + width: 1, + }); + + // col groupby cells with group values + /** + * Recursive function that generates the header cells corresponding to + * the groups of a given tree. + * + * @param {Object} tree + */ + function generateTreeHeaders(tree, fields) { + var group = tree.root; + var rowIndex = group.values.length; + var row = colGroupRows[rowIndex]; + var groupId = [[], group.values]; + var isLeaf = !tree.directSubTrees.size; + var leafCount = leafCounts[JSON.stringify(tree.root.values)]; + var cell = { + groupId: groupId, + height: isLeaf ? (colGroupBys.length + 1 - rowIndex) : 1, + isLeaf: isLeaf, + label: rowIndex === 0 ? undefined : fields[colGroupBys[rowIndex - 1].split(':')[0]].string, + title: group.labels[group.labels.length - 1] || _t('Total'), + width: leafCount * measureCount * (2 * originCount - 1), + }; + row.push(cell); + if (isLeaf) { + measureColumns.push(cell); + } + + [...tree.directSubTrees.values()].forEach(function (subTree) { + generateTreeHeaders(subTree, fields); + }); + } + + generateTreeHeaders(this.colGroupTree, this.fields); + // blank top right cell for 'Total' group (if there is more that one leaf) + if (leafCounts[JSON.stringify(this.colGroupTree.root.values)] > 1) { + var groupId = [[], []]; + var totalTopRightCell = { + groupId: groupId, + height: height, + title: "", + width: measureCount * (2 * originCount - 1), + }; + colGroupRows[0].push(totalTopRightCell); + measureColumns.push(totalTopRightCell); + } + headers = headers.concat(colGroupRows); + + // 2) generate measures row + var measuresRow = this._getMeasuresRow(measureColumns); + headers.push(measuresRow); + + // 3) generate origins row if more than one origin + if (originCount > 1) { + headers.push(this._getOriginsRow(measuresRow)); + } + + return headers; + }, + /** + * Returns the list of body rows of the pivot table for a given tree. + * + * @private + * @param {Object} tree + * @param {Object[]} columns + * @returns {Object[]} + */ + _getTableRows: function (tree, columns) { + var self = this; + + var rows = []; + var group = tree.root; + var rowGroupId = [group.values, []]; + var title = group.labels[group.labels.length - 1] || _t('Total'); + var indent = group.labels.length; + var isLeaf = !tree.directSubTrees.size; + var rowGroupBys = this._getGroupBys().rowGroupBys; + + var subGroupMeasurements = columns.map(function (column) { + var colGroupId = column.groupId; + var groupIntersectionId = [rowGroupId[0], colGroupId[1]]; + var measure = column.measure; + var originIndexes = column.originIndexes || [0]; + + var value = self._getCellValue(groupIntersectionId, measure, originIndexes); + + var measurement = { + groupId: groupIntersectionId, + originIndexes: originIndexes, + measure: measure, + value: value, + isBold: !groupIntersectionId[0].length || !groupIntersectionId[1].length, + }; + return measurement; + }); + + rows.push({ + title: title, + label: indent === 0 ? undefined : this.fields[rowGroupBys[indent - 1].split(':')[0]].string, + groupId: rowGroupId, + indent: indent, + isLeaf: isLeaf, + subGroupMeasurements: subGroupMeasurements + }); + + var subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; + subTreeKeys.forEach(function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + rows = rows.concat(self._getTableRows(subTree, columns)); + }); + + return rows; + }, + /** + * returns the height of a given groupTree + * + * @private + * @param {Object} tree, a groupTree + * @returns {number} + */ + _getTreeHeight: function (tree) { + var subTreeHeights = [...tree.directSubTrees.values()].map(this._getTreeHeight.bind(this)); + return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1; + }, + /** + * @private + * @returns {boolean} + */ + _hasData: function () { + return (this.counts[JSON.stringify([[], []])] || []).some(function (count) { + return count > 0; + }); + }, + /** + * @override + */ + _isEmpty() { + return !this._hasData(); + }, + /** + * Initilize/Reinitialize this.rowGroupTree, colGroupTree, measurements, + * counts and subdivide the group 'Total' as many times it is necessary. + * A first subdivision with no groupBy (divisors.slice(0, 1)) is made in + * order to see if there is data in the intersection of the group 'Total' + * and the various origins. In case there is none, nonsupplementary rpc + * will be done (see the code of subdivideGroup). + * Once the promise resolves, this.rowGroupTree, colGroupTree, + * measurements, counts are correctly set. + * + * @private + * @return {Promise} + */ + _loadData: function () { + var self = this; + + this.rowGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + this.colGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + this.measurements = {}; + this.counts = {}; + + var key = JSON.stringify([[], []]); + this.groupDomains = {}; + this.groupDomains[key] = this.data.domains.slice(0); + + + var group = { rowValues: [], colValues: [] }; + var groupBys = this._getGroupBys(); + var leftDivisors = sections(groupBys.rowGroupBys); + var rightDivisors = sections(groupBys.colGroupBys); + var divisors = cartesian(leftDivisors, rightDivisors); + + return this._subdivideGroup(group, divisors.slice(0, 1)).then(function () { + return self._subdivideGroup(group, divisors.slice(1)); + }); + }, + /** + * Extract the information in the read_group results (groupSubdivisions) + * and develop this.rowGroupTree, colGroupTree, measurements, counts, and + * groupDomains. + * If a column needs to be sorted, the rowGroupTree corresponding to the + * group is sorted. + * + * @private + * @param {Object} group + * @param {Object[]} groupSubdivisions + */ + _prepareData: function (group, groupSubdivisions) { + var self = this; + + var groupRowValues = group.rowValues; + var groupRowLabels = []; + var rowSubTree = this.rowGroupTree; + var root; + if (groupRowValues.length) { + // we should have labels information on hand! regretful! + rowSubTree = this._findGroup(this.rowGroupTree, groupRowValues); + root = rowSubTree.root; + groupRowLabels = root.labels; + } + + var groupColValues = group.colValues; + var groupColLabels = []; + if (groupColValues.length) { + root = this._findGroup(this.colGroupTree, groupColValues).root; + groupColLabels = root.labels; + } + + groupSubdivisions.forEach(function (groupSubdivision) { + groupSubdivision.subGroups.forEach(function (subGroup) { + + var rowValues = groupRowValues.concat(self._getGroupValues(subGroup, groupSubdivision.rowGroupBy)); + var rowLabels = groupRowLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.rowGroupBy)); + + var colValues = groupColValues.concat(self._getGroupValues(subGroup, groupSubdivision.colGroupBy)); + var colLabels = groupColLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.colGroupBy)); + + if (!colValues.length && rowValues.length) { + self._addGroup(self.rowGroupTree, rowLabels, rowValues); + } + if (colValues.length && !rowValues.length) { + self._addGroup(self.colGroupTree, colLabels, colValues); + } + + var key = JSON.stringify([rowValues, colValues]); + var originIndex = groupSubdivision.group.originIndex; + + if (!(key in self.measurements)) { + self.measurements[key] = self.data.origins.map(function () { + return self._getMeasurements({}); + }); + } + self.measurements[key][originIndex] = self._getMeasurements(subGroup); + + if (!(key in self.counts)) { + self.counts[key] = self.data.origins.map(function () { + return 0; + }); + } + self.counts[key][originIndex] = subGroup.__count; + + if (!(key in self.groupDomains)) { + self.groupDomains[key] = self.data.origins.map(function () { + return Domain.FALSE_DOMAIN; + }); + } + // if __domain is not defined this means that we are in the + // case where + // groupSubdivision.rowGroupBy = groupSubdivision.rowGroupBy = [] + if (subGroup.__domain) { + self.groupDomains[key][originIndex] = subGroup.__domain; + } + }); + }); + + if (this.data.sortedColumn) { + this.sortRows(this.data.sortedColumn, rowSubTree); + } + }, + /** + * In the preview implementation of the pivot view (a.k.a. version 2), + * the virtual field used to display the number of records was named + * __count__, whereas __count is actually the one used in xml. So + * basically, activating a filter specifying __count as measures crashed. + * Unfortunately, as __count__ was used in the JS, all filters saved as + * favorite at that time were saved with __count__, and not __count. + * So in order the make them still work with the new implementation, we + * handle both __count__ and __count. + * + * This function replaces in the given array of measures occurences of + * '__count__' by '__count'. + * + * @private + * @param {Array[string] || undefined} measures + * @returns {Array[string] || undefined} + */ + _processMeasures: function (measures) { + if (measures) { + return _.map(measures, function (measure) { + return measure === '__count__' ? '__count' : measure; + }); + } + }, + /** + * Determine this.data.domains and this.data.origins from + * this.data.domain and this.data.timeRanges; + * + * @private + */ + _computeDerivedParams: function () { + const { range, rangeDescription, comparisonRange, comparisonRangeDescription } = this.data.timeRanges; + if (range) { + this.data.domains = [this.data.domain.concat(comparisonRange), this.data.domain.concat(range)]; + this.data.origins = [comparisonRangeDescription, rangeDescription]; + } else { + this.data.domains = [this.data.domain]; + this.data.origins = [""]; + } + }, + /** + * Make any group in tree a leaf if it was a leaf in oldTree. + * + * @private + * @param {Object} tree + * @param {Object} oldTree + */ + _pruneTree: function (tree, oldTree) { + if (!oldTree.directSubTrees.size) { + tree.directSubTrees.clear(); + delete tree.sortedKeys; + return; + } + var self = this; + [...tree.directSubTrees.keys()].forEach(function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + if (!oldTree.directSubTrees.has(subTreeKey)) { + subTree.directSubTrees.clear(); + delete subTreeKey.sortedKeys; + } else { + var oldSubTree = oldTree.directSubTrees.get(subTreeKey); + self._pruneTree(subTree, oldSubTree); + } + }); + }, + /** + * Toggle the active state for a given measure, then reload the data + * if this turns out to be necessary. + * + * @param {string} fieldName + * @returns {Promise} + */ + _toggleMeasure: function (fieldName) { + var index = this.data.measures.indexOf(fieldName); + if (index !== -1) { + this.data.measures.splice(index, 1); + // in this case, we already have all data in memory, no need to + // actually reload a lesser amount of information + return Promise.resolve(); + } else { + this.data.measures.push(fieldName); + } + return this._loadData(); + }, + /** + * Extract from a groupBy value a label. + * + * @private + * @param {any} value + * @param {string} groupBy + * @returns {string} + */ + _sanitizeLabel: function (value, groupBy) { + var fieldName = groupBy.split(':')[0]; + if (value === false) { + return _t("Undefined"); + } + if (value instanceof Array) { + return this._getNumberedLabel(value, fieldName); + } + if (fieldName && this.fields[fieldName] && (this.fields[fieldName].type === 'selection')) { + var selected = _.where(this.fields[fieldName].selection, { 0: value })[0]; + return selected ? selected[1] : value; + } + return value; + }, + /** + * Extract from a groupBy value the raw value of that groupBy (discarding + * a label if any) + * + * @private + * @param {any} value + * @returns {any} + */ + _sanitizeValue: function (value) { + if (value instanceof Array) { + return value[0]; + } + return value; + }, + /** + * Get all partitions of a given group using the provided list of divisors + * and enrich the objects of this.rowGroupTree, colGroupTree, + * measurements, counts. + * + * @private + * @param {Object} group + * @param {Array[]} divisors + * @returns + */ + _subdivideGroup: function (group, divisors) { + var self = this; + + var key = JSON.stringify([group.rowValues, group.colValues]); + + var proms = this.data.origins.reduce( + function (acc, origin, originIndex) { + // if no information on group content is available, we fetch data. + // if group is known to be empty for the given origin, + // we don't need to fetch data fot that origin. + if (!self.counts[key] || self.counts[key][originIndex] > 0) { + var subGroup = { + rowValues: group.rowValues, + colValues: group.colValues, + originIndex: originIndex + }; + divisors.forEach(function (divisor) { + acc.push(self._getGroupSubdivision(subGroup, divisor[0], divisor[1])); + }); + } + return acc; + }, + [] + ); + return this._loadDataDropPrevious.add(Promise.all(proms)).then(function (groupSubdivisions) { + if (groupSubdivisions.length) { + self._prepareData(group, groupSubdivisions); + } + }); + }, + /** + * Sort recursively the subTrees of tree using sortFunction. + * In the end each node of the tree has its direct children sorted + * according to the criterion reprensented by sortFunction. + * + * @private + * @param {Function} sortFunction + * @param {Object} tree + */ + _sortTree: function (sortFunction, tree) { + var self = this; + tree.sortedKeys = _.sortBy([...tree.directSubTrees.keys()], sortFunction(tree)); + [...tree.directSubTrees.values()].forEach(function (subTree) { + self._sortTree(sortFunction, subTree); + }); + }, +}); + +return PivotModel; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js new file mode 100644 index 00000000..dcba95bc --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -0,0 +1,202 @@ +odoo.define('web.PivotRenderer', function (require) { + "use strict"; + + const OwlAbstractRenderer = require('web.AbstractRendererOwl'); + const field_utils = require('web.field_utils'); + const patchMixin = require('web.patchMixin'); + + const { useExternalListener, useState, onMounted, onPatched } = owl.hooks; + + /** + * Here is a basic example of the structure of the Pivot Table: + * + * ┌─────────────────────────┬─────────────────────────────────────────────┬─────────────────┐ + * │ │ - web.PivotHeader │ │ + * │ ├──────────────────────┬──────────────────────┤ │ + * │ │ + web.PivotHeader │ + web.PivotHeader │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ │ web.PivotMeasure │ web.PivotMeasure │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ ─ web.PivotHeader │ │ │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ + web.PivotHeader │ │ │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ + web.PivotHeader │ │ │ │ + * └─────────────────────────┴──────────────────────┴──────────────────────┴─────────────────┘ + * + */ + + class PivotRenderer extends OwlAbstractRenderer { + /** + * @override + * @param {boolean} props.disableLinking Disallow opening records by clicking on a cell + * @param {Object} props.widgets Widgets defined in the arch + */ + constructor() { + super(...arguments); + this.sampleDataTargets = ['table']; + this.state = useState({ + activeNodeHeader: { + groupId: false, + isXAxis: false, + click: false + }, + }); + + onMounted(() => this._updateTooltip()); + + onPatched(() => this._updateTooltip()); + + useExternalListener(window, 'click', this._resetState); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Get the formatted value of the cell + * + * @private + * @param {Object} cell + * @returns {string} Formatted value + */ + _getFormattedValue(cell) { + const type = this.props.widgets[cell.measure] || + (this.props.fields[cell.measure].type === 'many2one' ? 'integer' : this.props.fields[cell.measure].type); + const formatter = field_utils.format[type]; + return formatter(cell.value, this.props.fields[cell.measure]); + } + + /** + * Get the formatted variation of a cell + * + * @private + * @param {Object} cell + * @returns {string} Formatted variation + */ + _getFormattedVariation(cell) { + const value = cell.value; + return isNaN(value) ? '-' : field_utils.format.percentage(value, this.props.fields[cell.measure]); + } + + /** + * Retrieves the padding of a left header + * + * @private + * @param {Object} cell + * @returns {Number} Padding + */ + _getPadding(cell) { + return 5 + cell.indent * 30; + } + + /** + * Compute if a cell is active (with its groupId) + * + * @private + * @param {Array} groupId GroupId of a cell + * @param {Boolean} isXAxis true if the cell is on the x axis + * @returns {Boolean} true if the cell is active + */ + _isClicked(groupId, isXAxis) { + return _.isEqual(groupId, this.state.activeNodeHeader.groupId) && this.state.activeNodeHeader.isXAxis === isXAxis; + } + + /** + * Reset the state of the node. + * + * @private + */ + _resetState() { + // This check is used to avoid the destruction of the dropdown. + // The click on the header bubbles to window in order to hide + // all the other dropdowns (in this component or other components). + // So we need isHeaderClicked to cancel this behaviour. + if (this.isHeaderClicked) { + this.isHeaderClicked = false; + return; + } + this.state.activeNodeHeader = { + groupId: false, + isXAxis: false, + click: false + }; + } + + /** + * Configure the tooltips on the headers. + * + * @private + */ + _updateTooltip() { + $(this.el).find('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip(); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + + /** + * Handles a click on a menu item in the dropdown to select a groupby. + * + * @private + * @param {Object} field + * @param {string} interval + */ + _onClickMenuGroupBy(field, interval) { + this.trigger('groupby_menu_selection', { field, interval }); + } + + + /** + * Handles a click on a header node + * + * @private + * @param {Object} cell + * @param {string} type col or row + */ + _onHeaderClick(cell, type) { + const groupValues = cell.groupId[type === 'col' ? 1 : 0]; + const groupByLength = type === 'col' ? this.props.colGroupBys.length : this.props.rowGroupBys.length; + if (cell.isLeaf && groupValues.length >= groupByLength) { + this.isHeaderClicked = true; + this.state.activeNodeHeader = { + groupId: cell.groupId, + isXAxis: type === 'col', + click: 'leftClick' + }; + } + this.trigger(cell.isLeaf ? 'closed_header_click' : 'opened_header_click', { cell, type }); + } + + /** + * Hover the column in which the mouse is. + * + * @private + * @param {MouseEvent} ev + */ + _onMouseEnter(ev) { + var index = [...ev.currentTarget.parentNode.children].indexOf(ev.currentTarget); + if (ev.currentTarget.tagName === 'TH') { + index += 1; + } + this.el.querySelectorAll('td:nth-child(' + (index + 1) + ')').forEach(elt => elt.classList.add('o_cell_hover')); + } + + /** + * Remove the hover on the columns. + * + * @private + */ + _onMouseLeave() { + this.el.querySelectorAll('.o_cell_hover').forEach(elt => elt.classList.remove('o_cell_hover')); + } + } + + PivotRenderer.template = 'web.PivotRenderer'; + + return patchMixin(PivotRenderer); + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js new file mode 100644 index 00000000..ea3ab9c7 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -0,0 +1,158 @@ +odoo.define('web.PivotView', function (require) { + "use strict"; + + /** + * The Pivot View is a view that represents data in a 'pivot grid' form. It + * aggregates data on 2 dimensions and displays the result, allows the user to + * 'zoom in' data. + */ + + const AbstractView = require('web.AbstractView'); + const config = require('web.config'); + const core = require('web.core'); + const PivotModel = require('web.PivotModel'); + const PivotController = require('web.PivotController'); + const PivotRenderer = require('web.PivotRenderer'); + const RendererWrapper = require('web.RendererWrapper'); + + const _t = core._t; + const _lt = core._lt; + + const searchUtils = require('web.searchUtils'); + const GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES; + + const PivotView = AbstractView.extend({ + display_name: _lt('Pivot'), + icon: 'fa-table', + config: Object.assign({}, AbstractView.prototype.config, { + Model: PivotModel, + Controller: PivotController, + Renderer: PivotRenderer, + }), + viewType: 'pivot', + searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'], + + /** + * @override + * @param {Object} params + * @param {Array} params.additionalMeasures + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + const activeMeasures = []; // Store the defined active measures + const colGroupBys = []; // Store the defined group_by used on cols + const rowGroupBys = []; // Store the defined group_by used on rows + const measures = {}; // All the available measures + const groupableFields = {}; // The fields which can be used to group data + const widgets = {}; // Wigdets defined in the arch + const additionalMeasures = params.additionalMeasures || []; + + this.fields.__count = { string: _t("Count"), type: "integer" }; + + //Compute the measures and the groupableFields + Object.keys(this.fields).forEach(name => { + const field = this.fields[name]; + if (name !== 'id' && field.store === true) { + if (['integer', 'float', 'monetary'].includes(field.type) || additionalMeasures.includes(name)) { + measures[name] = field; + } + if (GROUPABLE_TYPES.includes(field.type)) { + groupableFields[name] = field; + } + } + }); + measures.__count = { string: _t("Count"), type: "integer" }; + + + this.arch.children.forEach(field => { + let name = field.attrs.name; + // Remove invisible fields from the measures if not in additionalMeasures + if (field.attrs.invisible && py.eval(field.attrs.invisible)) { + if (name in groupableFields) { + delete groupableFields[name]; + } + if (!additionalMeasures.includes(name)) { + delete measures[name]; + return; + } + } + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + if (field.attrs.widget) { + widgets[name] = field.attrs.widget; + } + // add active measures to the measure list. This is very rarely + // necessary, but it can be useful if one is working with a + // functional field non stored, but in a model with an overrided + // read_group method. In this case, the pivot view could work, and + // the measure should be allowed. However, be careful if you define + // a measure in your pivot view: non stored functional fields will + // probably not work (their aggregate will always be 0). + if (field.attrs.type === 'measure' && !(name in measures)) { + measures[name] = this.fields[name]; + } + if (field.attrs.string && name in measures) { + measures[name].string = field.attrs.string; + } + if (field.attrs.type === 'measure' || 'operator' in field.attrs) { + activeMeasures.push(name); + measures[name] = this.fields[name]; + } + if (field.attrs.type === 'col') { + colGroupBys.push(name); + } + if (field.attrs.type === 'row') { + rowGroupBys.push(name); + } + }); + if ((!activeMeasures.length) || this.arch.attrs.display_quantity) { + activeMeasures.splice(0, 0, '__count'); + } + + this.loadParams.measures = activeMeasures; + this.loadParams.colGroupBys = config.device.isMobile ? [] : colGroupBys; + this.loadParams.rowGroupBys = rowGroupBys; + this.loadParams.fields = this.fields; + this.loadParams.default_order = params.default_order || this.arch.attrs.default_order; + this.loadParams.groupableFields = groupableFields; + + const disableLinking = !!(this.arch.attrs.disable_linking && + JSON.stringify(this.arch.attrs.disable_linking)); + + this.rendererParams.widgets = widgets; + this.rendererParams.disableLinking = disableLinking; + + this.controllerParams.disableLinking = disableLinking; + this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled"); + this.controllerParams.measures = measures; + + // retrieve form and list view ids from the action to open those views + // when a data cell of the pivot view is clicked + this.controllerParams.views = [ + _findView(params.actionViews, 'list'), + _findView(params.actionViews, 'form'), + ]; + + function _findView(views, viewType) { + const view = views.find(view => { + return view.type === viewType; + }); + return [view ? view.viewID : false, viewType]; + } + }, + + /** + * + * @override + */ + getRenderer(parent, state) { + state = Object.assign(state || {}, this.rendererParams); + return new RendererWrapper(parent, this.config.Renderer, state); + }, + }); + + return PivotView; + +}); diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js new file mode 100644 index 00000000..4e7f8024 --- /dev/null +++ b/addons/web/static/src/js/views/qweb/qweb_view.js @@ -0,0 +1,208 @@ +/** + * Client-side implementation of a qweb view. + */ +odoo.define('web.qweb', function (require) { +"use strict"; + +var core = require('web.core'); +var AbstractView = require('web.AbstractView'); +var AbstractModel = require('web.AbstractModel'); +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractController = require('web.AbstractController'); +var registry = require('web.view_registry'); + +var _lt = core._lt; + +/** + * model + */ +var Model = AbstractModel.extend({ + /** + * init + */ + init: function () { + this._super.apply(this, arguments); + this._state = { + viewId: false, + modelName: false, + body: '', + context: {}, + domain: [], + }; + }, + /** + * fetches the rendered qweb view + */ + _fetch: function () { + var state = this._state; + return this._rpc({ + model: state.modelName, + method: 'qweb_render_view', + kwargs: { + view_id: state.viewId, + domain: state.domain, + context: state.context + } + }).then(function (r) { + state.body = r; + return state.viewId; + }); + }, + /** + * get + */ + __get: function () { + return this._state; + }, + /** + * load + */ + __load: function (params) { + _.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context'])); + + return this._fetch(); + }, + /** + * reload + */ + __reload: function (_id, params) { + _.extend(this._state, _.pick(params, ['domain', 'context'])); + + return this._fetch(); + } +}); +/** + * renderer + */ +var Renderer = AbstractRenderer.extend({ + /** + * render + */ + _render: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.$el.html(self.state.body); + }); + } +}); +/** + * controller + */ +var Controller = AbstractController.extend({ + events: _.extend({}, AbstractController.prototype.events, { + 'click [type="toggle"]': '_onLazyToggle', + 'click [type="action"]' : '_onActionClicked', + }), + + init: function () { + this._super.apply(this, arguments); + }, + + /** + * @override + */ + renderButtons: function ($node) { + this.$buttons = $('<nav/>'); + if ($node) { + $node.append(this.$buttons); + } + }, + _update: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + // move control panel buttons from the view to the control panel + // area + var $cp_buttons = self.renderer.$('nav.o_qweb_cp_buttons'); + $cp_buttons.children().appendTo(self.$buttons.empty()); + $cp_buttons.remove(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Lazy toggle. Content is not remembered between unfolds. + */ + _onLazyToggle: function (e) { + // TODO: add support for view (possibly action as well?) + var $target = $(e.target); + var $t = $target.closest('[data-model]'); + if (!($target.hasClass('fa-caret-down') || $target.hasClass('fa-caret-right'))) { + $target = $t.find('.fa-caret-down, .fa-caret-right'); + } + + var data = $t.data(); + if (this._fold($t)) { + $target.removeClass('fa-caret-down').addClass('fa-caret-right'); + return; + } + + // NB: $.data() automatically parses json attributes, but does not + // automatically parse lone float literals in data-*, so a + // data-args (as a json object) is very convenient + var args = data.args || _.omit(data, 'model', 'method', 'id'); + + return this._rpc({ + model: data.model, + method: data.method, + args: data.id ? [data.id] : undefined, + kwargs: args // FIXME: context? + }).then(function (s) { + return $(s); + }).then(function ($newcontent) { + $t.data('children', $newcontent).after($newcontent); + $target.removeClass('fa-caret-right').addClass('fa-caret-down'); + }); + }, + /** + * Attempts to fold the parameter, returns whether that happened. + */ + _fold: function ($el) { + var $children = $el.data('children'); + if (!$children) { + return false; + } + + var self = this; + $children.each(function (_i, e) { + self._fold($(e)); + }).remove(); + $el.removeData('children'); + return true; + } +}); + +/** + * view + */ +var QWebView = AbstractView.extend({ + display_name: _lt('Freedom View'), + icon: 'fa-file-picture-o', + viewType: 'qweb', + // groupable? + enableTimeRangeMenu: true, + config: _.extend({}, AbstractView.prototype.config, { + Model: Model, + Renderer: Renderer, + Controller: Controller, + }), + + /** + * init method + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + this.loadParams.viewId = viewInfo.view_id; + } +}); + +registry.add('qweb', QWebView); +return { + View: QWebView, + Controller: Controller, + Renderer: Renderer, + Model: Model +}; +}); diff --git a/addons/web/static/src/js/views/renderer_wrapper.js b/addons/web/static/src/js/views/renderer_wrapper.js new file mode 100644 index 00000000..d8fd3843 --- /dev/null +++ b/addons/web/static/src/js/views/renderer_wrapper.js @@ -0,0 +1,15 @@ +odoo.define('web.RendererWrapper', function (require) { + "use strict"; + + const { ComponentWrapper } = require('web.OwlCompatibility'); + + class RendererWrapper extends ComponentWrapper { + getLocalState() { } + setLocalState() { } + giveFocus() { } + resetLocalState() { } + } + + return RendererWrapper; + +}); diff --git a/addons/web/static/src/js/views/sample_server.js b/addons/web/static/src/js/views/sample_server.js new file mode 100644 index 00000000..8af27feb --- /dev/null +++ b/addons/web/static/src/js/views/sample_server.js @@ -0,0 +1,692 @@ +odoo.define('web.SampleServer', function (require) { + "use strict"; + + const session = require('web.session'); + const utils = require('web.utils'); + const Registry = require('web.Registry'); + + class UnimplementedRouteError extends Error {} + + /** + * Helper function returning the value from a list of sample strings + * corresponding to the given ID. + * @param {number} id + * @param {string[]} sampleTexts + * @returns {string} + */ + function getSampleFromId(id, sampleTexts) { + return sampleTexts[(id - 1) % sampleTexts.length]; + } + + /** + * Helper function returning a regular expression specifically matching + * a given 'term' in a fieldName. For example `fieldNameRegex('abc')`: + * will match: + * - "abc" + * - "field_abc__def" + * will not match: + * - "aabc" + * - "abcd_ef" + * @param {...string} term + * @returns {RegExp} + */ + function fieldNameRegex(...terms) { + return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`); + } + + const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message'); + const EMAIL_REGEX = fieldNameRegex('email'); + const PHONE_REGEX = fieldNameRegex('phone'); + const URL_REGEX = fieldNameRegex('url'); + + /** + * Sample server class + * + * Represents a static instance of the server used when a RPC call sends + * empty values/groups while the attribute 'sample' is set to true on the + * view. + * + * This server will generate fake data and send them in the adequate format + * according to the route/method used in the RPC. + */ + class SampleServer { + + /** + * @param {string} modelName + * @param {Object} fields + */ + constructor(modelName, fields) { + this.mainModel = modelName; + this.data = {}; + this.data[modelName] = { + fields, + records: [], + }; + // Generate relational fields' co models + for (const fieldName in fields) { + const field = fields[fieldName]; + if (['many2one', 'one2many', 'many2many'].includes(field.type)) { + this.data[field.relation] = this.data[field.relation] || { + fields: { + display_name: { type: 'char' }, + id: { type: 'integer' }, + color: { type: 'integer' }, + }, + records: [], + }; + } + } + // On some models, empty grouped Kanban or List view still contain + // real (empty) groups. In this case, we re-use the result of the + // web_read_group rpc to tweak sample data s.t. those real groups + // contain sample records. + this.existingGroups = null; + // Sample records generation is only done if necessary, so we delay + // it to the first "mockRPC" call. These flags allow us to know if + // the records have been generated or not. + this.populated = false; + this.existingGroupsPopulated = false; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * This is the main entry point of the SampleServer. Mocks a request to + * the server with sample data. + * @param {Object} params + * @returns {any} the result obtained with the sample data + * @throws {Error} If called on a route/method we do not handle + */ + mockRpc(params) { + if (!(params.model in this.data)) { + throw new Error(`SampleServer: unknown model ${params.model}`); + } + this._populateModels(); + switch (params.method || params.route) { + case '/web/dataset/search_read': + return this._mockSearchReadController(params); + case 'web_read_group': + return this._mockWebReadGroup(params); + case 'read_group': + return this._mockReadGroup(params); + case 'read_progress_bar': + return this._mockReadProgressBar(params); + case 'read': + return this._mockRead(params); + } + // this rpc can't be mocked by the SampleServer itself, so check if there is an handler + // in the mockRegistry: either specific for this model (with key 'model/method'), or + // global (with key 'method') + const method = params.method || params.route; + const mockFunction = SampleServer.mockRegistry.get(`${params.model}/${method}`) || + SampleServer.mockRegistry.get(method); + if (mockFunction) { + return mockFunction.call(this, params); + } + console.log(`SampleServer: unimplemented route "${params.method || params.route}"`); + throw new SampleServer.UnimplementedRouteError(); + } + + setExistingGroups(groups) { + this.existingGroups = groups; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @param {Object[]} measures, each measure has the form { fieldName, type } + * @param {Object[]} records + * @returns {Object} + */ + _aggregateFields(measures, records) { + const values = {}; + for (const { fieldName, type } of measures) { + if (['float', 'integer', 'monetary'].includes(type)) { + if (records.length) { + let value = 0; + for (const record of records) { + value += record[fieldName]; + } + values[fieldName] = this._sanitizeNumber(value); + } else { + values[fieldName] = null; + } + } + if (type === 'many2one') { + const ids = new Set(records.map(r => r[fieldName])); + values.fieldName = ids.size || null; + } + } + return values; + } + + /** + * @param {any} value + * @param {Object} options + * @param {string} [options.interval] + * @param {string} [options.relation] + * @param {string} [options.type] + * @returns {any} + */ + _formatValue(value, options) { + if (!value) { + return false; + } + const { type, interval, relation } = options; + if (['date', 'datetime'].includes(type)) { + const fmt = SampleServer.FORMATS[interval]; + return moment(value).format(fmt); + } else if (type === 'many2one') { + const rec = this.data[relation].records.find(({id}) => id === value); + return [value, rec.display_name]; + } else { + return value; + } + } + + /** + * Generates field values based on heuristics according to field types + * and names. + * + * @private + * @param {string} modelName + * @param {string} fieldName + * @param {number} id the record id + * @returns {any} the field value + */ + _generateFieldValue(modelName, fieldName, id) { + const field = this.data[modelName].fields[fieldName]; + switch (field.type) { + case "boolean": + return fieldName === 'active' ? true : this._getRandomBool(); + case "char": + case "text": + if (["display_name", "name"].includes(fieldName)) { + if (SampleServer.PEOPLE_MODELS.includes(modelName)) { + return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE); + } else if (modelName === 'res.country') { + return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES); + } + } + if (fieldName === 'display_name') { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (["name", "reference"].includes(fieldName)) { + return `REF${String(id).padStart(4, '0')}`; + } else if (DESCRIPTION_REGEX.test(fieldName)) { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (EMAIL_REGEX.test(fieldName)) { + const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE) + .replace(/ /, ".") + .toLowerCase(); + return `${emailName}@sample.demo`; + } else if (PHONE_REGEX.test(fieldName)) { + return `+1 555 754 ${String(id).padStart(4, '0')}`; + } else if (URL_REGEX.test(fieldName)) { + return `http://sample${id}.com`; + } + return false; + case "date": + case "datetime": { + const format = field.type === "date" ? + "YYYY-MM-DD" : + "YYYY-MM-DD HH:mm:ss"; + return this._getRandomDate(format); + } + case "float": + return this._getRandomFloat(SampleServer.MAX_FLOAT); + case "integer": { + let max = SampleServer.MAX_INTEGER; + if (fieldName.includes('color')) { + max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0; + } + return this._getRandomInt(max); + } + case "monetary": + return this._getRandomInt(SampleServer.MAX_MONETARY); + case "many2one": + if (field.relation === 'res.currency') { + return session.company_currency_id; + } + if (field.relation === 'ir.attachment') { + return false; + } + return this._getRandomSubRecordId(); + case "one2many": + case "many2many": { + const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()]; + return [...new Set(ids)]; + } + case "selection": { + // I hoped we wouldn't have to implement such special cases, but here it is. + // If this (mail) field is set, 'Warning' is displayed instead of the last + // activity, and we don't want to see a bunch of 'Warning's in a list. In the + // future, if we have to implement several special cases like that, we'll setup + // a proper hook to allow external modules to define extensions of this function. + // For now, as we have only one use case, I guess that doing it here is fine. + if (fieldName === 'activity_exception_decoration') { + return false; + } + if (field.selection.length > 0) { + return this._getRandomArrayEl(field.selection)[0]; + } + return false; + } + default: + return false; + } + } + + /** + * @private + * @param {any[]} array + * @returns {any} + */ + _getRandomArrayEl(array) { + return array[Math.floor(Math.random() * array.length)]; + } + + /** + * @private + * @returns {boolean} + */ + _getRandomBool() { + return Math.random() < 0.5; + } + + /** + * @private + * @param {string} format + * @returns {moment} + */ + _getRandomDate(format) { + const delta = Math.floor( + (Math.random() - Math.random()) * SampleServer.DATE_DELTA + ); + return new moment() + .add(delta, "hour") + .format(format); + } + + /** + * @private + * @param {number} max + * @returns {number} float in [O, max[ + */ + _getRandomFloat(max) { + return this._sanitizeNumber(Math.random() * max); + } + + /** + * @private + * @param {number} max + * @returns {number} int in [0, max[ + */ + _getRandomInt(max) { + return Math.floor(Math.random() * max); + } + + /** + * @private + * @returns {number} id in [1, SUB_RECORDSET_SIZE] + */ + _getRandomSubRecordId() { + return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1; + } + /** + * Mocks calls to the read method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Array[]} params.args (args[0] is the list of ids, args[1] is + * the list of fields) + * @returns {Object[]} + */ + _mockRead(params) { + const model = this.data[params.model]; + const ids = params.args[0]; + const fieldNames = params.args[1]; + const records = []; + for (const r of model.records) { + if (!ids.includes(r.id)) { + continue; + } + const record = { id: r.id }; + for (const fieldName of fieldNames) { + const field = model.fields[fieldName]; + if (!field) { + record[fieldName] = false; // unknown field + } else if (field.type === 'many2one') { + const relModel = this.data[field.relation]; + const relRecord = relModel.records.find( + relR => r[fieldName] === relR.id + ); + record[fieldName] = relRecord ? + [relRecord.id, relRecord.display_name] : + false; + } else { + record[fieldName] = r[fieldName]; + } + } + records.push(record); + } + return records; + } + + /** + * Mocks calls to the read_group method. + * + * @param {Object} params + * @param {string} params.model + * @param {string[]} [params.fields] defaults to the list of all fields + * @param {string[]} params.groupBy + * @param {boolean} [params.lazy=true] + * @returns {Object[]} Object with keys groups and length + */ + _mockReadGroup(params) { + const lazy = 'lazy' in params ? params.lazy : true; + const model = params.model; + const fields = this.data[model].fields; + const records = this.data[model].records; + + const normalizedGroupBys = []; + let groupBy = []; + if (params.groupBy.length) { + groupBy = lazy ? [params.groupBy[0]] : params.groupBy; + } + for (const groupBySpec of groupBy) { + let [fieldName, interval] = groupBySpec.split(':'); + interval = interval || 'month'; + const { type, relation } = fields[fieldName]; + if (type) { + const gb = { fieldName, type, interval, relation, alias: groupBySpec }; + normalizedGroupBys.push(gb); + } + } + const groups = utils.groupBy(records, (record) => { + const vals = {}; + for (const gb of normalizedGroupBys) { + const { fieldName, type } = gb; + let value; + if (['date', 'datetime'].includes(type)) { + value = this._formatValue(record[fieldName], gb); + } else { + value = record[fieldName]; + } + vals[fieldName] = value; + } + return JSON.stringify(vals); + }); + const measures = []; + for (const measureSpec of (params.fields || Object.keys(fields))) { + const [fieldName, aggregateFunction] = measureSpec.split(':'); + const { type } = fields[fieldName]; + if (!params.groupBy.includes(fieldName) && type && + (type !== 'many2one' || aggregateFunction !== 'count_distinct')) { + measures.push({ fieldName, type }); + } + } + + let result = []; + for (const id in groups) { + const records = groups[id]; + const group = { __domain: [] }; + let countKey = `__count`; + if (normalizedGroupBys.length && lazy) { + countKey = `${normalizedGroupBys[0].fieldName}_count`; + } + group[countKey] = records.length; + const firstElem = records[0]; + for (const gb of normalizedGroupBys) { + const { alias, fieldName } = gb; + group[alias] = this._formatValue(firstElem[fieldName], gb); + } + Object.assign(group, this._aggregateFields(measures, records)); + result.push(group); + } + if (normalizedGroupBys.length > 0) { + const { alias, interval, type } = normalizedGroupBys[0]; + result = utils.sortBy(result, (group) => { + const val = group[alias]; + if (['date', 'datetime'].includes(type)) { + return moment(val, SampleServer.FORMATS[interval]); + } + return val; + }); + } + return result; + } + + /** + * Mocks calls to the read_progress_bar method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Object} params.kwargs + * @return {Object} + */ + _mockReadProgressBar(params) { + const groupBy = params.kwargs.group_by; + const progress_bar = params.kwargs.progress_bar; + const groupByField = this.data[params.model].fields[groupBy]; + const data = {}; + for (const record of this.data[params.model].records) { + let groupByValue = record[groupBy]; + if (groupByField.type === "many2one") { + const relatedRecords = this.data[groupByField.relation].records; + const relatedRecord = relatedRecords.find(r => r.id === groupByValue); + groupByValue = relatedRecord.display_name; + } + if (!(groupByValue in data)) { + data[groupByValue] = {}; + for (const key in progress_bar.colors) { + data[groupByValue][key] = 0; + } + } + const fieldValue = record[progress_bar.field]; + if (fieldValue in data[groupByValue]) { + data[groupByValue][fieldValue]++; + } + } + return data; + } + + /** + * Mocks calls to the /web/dataset/search_read route to return sample + * records. + * @private + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @returns {{ records: Object[], length: number }} + */ + _mockSearchReadController(params) { + const model = this.data[params.model]; + const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT); + const records = this._mockRead({ + model: params.model, + args: [rawRecords.map(r => r.id), params.fields], + }); + return { records, length: records.length }; + } + + /** + * Mocks calls to the web_read_group method to return groups populated + * with sample records. Only handles the case where the real call to + * web_read_group returned groups, but none of these groups contain + * records. In this case, we keep the real groups, and populate them + * with sample records. + * @private + * @param {Object} params + * @param {Object} [result] the result of a real call to web_read_group + * @returns {{ groups: Object[], length: number }} + */ + _mockWebReadGroup(params) { + let groups; + if (this.existingGroups) { + this._tweakExistingGroups(params); + groups = this.existingGroups; + } else { + groups = this._mockReadGroup(params); + } + return { + groups, + length: groups.length, + }; + } + + /** + * Updates the sample data such that the existing groups (in database) + * also exists in the sample, and such that there are sample records in + * those groups. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.groupBy + */ + _populateExistingGroups(params) { + if (!this.existingGroupsPopulated) { + const groups = this.existingGroups; + this.groupsInfo = groups; + const groupBy = params.groupBy[0]; + const values = groups.map(g => g[groupBy]); + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === 'many2one'; + if (groupedByM2O) { // re-populate co model with relevant records + this.data[groupByField.relation].records = values.map(v => { + return { id: v[0], display_name: v[1] }; + }); + } + for (const r of this.data[params.model].records) { + const value = getSampleFromId(r.id, values); + r[groupBy] = groupedByM2O ? value[0] : value; + } + this.existingGroupsPopulated = true; + } + } + + /** + * Generates sample records for the models in this.data. Records will be + * generated once, and subsequent calls to this function will be skipped. + * @private + */ + _populateModels() { + if (!this.populated) { + for (const modelName in this.data) { + const model = this.data[modelName]; + const fieldNames = Object.keys(model.fields).filter(f => f !== 'id'); + const size = modelName === this.mainModel ? + SampleServer.MAIN_RECORDSET_SIZE : + SampleServer.SUB_RECORDSET_SIZE; + for (let id = 1; id <= size; id++) { + const record = { id }; + for (const fieldName of fieldNames) { + record[fieldName] = this._generateFieldValue(modelName, fieldName, id); + } + model.records.push(record); + } + } + this.populated = true; + } + } + + /** + * Rounds the given number value according to the configured precision. + * @private + * @param {number} value + * @returns {number} + */ + _sanitizeNumber(value) { + return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION)); + } + + /** + * A real (web_)read_group call has been done, and it has returned groups, + * but they are all empty. This function updates the sample data such + * that those group values exist and those groups contain sample records. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @param {string[]} params.groupBy + * @returns {Object[]} groups with count and aggregate values updated + * + * TODO: rename + */ + _tweakExistingGroups(params) { + const groups = this.existingGroups; + this._populateExistingGroups(params); + + // update count and aggregates for each group + const groupBy = params.groupBy[0].split(':')[0]; + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === 'many2one'; + const records = this.data[params.model].records; + for (const g of groups) { + const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy]; + const recordsInGroup = records.filter(r => r[groupBy] === groupValue); + g[`${groupBy}_count`] = recordsInGroup.length; + for (const field of params.fields) { + const fieldType = this.data[params.model].fields[field].type; + if (['integer, float', 'monetary'].includes(fieldType)) { + g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0); + } + } + g.__data = { + records: this._mockRead({ + model: params.model, + args: [recordsInGroup.map(r => r.id), params.fields], + }), + length: recordsInGroup.length, + }; + } + } + } + + SampleServer.FORMATS = { + day: 'YYYY-MM-DD', + week: '[W]ww YYYY', + month: 'MMMM YYYY', + quarter: '[Q]Q YYYY', + year: 'Y', + }; + SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: 'DD MMM YYYY' }); + + SampleServer.MAIN_RECORDSET_SIZE = 16; + SampleServer.SUB_RECORDSET_SIZE = 5; + SampleServer.SEARCH_READ_LIMIT = 10; + + SampleServer.MAX_FLOAT = 100; + SampleServer.MAX_INTEGER = 50; + SampleServer.MAX_COLOR_INT = 7; + SampleServer.MAX_MONETARY = 100000; + SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days + SampleServer.FLOAT_PRECISION = 2; + + SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"]; + SampleServer.SAMPLE_PEOPLE = [ + "John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot", + ]; + SampleServer.SAMPLE_TEXTS = [ + "Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa", + ]; + SampleServer.PEOPLE_MODELS = [ + 'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact' + ]; + + SampleServer.UnimplementedRouteError = UnimplementedRouteError; + + // mockRegistry allows to register mock version of methods or routes, + // for all models: + // SampleServer.mockRegistry.add('some_route', () => "abcd"); + // for a specific model (e.g. 'res.partner'): + // SampleServer.mockRegistry.add('res.partner/some_method', () => 23); + SampleServer.mockRegistry = new Registry(); + + return SampleServer; +}); diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js new file mode 100644 index 00000000..71537847 --- /dev/null +++ b/addons/web/static/src/js/views/search_panel.js @@ -0,0 +1,214 @@ +odoo.define("web/static/src/js/views/search_panel.js", function (require) { + "use strict"; + + const { Model, useModel } = require("web/static/src/js/model.js"); + const patchMixin = require("web.patchMixin"); + + const { Component, hooks } = owl; + const { useState, useSubEnv } = hooks; + + /** + * Search panel + * + * Represent an extension of the search interface located on the left side of + * the view. It is divided in sections defined in a "<searchpanel>" node located + * inside of a "<search>" arch. Each section is represented by a list of different + * values (categories or ungrouped filters) or groups of values (grouped filters). + * Its state is directly affected by its model (@see SearchPanelModelExtension). + * @extends Component + */ + class SearchPanel extends Component { + constructor() { + super(...arguments); + + useSubEnv({ searchModel: this.props.searchModel }); + + this.state = useState({ + active: {}, + expanded: {}, + }); + this.model = useModel("searchModel"); + this.scrollTop = 0; + this.hasImportedState = false; + + this.importState(this.props.importedState); + } + + async willStart() { + this._expandDefaultValue(); + this._updateActiveValues(); + } + + mounted() { + this._updateGroupHeadersChecked(); + if (this.hasImportedState) { + this.el.scroll({ top: this.scrollTop }); + } + } + + async willUpdateProps() { + this._updateActiveValues(); + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + exportState() { + const exported = { + expanded: this.state.expanded, + scrollTop: this.el.scrollTop, + }; + return JSON.stringify(exported); + } + + importState(stringifiedState) { + this.hasImportedState = Boolean(stringifiedState); + if (this.hasImportedState) { + const state = JSON.parse(stringifiedState); + this.state.expanded = state.expanded; + this.scrollTop = state.scrollTop; + } + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Expands category values holding the default value of a category. + * @private + */ + _expandDefaultValue() { + if (this.hasImportedState) { + return; + } + const categories = this.model.get("sections", s => s.type === "category"); + for (const category of categories) { + this.state.expanded[category.id] = {}; + if (category.activeValueId) { + const ancestorIds = this._getAncestorValueIds(category, category.activeValueId); + for (const ancestorId of ancestorIds) { + this.state.expanded[category.id][ancestorId] = true; + } + } + } + } + + /** + * @private + * @param {Object} category + * @param {number} categoryValueId + * @returns {number[]} list of ids of the ancestors of the given value in + * the given category. + */ + _getAncestorValueIds(category, categoryValueId) { + const { parentId } = category.values.get(categoryValueId); + return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : []; + } + + /** + * Prevent unnecessary calls to the model by ensuring a different category + * is clicked. + * @private + * @param {Object} category + * @param {Object} value + */ + async _toggleCategory(category, value) { + if (value.childrenIds.length) { + const categoryState = this.state.expanded[category.id]; + if (categoryState[value.id] && category.activeValueId === value.id) { + delete categoryState[value.id]; + } else { + categoryState[value.id] = true; + } + } + if (category.activeValueId !== value.id) { + this.state.active[category.id] = value.id; + this.model.dispatch("toggleCategoryValue", category.id, value.id); + } + } + + /** + * @private + * @param {number} filterId + * @param {{ values: Map<Object> }} group + */ + _toggleFilterGroup(filterId, { values }) { + const valueIds = []; + const checked = [...values.values()].every( + (value) => this.state.active[filterId][value.id] + ); + values.forEach(({ id }) => { + valueIds.push(id); + this.state.active[filterId][id] = !checked; + }); + this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked); + } + + /** + * @private + * @param {number} filterId + * @param {Object} [group] + * @param {number} valueId + * @param {MouseEvent} ev + */ + _toggleFilterValue(filterId, valueId, { currentTarget }) { + this.state.active[filterId][valueId] = currentTarget.checked; + this._updateGroupHeadersChecked(); + this.model.dispatch("toggleFilterValues", filterId, [valueId]); + } + + _updateActiveValues() { + for (const section of this.model.get("sections")) { + if (section.type === "category") { + this.state.active[section.id] = section.activeValueId; + } else { + this.state.active[section.id] = {}; + if (section.groups) { + for (const group of section.groups.values()) { + for (const value of group.values.values()) { + this.state.active[section.id][value.id] = value.checked; + } + } + } + if (section && section.values) { + for (const value of section.values.values()) { + this.state.active[section.id][value.id] = value.checked; + } + } + } + } + } + + /** + * Updates the "checked" or "indeterminate" state of each of the group + * headers according to the state of their values. + * @private + */ + _updateGroupHeadersChecked() { + const groups = this.el.querySelectorAll(":scope .o_search_panel_filter_group"); + for (const group of groups) { + const header = group.querySelector(":scope .o_search_panel_group_header input"); + const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")]; + header.checked = false; + header.indeterminate = false; + if (vals.every((v) => v.checked)) { + header.checked = true; + } else if (vals.some((v) => v.checked)) { + header.indeterminate = true; + } + } + } + } + SearchPanel.modelExtension = "SearchPanel"; + + SearchPanel.props = { + className: { type: String, optional: 1 }, + importedState: { type: String, optional: 1 }, + searchModel: Model, + }; + SearchPanel.template = "web.SearchPanel"; + + return patchMixin(SearchPanel); +}); diff --git a/addons/web/static/src/js/views/search_panel_model_extension.js b/addons/web/static/src/js/views/search_panel_model_extension.js new file mode 100644 index 00000000..48466f8d --- /dev/null +++ b/addons/web/static/src/js/views/search_panel_model_extension.js @@ -0,0 +1,789 @@ +odoo.define("web/static/src/js/views/search_panel_model_extension.js", function (require) { + "use strict"; + + const ActionModel = require("web/static/src/js/views/action_model.js"); + const { sortBy } = require("web.utils"); + const Domain = require("web.Domain"); + const pyUtils = require("web.py_utils"); + + // DefaultViewTypes is the list of view types for which the searchpanel is + // present by default (if not explicitly stated in the "view_types" attribute + // in the arch). + const DEFAULT_VIEW_TYPES = ["kanban", "tree"]; + const DEFAULT_LIMIT = 200; + let nextSectionId = 1; + + /** + * @param {Filter} filter + * @returns {boolean} + */ + function hasDomain(filter) { + return filter.domain !== "[]"; + } + + /** + * @param {Section} section + * @returns {boolean} + */ + function hasValues({ errorMsg, groups, type, values }) { + if (errorMsg) { + return true; + } else if (groups) { + return [...groups.values()].some((g) => g.values.size); + } else if (type === "category") { + return values && values.size > 1; // false item ignored + } else { + return values && values.size > 0; + } + } + + /** + * Returns a serialised array of the given map with its values being the + * shallow copies of the original values. + * @param {Map<any, Object>} map + * @return {Array[]} + */ + function serialiseMap(map) { + return [...map].map(([key, val]) => [key, Object.assign({}, val)]); + } + + /** + * @typedef Section + * @prop {string} color + * @prop {string} description + * @prop {boolean} enableCounters + * @prop {boolean} expand + * @prop {string} fieldName + * @prop {string} icon + * @prop {number} id + * @prop {number} index + * @prop {number} limit + * @prop {string} type + */ + + /** + * @typedef {Section} Category + * @prop {boolean} hierarchize + */ + + /** + * @typedef {Section} Filter + * @prop {string} domain + * @prop {string} groupBy + */ + + /** + * @function sectionPredicate + * @param {Section} section + * @returns {boolean} + */ + + /** + * @property {{ sections: Map<number, Section> }} state + * @extends ActionModel.Extension + */ + class SearchPanelModelExtension extends ActionModel.Extension { + constructor() { + super(...arguments); + + this.categoriesToLoad = []; + this.defaultValues = {}; + this.filtersToLoad = []; + this.initialStateImport = false; + this.searchDomain = []; + for (const key in this.config.context) { + const match = /^searchpanel_default_(.*)$/.exec(key); + if (match) { + this.defaultValues[match[1]] = this.config.context[key]; + } + } + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + */ + async callLoad(params) { + const searchDomain = this._getExternalDomain(); + params.searchDomainChanged = ( + JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain) + ); + if (!this.shouldLoad && !this.initialStateImport) { + const isFetchable = (section) => section.enableCounters || + (params.searchDomainChanged && !section.expand); + this.categoriesToLoad = this.categories.filter(isFetchable); + this.filtersToLoad = this.filters.filter(isFetchable); + this.shouldLoad = params.searchDomainChanged || + Boolean(this.categoriesToLoad.length + this.filtersToLoad.length); + } + this.searchDomain = searchDomain; + this.initialStateImport = false; + await super.callLoad(params); + } + + /** + * @override + */ + exportState() { + const state = Object.assign({}, super.exportState()); + state.sections = serialiseMap(state.sections); + for (const [id, section] of state.sections) { + section.values = serialiseMap(section.values); + if (section.groups) { + section.groups = serialiseMap(section.groups); + for (const [id, group] of section.groups) { + group.values = serialiseMap(group.values); + } + } + } + return state; + } + + /** + * @override + * @returns {any} + */ + get(property, ...args) { + switch (property) { + case "domain": return this.getDomain(); + case "sections": return this.getSections(...args); + } + } + + /** + * @override + */ + importState(importedState) { + this.initialStateImport = Boolean(importedState && !this.state.sections); + super.importState(...arguments); + if (importedState) { + this.state.sections = new Map(this.state.sections); + for (const section of this.state.sections.values()) { + section.values = new Map(section.values); + if (section.groups) { + section.groups = new Map(section.groups); + for (const group of section.groups.values()) { + group.values = new Map(group.values); + } + } + } + } + } + + /** + * @override + */ + async isReady() { + await this.sectionsPromise; + } + + /** + * @override + */ + async load(params) { + this.sectionsPromise = this._fetchSections(params.isInitialLoad); + if (this._shouldWaitForData(params)) { + await this.sectionsPromise; + } + } + + /** + * @override + */ + prepareState() { + Object.assign(this.state, { sections: new Map() }); + this._createSectionsFromArch(); + } + + //--------------------------------------------------------------------- + // Actions / Getters + //--------------------------------------------------------------------- + + /** + * Returns the concatenation of the category domain ad the filter + * domain. + * @returns {Array[]} + */ + getDomain() { + return Domain.prototype.normalizeArray([ + ...this._getCategoryDomain(), + ...this._getFilterDomain(), + ]); + } + + /** + * Returns a sorted list of a copy of all sections. This list can be + * filtered by a given predicate. + * @param {sectionPredicate} [predicate] used to determine + * which subsets of sections is wanted + * @returns {Section[]} + */ + getSections(predicate) { + let sections = [...this.state.sections.values()].map((section) => + Object.assign({}, section, { empty: !hasValues(section) }) + ); + if (predicate) { + sections = sections.filter(predicate); + } + return sections.sort((s1, s2) => s1.index - s2.index); + } + + /** + * Sets the active value id of a given category. + * @param {number} sectionId + * @param {number} valueId + */ + toggleCategoryValue(sectionId, valueId) { + const category = this.state.sections.get(sectionId); + category.activeValueId = valueId; + } + + /** + * Toggles a the filter value of a given section. The value will be set + * to "forceTo" if provided, else it will be its own opposed value. + * @param {number} sectionId + * @param {number[]} valueIds + * @param {boolean} [forceTo=null] + */ + toggleFilterValues(sectionId, valueIds, forceTo = null) { + const section = this.state.sections.get(sectionId); + for (const valueId of valueIds) { + const value = section.values.get(valueId); + value.checked = forceTo === null ? !value.checked : forceTo; + } + } + + //--------------------------------------------------------------------- + // Internal getters + //--------------------------------------------------------------------- + + /** + * Shorthand access to sections of type "category". + * @returns {Category[]} + */ + get categories() { + return [...this.state.sections.values()].filter(s => s.type === "category"); + } + + /** + * Shorthand access to sections of type "filter". + * @returns {Filter[]} + */ + get filters() { + return [...this.state.sections.values()].filter(s => s.type === "filter"); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Sets active values for each filter (coming from context). This needs + * to be done only once at initialization. + * @private + */ + _applyDefaultFilterValues() { + for (const { fieldName, values } of this.filters) { + const defaultValues = this.defaultValues[fieldName] || []; + for (const valueId of defaultValues) { + const value = values.get(valueId); + if (value) { + value.checked = true; + } + } + } + } + + /** + * @private + * @param {string} sectionId + * @param {Object} result + */ + _createCategoryTree(sectionId, result) { + const category = this.state.sections.get(sectionId); + + let { error_msg, parent_field: parentField, values, } = result; + if (error_msg) { + category.errorMsg = error_msg; + values = []; + } + if (category.hierarchize) { + category.parentField = parentField; + } + for (const value of values) { + category.values.set( + value.id, + Object.assign({}, value, { + childrenIds: [], + parentId: value[parentField] || false, + }) + ); + } + for (const value of values) { + const { parentId } = category.values.get(value.id); + if (parentId && category.values.has(parentId)) { + category.values.get(parentId).childrenIds.push(value.id); + } + } + // collect rootIds + category.rootIds = [false]; + for (const value of values) { + const { parentId } = category.values.get(value.id); + if (!parentId) { + category.rootIds.push(value.id); + } + } + // Set active value from context + const valueIds = [false, ...values.map((val) => val.id)]; + this._ensureCategoryValue(category, valueIds); + } + + /** + * @private + * @param {string} sectionId + * @param {Object} result + */ + _createFilterTree(sectionId, result) { + const filter = this.state.sections.get(sectionId); + + let { error_msg, values, } = result; + if (error_msg) { + filter.errorMsg = error_msg; + values = []; + } + + // restore checked property + values.forEach((value) => { + const oldValue = filter.values.get(value.id); + value.checked = oldValue ? oldValue.checked : false; + }); + + filter.values = new Map(); + const groupIds = []; + if (filter.groupBy) { + const groups = new Map(); + for (const value of values) { + const groupId = value.group_id; + if (!groups.has(groupId)) { + if (groupId) { + groupIds.push(groupId); + } + groups.set(groupId, { + id: groupId, + name: value.group_name, + values: new Map(), + tooltip: value.group_tooltip, + sequence: value.group_sequence, + hex_color: value.group_hex_color, + }); + // restore former checked state + const oldGroup = + filter.groups && filter.groups.get(groupId); + groups.get(groupId).state = + (oldGroup && oldGroup.state) || false; + } + groups.get(groupId).values.set(value.id, value); + } + filter.groups = groups; + filter.sortedGroupIds = sortBy( + groupIds, + (id) => groups.get(id).sequence || groups.get(id).name + ); + for (const group of filter.groups.values()) { + for (const [valueId, value] of group.values) { + filter.values.set(valueId, value); + } + } + } else { + for (const value of values) { + filter.values.set(value.id, value); + } + } + } + + /** + * Adds a section in this.state.sections for each visible field found + * in the search panel arch. + * @private + */ + _createSectionsFromArch() { + let hasCategoryWithCounters = false; + let hasFilterWithDomain = false; + this.config.archNodes.forEach(({ attrs, tag }, index) => { + if (tag !== "field" || attrs.invisible === "1") { + return; + } + const type = attrs.select === "multi" ? "filter" : "category"; + const section = { + color: attrs.color, + description: + attrs.string || this.config.fields[attrs.name].string, + enableCounters: !!pyUtils.py_eval( + attrs.enable_counters || "0" + ), + expand: !!pyUtils.py_eval(attrs.expand || "0"), + fieldName: attrs.name, + icon: attrs.icon, + id: nextSectionId++, + index, + limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)), + type, + values: new Map(), + }; + if (type === "category") { + section.activeValueId = this.defaultValues[attrs.name]; + section.icon = section.icon || "fa-folder"; + section.hierarchize = !!pyUtils.py_eval( + attrs.hierarchize || "1" + ); + section.values.set(false, { + childrenIds: [], + display_name: this.env._t("All"), + id: false, + bold: true, + parentId: false, + }); + hasCategoryWithCounters = hasCategoryWithCounters || section.enableCounters; + } else { + section.domain = attrs.domain || "[]"; + section.groupBy = attrs.groupby; + section.icon = section.icon || "fa-filter"; + hasFilterWithDomain = hasFilterWithDomain || section.domain !== "[]"; + } + this.state.sections.set(section.id, section); + }); + /** + * Category counters are automatically disabled if a filter domain is found + * to avoid inconsistencies with the counters. The underlying problem could + * actually be solved by reworking the search panel and the way the + * counters are computed, though this is not the current priority + * considering the time it would take, hence this quick "fix". + */ + if (hasCategoryWithCounters && hasFilterWithDomain) { + // If incompatibilities are found -> disables all category counters + for (const category of this.categories) { + category.enableCounters = false; + } + // ... and triggers a warning + console.warn( + "Warning: categories with counters are incompatible with filters having a domain attribute.", + "All category counters have been disabled to avoid inconsistencies.", + ); + } + } + + /** + * Ensures that the active value of a category is one of its own + * existing values. + * @private + * @param {Category} category + * @param {number[]} valueIds + */ + _ensureCategoryValue(category, valueIds) { + if (!valueIds.includes(category.activeValueId)) { + category.activeValueId = valueIds[0]; + } + } + + /** + * Fetches values for each category at startup. At reload a category is + * only fetched if needed. + * @private + * @param {Category[]} categories + * @returns {Promise} resolved when all categories have been fetched + */ + async _fetchCategories(categories) { + const filterDomain = this._getFilterDomain(); + await Promise.all(categories.map(async (category) => { + const result = await this.env.services.rpc({ + method: "search_panel_select_range", + model: this.config.modelName, + args: [category.fieldName], + kwargs: { + category_domain: this._getCategoryDomain(category.id), + enable_counters: category.enableCounters, + expand: category.expand, + filter_domain: filterDomain, + hierarchize: category.hierarchize, + limit: category.limit, + search_domain: this.searchDomain, + }, + }); + this._createCategoryTree(category.id, result); + })); + } + + /** + * Fetches values for each filter. This is done at startup and at each + * reload if needed. + * @private + * @param {Filter[]} filters + * @returns {Promise} resolved when all filters have been fetched + */ + async _fetchFilters(filters) { + const evalContext = {}; + for (const category of this.categories) { + evalContext[category.fieldName] = category.activeValueId; + } + const categoryDomain = this._getCategoryDomain(); + await Promise.all(filters.map(async (filter) => { + const result = await this.env.services.rpc({ + method: "search_panel_select_multi_range", + model: this.config.modelName, + args: [filter.fieldName], + kwargs: { + category_domain: categoryDomain, + comodel_domain: Domain.prototype.stringToArray( + filter.domain, + evalContext + ), + enable_counters: filter.enableCounters, + filter_domain: this._getFilterDomain(filter.id), + expand: filter.expand, + group_by: filter.groupBy || false, + group_domain: this._getGroupDomain(filter), + limit: filter.limit, + search_domain: this.searchDomain, + }, + }); + this._createFilterTree(filter.id, result); + })); + } + + /** + * @private + * @param {boolean} isInitialLoad + * @returns {Promise} + */ + async _fetchSections(isInitialLoad) { + await this._fetchCategories( + isInitialLoad ? this.categories : this.categoriesToLoad + ); + await this._fetchFilters( + isInitialLoad ? this.filters : this.filtersToLoad + ); + if (isInitialLoad) { + this._applyDefaultFilterValues(); + } + } + + /** + * Computes and returns the domain based on the current active + * categories. If "excludedCategoryId" is provided, the category with + * that id is not taken into account in the domain computation. + * @private + * @param {string} [excludedCategoryId] + * @returns {Array[]} + */ + _getCategoryDomain(excludedCategoryId) { + const domain = []; + for (const category of this.categories) { + if ( + category.id === excludedCategoryId || + !category.activeValueId + ) { + continue; + } + const field = this.config.fields[category.fieldName]; + const operator = + field.type === "many2one" && category.parentField ? "child_of" : "="; + domain.push([ + category.fieldName, + operator, + category.activeValueId, + ]); + } + return domain; + } + + /** + * Returns the domain retrieved from the other model extensions. + * @private + * @returns {Array[]} + */ + _getExternalDomain() { + const domains = this.config.get("domain"); + const domain = domains.reduce((acc, dom) => [...acc, ...dom], []); + return Domain.prototype.normalizeArray(domain); + } + + /** + * Computes and returns the domain based on the current checked + * filters. The values of a single filter are combined using a simple + * rule: checked values within a same group are combined with an "OR" + * operator (this is expressed as single condition using a list) and + * groups are combined with an "AND" operator (expressed by + * concatenation of conditions). + * If a filter has no group, its checked values are implicitely + * considered as forming a group (and grouped using an "OR"). + * If excludedFilterId is provided, the filter with that id is not + * taken into account in the domain computation. + * @private + * @param {string} [excludedFilterId] + * @returns {Array[]} + */ + _getFilterDomain(excludedFilterId) { + const domain = []; + + function addCondition(fieldName, valueMap) { + const ids = []; + for (const [valueId, value] of valueMap) { + if (value.checked) { + ids.push(valueId); + } + } + if (ids.length) { + domain.push([fieldName, "in", ids]); + } + } + + for (const filter of this.filters) { + if (filter.id === excludedFilterId) { + continue; + } + const { fieldName, groups, values } = filter; + if (groups) { + for (const group of groups.values()) { + addCondition(fieldName, group.values); + } + } else { + addCondition(fieldName, values); + } + } + return domain; + } + + /** + * Returns a domain or an object of domains used to complement + * the filter domains to accurately describe the constrains on + * records when computing record counts associated to the filter + * values (if a groupBy is provided). The idea is that the checked + * values within a group should not impact the counts for the other + * values in the same group. + * @private + * @param {string} filter + * @returns {Object<string, Array[]> | Array[] | null} + */ + _getGroupDomain(filter) { + const { fieldName, groups, enableCounters } = filter; + const { type: fieldType } = this.config.fields[fieldName]; + + if (!enableCounters || !groups) { + return { + many2one: [], + many2many: {}, + }[fieldType]; + } + let groupDomain = null; + if (fieldType === "many2one") { + for (const group of groups.values()) { + const valueIds = []; + let active = false; + for (const [valueId, value] of group.values) { + const { checked } = value; + valueIds.push(valueId); + if (checked) { + active = true; + } + } + if (active) { + if (groupDomain) { + groupDomain = [[0, "=", 1]]; + break; + } else { + groupDomain = [[fieldName, "in", valueIds]]; + } + } + } + } else if (fieldType === "many2many") { + const checkedValueIds = new Map(); + groups.forEach(({ values }, groupId) => { + values.forEach(({ checked }, valueId) => { + if (checked) { + if (!checkedValueIds.has(groupId)) { + checkedValueIds.set(groupId, []); + } + checkedValueIds.get(groupId).push(valueId); + } + }); + }); + groupDomain = {}; + for (const [gId, ids] of checkedValueIds.entries()) { + for (const groupId of groups.keys()) { + if (gId !== groupId) { + const key = JSON.stringify(groupId); + if (!groupDomain[key]) { + groupDomain[key] = []; + } + groupDomain[key].push([fieldName, "in", ids]); + } + } + } + } + return groupDomain; + } + + /** + * Returns whether the query informations should be considered as ready + * before or after having (re-)fetched the sections data. + * @private + * @param {Object} params + * @param {boolean} params.isInitialLoad + * @param {boolean} params.searchDomainChanged + * @returns {boolean} + */ + _shouldWaitForData({ isInitialLoad, searchDomainChanged }) { + if (isInitialLoad && Object.keys(this.defaultValues).length) { + // Default values need to be checked on initial load + return true; + } + if (this.categories.length && this.filters.some(hasDomain)) { + // Selected category value might affect the filter values + return true; + } + if (!this.searchDomain.length) { + // No search domain -> no need to check for expand + return false; + } + return [...this.state.sections.values()].some( + (section) => !section.expand && searchDomainChanged + ); + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @override + * @returns {{ attrs: Object, children: Object[] } | null} + */ + static extractArchInfo(archs, viewType) { + const { children } = archs.search; + const spNode = children.find(c => c.tag === "searchpanel"); + const isObject = (obj) => typeof obj === "object" && obj !== null; + if (spNode) { + const actualType = viewType === "list" ? "tree" : viewType; + const { view_types } = spNode.attrs; + const viewTypes = view_types ? + view_types.split(",") : + DEFAULT_VIEW_TYPES; + if (viewTypes.includes(actualType)) { + return { + attrs: spNode.attrs, + children: spNode.children.filter(isObject), + }; + } + } + return null; + } + } + SearchPanelModelExtension.layer = 1; + + ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30); + + return SearchPanelModelExtension; +}); diff --git a/addons/web/static/src/js/views/select_create_controllers_registry.js b/addons/web/static/src/js/views/select_create_controllers_registry.js new file mode 100644 index 00000000..2cbc0fd0 --- /dev/null +++ b/addons/web/static/src/js/views/select_create_controllers_registry.js @@ -0,0 +1,60 @@ +odoo.define('web.select_create_controllers_registry', function (require) { +"use strict"; + +return {}; + +}); + +odoo.define('web._select_create_controllers_registry', function (require) { +"use strict"; + +var KanbanController = require('web.KanbanController'); +var ListController = require('web.ListController'); +var select_create_controllers_registry = require('web.select_create_controllers_registry'); + +var SelectCreateKanbanController = KanbanController.extend({ + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Override to select the clicked record instead of opening it + * + * @override + * @private + */ + _onOpenRecord: function (ev) { + var selectedRecord = this.model.get(ev.data.id); + this.trigger_up('select_record', { + id: selectedRecord.res_id, + display_name: selectedRecord.data.display_name, + }); + }, +}); + +var SelectCreateListController = ListController.extend({ + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Override to select the clicked record instead of opening it + * + * @override + * @private + */ + _onOpenRecord: function (ev) { + var selectedRecord = this.model.get(ev.data.id); + this.trigger_up('select_record', { + id: selectedRecord.res_id, + display_name: selectedRecord.data.display_name, + }); + }, +}); + +_.extend(select_create_controllers_registry, { + SelectCreateListController: SelectCreateListController, + SelectCreateKanbanController: SelectCreateKanbanController, +}); + +}); diff --git a/addons/web/static/src/js/views/signature_dialog.js b/addons/web/static/src/js/views/signature_dialog.js new file mode 100644 index 00000000..12bb18f4 --- /dev/null +++ b/addons/web/static/src/js/views/signature_dialog.js @@ -0,0 +1,121 @@ +odoo.define('web.signature_dialog', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var NameAndSignature = require('web.name_and_signature').NameAndSignature; + +var _t = core._t; + +// The goal of this dialog is to ask the user a signature request. +// It uses @see SignNameAndSignature for the name and signature fields. +var SignatureDialog = Dialog.extend({ + template: 'web.signature_dialog', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web/static/src/xml/name_and_signature.xml'] + ), + custom_events: { + 'signature_changed': '_onChangeSignature', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {Object} options + * @param {string} [options.title='Adopt Your Signature'] - modal title + * @param {string} [options.size='medium'] - modal size + * @param {Object} [options.nameAndSignatureOptions={}] - options for + * @see NameAndSignature.init() + */ + init: function (parent, options) { + var self = this; + options = options || {}; + + options.title = options.title || _t("Adopt Your Signature"); + options.size = options.size || 'medium'; + options.technical = false; + + if (!options.buttons) { + options.buttons = []; + options.buttons.push({text: _t("Adopt and Sign"), classes: "btn-primary", disabled: true, click: function (e) { + self._onConfirm(); + }}); + options.buttons.push({text: _t("Cancel"), close: true}); + } + + this._super(parent, options); + + this.nameAndSignature = new NameAndSignature(this, options.nameAndSignatureOptions); + }, + /** + * Start the nameAndSignature widget and wait for it. + * + * @override + */ + willStart: function () { + return Promise.all([ + this.nameAndSignature.appendTo($('<div>')), + this._super.apply(this, arguments) + ]); + }, + /** + * Initialize the name and signature widget when the modal is opened. + * + * @override + */ + start: function () { + var self = this; + this.$primaryButton = this.$footer.find('.btn-primary'); + + this.opened().then(function () { + self.$('.o_web_sign_name_and_signature').replaceWith(self.nameAndSignature.$el); + // initialize the signature area + self.nameAndSignature.resetSignature(); + }); + + return this._super.apply(this, arguments); + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns whether the drawing area is currently empty. + * + * @see NameAndSignature.isSignatureEmpty() + * @returns {boolean} Whether the drawing area is currently empty. + */ + isSignatureEmpty: function () { + return this.nameAndSignature.isSignatureEmpty(); + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Toggles the submit button depending on the signature state. + * + * @private + */ + _onChangeSignature: function () { + var isEmpty = this.nameAndSignature.isSignatureEmpty(); + this.$primaryButton.prop('disabled', isEmpty); + }, + /** + * Upload the signature image when confirm. + * + * @private + */ + _onConfirm: function (fct) { + this.trigger_up('upload_signature', { + name: this.nameAndSignature.getName(), + signatureImage: this.nameAndSignature.getSignatureImage(), + }); + }, +}); + +return SignatureDialog; + +}); diff --git a/addons/web/static/src/js/views/standalone_field_manager_mixin.js b/addons/web/static/src/js/views/standalone_field_manager_mixin.js new file mode 100644 index 00000000..501ecf7c --- /dev/null +++ b/addons/web/static/src/js/views/standalone_field_manager_mixin.js @@ -0,0 +1,64 @@ +odoo.define('web.StandaloneFieldManagerMixin', function (require) { +"use strict"; + + +var FieldManagerMixin = require('web.FieldManagerMixin'); + +/** + * The StandaloneFieldManagerMixin is a mixin, designed to be used by a widget + * that instanciates its own field widgets. + * + * @mixin + * @name StandaloneFieldManagerMixin + * @mixes FieldManagerMixin + * @property {Function} _confirmChange + * @property {Function} _registerWidget + */ +var StandaloneFieldManagerMixin = _.extend({}, FieldManagerMixin, { + + /** + * @override + */ + init: function () { + FieldManagerMixin.init.apply(this, arguments); + + // registeredWidgets is a dict of all field widgets used by the widget + this.registeredWidgets = {}; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method will be called whenever a field value has changed (and has + * been confirmed by the model). + * + * @private + * @param {string} id basicModel Id for the changed record + * @param {string[]} fields the fields (names) that have been changed + * @param {OdooEvent} event the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, event) { + var result = FieldManagerMixin._confirmChange.apply(this, arguments); + var record = this.model.get(id); + _.each(this.registeredWidgets[id], function (widget, fieldName) { + if (_.contains(fields, fieldName)) { + widget.reset(record, event); + } + }); + return result; + }, + + _registerWidget: function (datapointID, fieldName, widget) { + if (!this.registeredWidgets[datapointID]) { + this.registeredWidgets[datapointID] = {}; + } + this.registeredWidgets[datapointID][fieldName] = widget; + }, +}); + +return StandaloneFieldManagerMixin; + +}); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js new file mode 100644 index 00000000..21004ed9 --- /dev/null +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -0,0 +1,484 @@ +odoo.define('web.view_dialogs', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dom = require('web.dom'); +var view_registry = require('web.view_registry'); +var select_create_controllers_registry = require('web.select_create_controllers_registry'); + +var _t = core._t; + +/** + * Class with everything which is common between FormViewDialog and + * SelectCreateDialog. + */ +var ViewDialog = Dialog.extend({ + custom_events: _.extend({}, Dialog.prototype.custom_events, { + push_state: '_onPushState', + }), + /** + * @constructor + * @param {Widget} parent + * @param {options} [options] + * @param {string} [options.dialogClass=o_act_window] + * @param {string} [options.res_model] the model of the record(s) to open + * @param {any[]} [options.domain] + * @param {Object} [options.context] + */ + init: function (parent, options) { + options = options || {}; + options.fullscreen = config.device.isMobile; + options.dialogClass = options.dialogClass || '' + ' o_act_window'; + + this._super(parent, $.extend(true, {}, options)); + + this.res_model = options.res_model || null; + this.domain = options.domain || []; + this.context = options.context || {}; + this.options = _.extend(this.options || {}, options || {}); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * We stop all push_state events from bubbling up. It would be weird to + * change the url because a dialog opened. + * + * @param {OdooEvent} event + */ + _onPushState: function (event) { + event.stopPropagation(); + }, +}); + +/** + * Create and edit dialog (displays a form view record and leave once saved) + */ +var FormViewDialog = ViewDialog.extend({ + /** + * @param {Widget} parent + * @param {Object} [options] + * @param {string} [options.parentID] the id of the parent record. It is + * useful for situations such as a one2many opened in a form view dialog. + * In that case, we want to be able to properly evaluate domains with the + * 'parent' key. + * @param {integer} [options.res_id] the id of the record to open + * @param {Object} [options.form_view_options] dict of options to pass to + * the Form View @todo: make it work + * @param {Object} [options.fields_view] optional form fields_view + * @param {boolean} [options.readonly=false] only applicable when not in + * creation mode + * @param {boolean} [options.deletable=false] whether or not the record can + * be deleted + * @param {boolean} [options.disable_multiple_selection=false] set to true + * to remove the possibility to create several records in a row + * @param {function} [options.on_saved] callback executed after saving a + * record. It will be called with the record data, and a boolean which + * indicates if something was changed + * @param {function} [options.on_remove] callback executed when the user + * clicks on the 'Remove' button + * @param {BasicModel} [options.model] if given, it will be used instead of + * a new form view model + * @param {string} [options.recordID] if given, the model has to be given as + * well, and in that case, it will be used without loading anything. + * @param {boolean} [options.shouldSaveLocally] if true, the view dialog + * will save locally instead of actually saving (useful for one2manys) + * @param {function} [options._createContext] function to get context for name field + * useful for many2many_tags widget where we want to removed default_name field + * context. + */ + init: function (parent, options) { + var self = this; + options = options || {}; + + this.res_id = options.res_id || null; + this.on_saved = options.on_saved || (function () {}); + this.on_remove = options.on_remove || (function () {}); + this.context = options.context; + this._createContext = options._createContext; + this.model = options.model; + this.parentID = options.parentID; + this.recordID = options.recordID; + this.shouldSaveLocally = options.shouldSaveLocally; + this.readonly = options.readonly; + this.deletable = options.deletable; + this.disable_multiple_selection = options.disable_multiple_selection; + var oBtnRemove = 'o_btn_remove'; + + var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection; + var readonly = _.isNumber(options.res_id) && options.readonly; + + if (!options.buttons) { + options.buttons = [{ + text: options.close_text || (readonly ? _t("Close") : _t("Discard")), + classes: "btn-secondary o_form_button_cancel", + close: true, + click: function () { + if (!readonly) { + self.form_view.model.discardChanges(self.form_view.handle, { + rollback: self.shouldSaveLocally, + }); + } + }, + }]; + + if (!readonly) { + options.buttons.unshift({ + text: options.save_text || (multi_select ? _t("Save & Close") : _t("Save")), + classes: "btn-primary", + click: function () { + self._save().then(self.close.bind(self)); + } + }); + + if (multi_select) { + options.buttons.splice(1, 0, { + text: _t("Save & New"), + classes: "btn-primary", + click: function () { + self._save() + .then(function () { + // reset default name field from context when Save & New is clicked, pass additional + // context so that when getContext is called additional context resets it + var additionalContext = self._createContext && self._createContext(false) || {}; + self.form_view.createRecord(self.parentID, additionalContext); + }) + .then(function () { + if (!self.deletable) { + return; + } + self.deletable = false; + self.buttons = self.buttons.filter(function (button) { + return button.classes.split(' ').indexOf(oBtnRemove) < 0; + }); + self.set_buttons(self.buttons); + self.set_title(_t("Create ") + _.str.strRight(self.title, _t("Open: "))); + }); + }, + }); + } + + var multi = options.disable_multiple_selection; + if (!multi && this.deletable) { + this._setRemoveButtonOption(options, oBtnRemove); + } + } + } + this._super(parent, options); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the form view dialog. It is necessarily asynchronous, but this + * method returns immediately. + * + * @returns {FormViewDialog} this instance + */ + open: function () { + var self = this; + var _super = this._super.bind(this); + var FormView = view_registry.get('form'); + var fields_view_def; + if (this.options.fields_view) { + fields_view_def = Promise.resolve(this.options.fields_view); + } else { + fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form'); + } + + fields_view_def.then(function (viewInfo) { + var refinedContext = _.pick(self.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); + var formview = new FormView(viewInfo, { + modelName: self.res_model, + context: refinedContext, + ids: self.res_id ? [self.res_id] : [], + currentId: self.res_id || undefined, + index: 0, + mode: self.res_id && self.options.readonly ? 'readonly' : 'edit', + footerToButtons: true, + default_buttons: false, + withControlPanel: false, + model: self.model, + parentID: self.parentID, + recordID: self.recordID, + isFromFormViewDialog: true, + }); + return formview.getController(self); + }).then(function (formView) { + self.form_view = formView; + var fragment = document.createDocumentFragment(); + if (self.recordID && self.shouldSaveLocally) { + self.model.save(self.recordID, {savePoint: true}); + } + return self.form_view.appendTo(fragment) + .then(function () { + self.opened().then(function () { + var $buttons = $('<div>'); + self.form_view.renderButtons($buttons); + if ($buttons.children().length) { + self.$footer.empty().append($buttons.contents()); + } + dom.append(self.$el, fragment, { + callbacks: [{widget: self.form_view}], + in_DOM: true, + }); + self.form_view.updateButtons(); + }); + return _super(); + }); + }); + + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _focusOnClose: function() { + var isFocusSet = false; + this.trigger_up('form_dialog_discarded', { + callback: function (isFocused) { + isFocusSet = isFocused; + }, + }); + return isFocusSet; + }, + + /** + * @private + */ + _remove: function () { + return Promise.resolve(this.on_remove()); + }, + + /** + * @private + * @returns {Promise} + */ + _save: function () { + var self = this; + return this.form_view.saveRecord(this.form_view.handle, { + stayInEdit: true, + reload: false, + savePoint: this.shouldSaveLocally, + viewType: 'form', + }).then(function (changedFields) { + // record might have been changed by the save (e.g. if this was a new record, it has an + // id now), so don't re-use the copy obtained before the save + var record = self.form_view.model.get(self.form_view.handle); + return self.on_saved(record, !!changedFields.length); + }); + }, + + /** + * Set the "remove" button into the options' buttons list + * + * @private + * @param {Object} options The options object to modify + * @param {string} btnClasses The classes for the remove button + */ + _setRemoveButtonOption(options, btnClasses) { + const self = this; + options.buttons.push({ + text: _t("Remove"), + classes: 'btn-secondary ' + btnClasses, + click: function() { + self._remove().then(self.close.bind(self)); + } + }); + }, +}); + +/** + * Search dialog (displays a list of records and permits to create a new one by switching to a form view) + */ +var SelectCreateDialog = ViewDialog.extend({ + custom_events: _.extend({}, ViewDialog.prototype.custom_events, { + select_record: function (event) { + if (!this.options.readonly) { + this.on_selected([event.data]); + this.close(); + } + }, + selection_changed: function (event) { + event.stopPropagation(); + this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length); + }, + }), + + /** + * options: + * - initial_ids + * - initial_view: form or search (default search) + * - list_view_options: dict of options to pass to the List View + * - on_selected: optional callback to execute when records are selected + * - disable_multiple_selection: true to allow create/select multiple records + * - dynamicFilters: filters to add to the searchview + */ + init: function () { + this._super.apply(this, arguments); + _.defaults(this.options, { initial_view: 'search' }); + this.on_selected = this.options.on_selected || (function () {}); + this.on_closed = this.options.on_closed || (function () {}); + this.initialIDs = this.options.initial_ids; + this.viewType = 'list'; + }, + + open: function () { + if (this.options.initial_view !== "search") { + return this.create_edit_record(); + } + var self = this; + var _super = this._super.bind(this); + var viewRefID = this.viewType === 'kanban' ? + (this.options.kanban_view_ref && JSON.parse(this.options.kanban_view_ref) || false) : false; + return this.loadViews(this.res_model, this.context, [[viewRefID, this.viewType], [false, 'search']], {load_filters: true}) + .then(this.setup.bind(this)) + .then(function (fragment) { + self.opened().then(function () { + dom.append(self.$el, fragment, { + callbacks: [{widget: self.viewController}], + in_DOM: true, + }); + self.set_buttons(self.__buttons); + }); + return _super(); + }); + }, + + setup: function (fieldsViews) { + var self = this; + var fragment = document.createDocumentFragment(); + + var domain = this.domain; + if (this.initialIDs) { + domain = domain.concat([['id', 'in', this.initialIDs]]); + } + var ViewClass = view_registry.get(this.viewType); + var viewOptions = {}; + var selectCreateController; + if (this.viewType === 'list') { // add listview specific options + _.extend(viewOptions, { + hasSelectors: !this.options.disable_multiple_selection, + readonly: true, + + }, this.options.list_view_options); + selectCreateController = select_create_controllers_registry.SelectCreateListController; + } + if (this.viewType === 'kanban') { + _.extend(viewOptions, { + noDefaultGroupby: true, + selectionMode: this.options.selectionMode || false, + }); + selectCreateController = select_create_controllers_registry.SelectCreateKanbanController; + } + var view = new ViewClass(fieldsViews[this.viewType], _.extend(viewOptions, { + action: { + controlPanelFieldsView: fieldsViews.search, + help: _.str.sprintf("<p>%s</p>", _t("No records found!")), + }, + action_buttons: false, + dynamicFilters: this.options.dynamicFilters, + context: this.context, + domain: domain, + modelName: this.res_model, + withBreadcrumbs: false, + withSearchPanel: false, + })); + view.setController(selectCreateController); + return view.getController(this).then(function (controller) { + self.viewController = controller; + // render the footer buttons + self._prepareButtons(); + return self.viewController.appendTo(fragment); + }).then(function () { + return fragment; + }); + }, + close: function () { + this._super.apply(this, arguments); + this.on_closed(); + }, + create_edit_record: function () { + var self = this; + var dialog = new FormViewDialog(this, _.extend({}, this.options, { + on_saved: function (record) { + var values = [{ + id: record.res_id, + display_name: record.data.display_name || record.data.name, + }]; + self.on_selected(values); + }, + })).open(); + dialog.on('closed', this, this.close); + return dialog; + }, + /** + * @override + */ + _focusOnClose: function() { + var isFocusSet = false; + this.trigger_up('form_dialog_discarded', { + callback: function (isFocused) { + isFocusSet = isFocused; + }, + }); + return isFocusSet; + }, + /** + * prepare buttons for dialog footer based on options + * + * @private + */ + _prepareButtons: function () { + this.__buttons = [{ + text: _t("Cancel"), + classes: 'btn-secondary o_form_button_cancel', + close: true, + }]; + if (!this.options.no_create) { + this.__buttons.unshift({ + text: _t("Create"), + classes: 'btn-primary', + click: this.create_edit_record.bind(this) + }); + } + if (!this.options.disable_multiple_selection) { + this.__buttons.unshift({ + text: _t("Select"), + classes: 'btn-primary o_select_button', + disabled: true, + close: true, + click: function () { + var records = this.viewController.getSelectedRecords(); + var values = _.map(records, function (record) { + return { + id: record.res_id, + display_name: record.data.display_name, + }; + }); + this.on_selected(values); + }, + }); + } + }, +}); + +return { + FormViewDialog: FormViewDialog, + SelectCreateDialog: SelectCreateDialog, +}; + +}); diff --git a/addons/web/static/src/js/views/view_registry.js b/addons/web/static/src/js/views/view_registry.js new file mode 100644 index 00000000..f936787b --- /dev/null +++ b/addons/web/static/src/js/views/view_registry.js @@ -0,0 +1,44 @@ +odoo.define('web.view_registry', function (require) { +"use strict"; + +/** + * This module defines the view_registry. Web views are added to the registry + * in the 'web._view_registry' module to avoid cyclic dependencies. + * Views defined in other addons should be added in this registry as well, + * ideally in another module than the one defining the view, in order to + * separate the declarative part of a module (the view definition) from its + * 'side-effects' part. + */ + +var Registry = require('web.Registry'); + +return new Registry(); + +}); + +odoo.define('web._view_registry', function (require) { +"use strict"; + +/** + * The purpose of this module is to add the web views in the view_registry. + * This can't be done directly in the module defining the view_registry as it + * would produce cyclic dependencies. + */ + +var FormView = require('web.FormView'); +var GraphView = require('web.GraphView'); +var KanbanView = require('web.KanbanView'); +var ListView = require('web.ListView'); +var PivotView = require('web.PivotView'); +var CalendarView = require('web.CalendarView'); +var view_registry = require('web.view_registry'); + +view_registry + .add('form', FormView) + .add('list', ListView) + .add('kanban', KanbanView) + .add('graph', GraphView) + .add('pivot', PivotView) + .add('calendar', CalendarView); + +}); diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js new file mode 100644 index 00000000..82cefe5b --- /dev/null +++ b/addons/web/static/src/js/views/view_utils.js @@ -0,0 +1,92 @@ +odoo.define('web.viewUtils', function (require) { +"use strict"; + +var dom = require('web.dom'); +var utils = require('web.utils'); + +var viewUtils = { + /** + * Returns the value of a group dataPoint, i.e. the value of the groupBy + * field for the records in that group. + * + * @param {Object} group dataPoint of type list, corresponding to a group + * @param {string} groupByField the name of the groupBy field + * @returns {string | integer | false} + */ + getGroupValue: function (group, groupByField) { + var groupedByField = group.fields[groupByField]; + switch (groupedByField.type) { + case 'many2one': + return group.res_id || false; + case 'selection': + var descriptor = _.find(groupedByField.selection, function (option) { + return option[1] === group.value; + }); + return descriptor && descriptor[0]; + case 'char': + case 'boolean': + return group.value; + default: + return false; // other field types are not handled + } + }, + /** + * States whether or not the quick create feature is available for the given + * datapoint, depending on its groupBy field. + * + * @param {Object} list dataPoint of type list + * @returns {Boolean} true iff the kanban quick create feature is available + */ + isQuickCreateEnabled: function (list) { + var groupByField = list.groupedBy[0] && list.groupedBy[0].split(':')[0]; + if (!groupByField) { + return false; + } + var availableTypes = ['char', 'boolean', 'many2one', 'selection']; + if (!_.contains(availableTypes, list.fields[groupByField].type)) { + return false; + } + return true; + }, + /** + * @param {string} arch view arch + * @returns {Object} parsed arch + */ + parseArch: function (arch) { + var doc = $.parseXML(arch).documentElement; + var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban'; + return utils.xml_to_json(doc, stripWhitespaces); + }, + /** + * Renders a button according to a given arch node element. + * + * @param {Object} node + * @param {Object} [options] + * @param {string} [options.extraClass] + * @param {boolean} [options.textAsTitle=false] + * @returns {jQuery} + */ + renderButtonFromNode: function (node, options) { + var btnOptions = { + attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options', 'effect'), + icon: node.attrs.icon, + }; + if (options && options.extraClass) { + var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : []; + btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' '); + } + var str = (node.attrs.string || '').replace(/_/g, ''); + if (str) { + if (options && options.textAsTitle) { + btnOptions.attrs.title = str; + } else { + btnOptions.text = str; + } + } + return dom.renderButton(btnOptions); + }, +}; + +return viewUtils; + +}); diff --git a/addons/web/static/src/js/widgets/attach_document.js b/addons/web/static/src/js/widgets/attach_document.js new file mode 100644 index 00000000..8ad48882 --- /dev/null +++ b/addons/web/static/src/js/widgets/attach_document.js @@ -0,0 +1,139 @@ +odoo.define('web.AttachDocument', function (require) { +"use static"; + +var core = require('web.core'); +var framework = require('web.framework'); +var widgetRegistry = require('web.widget_registry'); +var Widget = require('web.Widget'); + +var _t = core._t; + +var AttachDocument = Widget.extend({ + template: 'AttachDocument', + events: { + 'click': '_onClickAttachDocument', + 'change input.o_input_file': '_onFileChanged', + }, + /** + * @constructor + * @param {Widget} parent + * @param {Object} record + * @param {Object} nodeInfo + */ + init: function (parent, record, nodeInfo) { + this._super.apply(this, arguments); + this.res_id = record.res_id; + this.res_model = record.model; + this.state = record; + this.node = nodeInfo; + this.fileuploadID = _.uniqueId('o_fileupload'); + }, + /** + * @override + */ + start: function () { + $(window).on(this.fileuploadID, this._onFileLoaded.bind(this)); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + $(window).off(this.fileuploadID); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // private + //-------------------------------------------------------------------------- + + /** + * Helper function to display a warning that some fields have an invalid + * value. This is used when a save operation cannot be completed. + * + * @private + * @param {string[]} invalidFields - list of field names + */ + _notifyInvalidFields: function (invalidFields) { + var fields = this.state.fields; + var warnings = invalidFields.map(function (fieldName) { + var fieldStr = fields[fieldName].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("Invalid fields:"), warnings.join('')); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens File Explorer dialog if all fields are valid and record is saved + * + * @private + * @param {Event} ev + */ + _onClickAttachDocument: function (ev) { + if ($(ev.target).is('input.o_input_file')) { + return; + } + var fieldNames = this.getParent().canBeSaved(this.state.id); + if (fieldNames.length) { + return this._notifyInvalidFields(fieldNames); + } + // We want to save record on widget click and then open File Selection Explorer + // but due to this security restriction give warning to save record first. + // https://stackoverflow.com/questions/29728705/trigger-click-on-input-file-on-asynchronous-ajax-done/29873845#29873845 + if (!this.res_id) { + return this.do_warn(false, _t('Please save before attaching a file')); + } + this.$('input.o_input_file').trigger('click'); + }, + /** + * Submits file + * + * @private + * @param {Event} ev + */ + _onFileChanged: function (ev) { + ev.stopPropagation(); + this.$('form.o_form_binary_form').trigger('submit'); + framework.blockUI(); + }, + /** + * Call action given as node attribute after file submission + * + * @private + */ + _onFileLoaded: function () { + var self = this; + // the first argument isn't a file but the jQuery.Event + var files = Array.prototype.slice.call(arguments, 1); + return new Promise(function (resolve) { + if (self.node.attrs.action) { + self._rpc({ + model: self.res_model, + method: self.node.attrs.action, + args: [self.res_id], + kwargs: { + attachment_ids: _.map(files, function (file) { + return file.id; + }), + } + }).then(function () { + resolve(); + }); + } else { + resolve(); + } + }).then(function () { + self.trigger_up('reload'); + framework.unblockUI(); + }); + }, + +}); +widgetRegistry.add('attach_document', AttachDocument); +}); diff --git a/addons/web/static/src/js/widgets/change_password.js b/addons/web/static/src/js/widgets/change_password.js new file mode 100644 index 00000000..e75d8dfd --- /dev/null +++ b/addons/web/static/src/js/widgets/change_password.js @@ -0,0 +1,75 @@ +odoo.define('web.ChangePassword', function (require) { +"use strict"; + +/** + * This file defines a client action that opens in a dialog (target='new') and + * allows the user to change his password. + */ + +var AbstractAction = require('web.AbstractAction'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var web_client = require('web.web_client'); + +var _t = core._t; + +var ChangePassword = AbstractAction.extend({ + template: "ChangePassword", + + /** + * @fixme: weird interaction with the parent for the $buttons handling + * + * @override + * @returns {Promise} + */ + start: function () { + var self = this; + web_client.set_title(_t("Change Password")); + var $button = self.$('.oe_form_button'); + $button.appendTo(this.getParent().$footer); + $button.eq(1).click(function () { + self.$el.parents('.modal').modal('hide'); + }); + $button.eq(0).click(function () { + self._rpc({ + route: '/web/session/change_password', + params: { + fields: $('form[name=change_password_form]').serializeArray() + } + }) + .then(function (result) { + if (result.error) { + self._display_error(result); + } else { + self.do_action('logout'); + } + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Displays the error in a dialog + * + * @private + * @param {Object} error + * @param {string} error.error + * @param {string} error.title + */ + _display_error: function (error) { + return new Dialog(this, { + size: 'medium', + title: error.title, + $content: $('<div>').html(error.error) + }).open(); + }, +}); + +core.action_registry.add("change_password", ChangePassword); + +return ChangePassword; + +}); diff --git a/addons/web/static/src/js/widgets/colorpicker.js b/addons/web/static/src/js/widgets/colorpicker.js new file mode 100644 index 00000000..29e8c22a --- /dev/null +++ b/addons/web/static/src/js/widgets/colorpicker.js @@ -0,0 +1,699 @@ +odoo.define('web.Colorpicker', function (require) { +'use strict'; + +var core = require('web.core'); +var utils = require('web.utils'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); + +var _t = core._t; + +var ColorpickerWidget = Widget.extend({ + xmlDependencies: ['/web/static/src/xml/colorpicker.xml'], + template: 'Colorpicker', + events: { + 'click': '_onClick', + 'keypress': '_onKeypress', + 'mousedown .o_color_pick_area': '_onMouseDownPicker', + 'mousedown .o_color_slider': '_onMouseDownSlider', + 'mousedown .o_opacity_slider': '_onMouseDownOpacitySlider', + 'change .o_color_picker_inputs': '_onChangeInputs', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {Object} [options] + * @param {string} [options.defaultColor='#FF0000'] + * @param {string} [options.noTransparency=false] + */ + init: function (parent, options) { + this._super(...arguments); + options = options || {}; + this.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + this.colorComponents = {}; + this.uniqueId = _.uniqueId('colorpicker'); + + // Needs to be bound on document to work in all possible cases. + const $document = $(document); + $document.on(`mousemove.${this.uniqueId}`, _.throttle((ev) => { + this._onMouseMovePicker(ev); + this._onMouseMoveSlider(ev); + this._onMouseMoveOpacitySlider(ev); + }, 50)); + $document.on(`mouseup.${this.uniqueId}`, _.throttle((ev) => { + if (this.pickerFlag || this.sliderFlag || this.opacitySliderFlag) { + this._colorSelected(); + } + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + }, 10)); + + this.options = _.clone(options); + }, + /** + * @override + */ + start: function () { + this.$colorpickerArea = this.$('.o_color_pick_area'); + this.$colorpickerPointer = this.$('.o_picker_pointer'); + this.$colorSlider = this.$('.o_color_slider'); + this.$colorSliderPointer = this.$('.o_slider_pointer'); + this.$opacitySlider = this.$('.o_opacity_slider'); + this.$opacitySliderPointer = this.$('.o_opacity_pointer'); + + var defaultColor = this.options.defaultColor || '#FF0000'; + var rgba = ColorpickerWidget.convertCSSColorToRgba(defaultColor); + if (rgba) { + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + } + + // Pre-fill the inputs. This is because on safari, the baseline for empty + // input is not the baseline of where the text would be, but the bottom + // of the input itself. (see https://bugs.webkit.org/show_bug.cgi?id=142968) + // This will cause the first _updateUI to alter the layout of the colorpicker + // which will change its height. Changing the height of an element inside of + // the callback to a ResizeObserver observing it will cause an error + // (ResizeObserver loop completed with undelivered notifications) that cannot + // be caught, which will open the crash manager. Prefilling the inputs sets + // the baseline correctly from the start so the layout doesn't change. + Object.entries(this.colorComponents).forEach(([component, value]) => { + const input = this.el.querySelector(`.o_${component}_input`); + if (input) { + input.value = value; + } + }); + const resizeObserver = new window.ResizeObserver(() => { + this._updateUI(); + }); + resizeObserver.observe(this.el); + + this.previewActive = true; + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(document).off(`.${this.uniqueId}`); + }, + /** + * Sets the currently selected color + * + * @param {string} color rgb[a] + */ + setSelectedColor: function (color) { + var rgba = ColorpickerWidget.convertCSSColorToRgba(color); + if (rgba) { + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates input values, color preview, picker and slider pointer positions. + * + * @private + */ + _updateUI: function () { + var self = this; + + // Update inputs + _.each(this.colorComponents, function (value, color) { + self.$(_.str.sprintf('.o_%s_input', color)).val(value); + }); + + // Update preview + this.$('.o_color_preview').css('background-color', this.colorComponents.cssColor); + + // Update picker area and picker pointer position + this.$colorpickerArea.css('background-color', _.str.sprintf('hsl(%s, 100%%, 50%%)', this.colorComponents.hue)); + var top = (100 - this.colorComponents.lightness) * this.$colorpickerArea.height() / 100; + var left = this.colorComponents.saturation * this.$colorpickerArea.width() / 100; + this.$colorpickerPointer.css({ + top: (top - 5) + 'px', + left: (left - 5) + 'px', + }); + + // Update color slider position + var height = this.$colorSlider.height(); + var y = this.colorComponents.hue * height / 360; + this.$colorSliderPointer.css('top', Math.round(y - 2)); + + if (! this.options.noTransparency) { + // Update opacity slider position + var heightOpacity = this.$opacitySlider.height(); + var z = heightOpacity * (1 - this.colorComponents.opacity / 100.0); + this.$opacitySliderPointer.css('top', Math.round(z - 2)); + + // Add gradient color on opacity slider + this.$opacitySlider.css('background', 'linear-gradient(' + this.colorComponents.hex + ' 0%, transparent 100%)'); + } + }, + /** + * Updates colors according to given hex value. Opacity is left unchanged. + * + * @private + * @param {string} hex - hexadecimal code + */ + _updateHex: function (hex) { + var rgb = ColorpickerWidget.convertCSSColorToRgba(hex); + if (!rgb) { + return; + } + _.extend(this.colorComponents, + {hex: hex}, + rgb, + ColorpickerWidget.convertRgbToHsl(rgb.red, rgb.green, rgb.blue) + ); + this._updateCssColor(); + }, + /** + * Updates colors according to given RGB values. + * + * @private + * @param {integer} r + * @param {integer} g + * @param {integer} b + * @param {integer} [a] + */ + _updateRgba: function (r, g, b, a) { + // We update the hexadecimal code by transforming into a css color and + // ignoring the opacity (we don't display opacity component in hexa as + // not supported on all browsers) + var hex = ColorpickerWidget.convertRgbaToCSSColor(r, g, b); + if (!hex) { + return; + } + _.extend(this.colorComponents, + {red: r, green: g, blue: b}, + a === undefined ? {} : {opacity: a}, + {hex: hex}, + ColorpickerWidget.convertRgbToHsl(r, g, b) + ); + this._updateCssColor(); + }, + /** + * Updates colors according to given HSL values. + * + * @private + * @param {integer} h + * @param {integer} s + * @param {integer} l + */ + _updateHsl: function (h, s, l) { + var rgb = ColorpickerWidget.convertHslToRgb(h, s, l); + if (!rgb) { + return; + } + // We receive an hexa as we ignore the opacity + const hex = ColorpickerWidget.convertRgbaToCSSColor(rgb.red, rgb.green, rgb.blue); + _.extend(this.colorComponents, + {hue: h, saturation: s, lightness: l}, + rgb, + {hex: hex} + ); + this._updateCssColor(); + }, + /** + * Updates color opacity. + * + * @private + * @param {integer} a + */ + _updateOpacity: function (a) { + if (a < 0 || a > 100) { + return; + } + _.extend(this.colorComponents, + {opacity: a} + ); + this._updateCssColor(); + }, + /** + * Trigger an event to annonce that the widget value has changed + * + * @private + */ + _colorSelected: function () { + this.trigger_up('colorpicker_select', this.colorComponents); + }, + /** + * Updates css color representation. + * + * @private + */ + _updateCssColor: function () { + const r = this.colorComponents.red; + const g = this.colorComponents.green; + const b = this.colorComponents.blue; + const a = this.colorComponents.opacity; + _.extend(this.colorComponents, + {cssColor: ColorpickerWidget.convertRgbaToCSSColor(r, g, b, a)} + ); + if (this.previewActive) { + this.trigger_up('colorpicker_preview', this.colorComponents); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onKeypress: function (ev) { + if (ev.charCode === $.ui.keyCode.ENTER) { + if (ev.target.tagName === 'INPUT') { + this._onChangeInputs(ev); + } + ev.preventDefault(); + this.trigger_up('enter_key_color_colorpicker'); + } + }, + /** + * @param {Event} ev + */ + _onClick: function (ev) { + ev.originalEvent.__isColorpickerClick = true; + $(ev.target).find('> .o_opacity_pointer, > .o_slider_pointer, > .o_picker_pointer').addBack('.o_opacity_pointer, .o_slider_pointer, .o_picker_pointer').focus(); + }, + /** + * Updates color when the user starts clicking on the picker. + * + * @private + * @param {Event} ev + */ + _onMouseDownPicker: function (ev) { + this.pickerFlag = true; + ev.preventDefault(); + this._onMouseMovePicker(ev); + }, + /** + * Updates saturation and lightness values on mouse drag over picker. + * + * @private + * @param {Event} ev + */ + _onMouseMovePicker: function (ev) { + if (!this.pickerFlag) { + return; + } + + var offset = this.$colorpickerArea.offset(); + var top = ev.pageY - offset.top; + var left = ev.pageX - offset.left; + var saturation = Math.round(100 * left / this.$colorpickerArea.width()); + var lightness = Math.round(100 * (this.$colorpickerArea.height() - top) / this.$colorpickerArea.height()); + saturation = utils.confine(saturation, 0, 100); + lightness = utils.confine(lightness, 0, 100); + + this._updateHsl(this.colorComponents.hue, saturation, lightness); + this._updateUI(); + }, + /** + * Updates color when user starts clicking on slider. + * + * @private + * @param {Event} ev + */ + _onMouseDownSlider: function (ev) { + this.sliderFlag = true; + ev.preventDefault(); + this._onMouseMoveSlider(ev); + }, + /** + * Updates hue value on mouse drag over slider. + * + * @private + * @param {Event} ev + */ + _onMouseMoveSlider: function (ev) { + if (!this.sliderFlag) { + return; + } + + var y = ev.pageY - this.$colorSlider.offset().top; + var hue = Math.round(360 * y / this.$colorSlider.height()); + hue = utils.confine(hue, 0, 360); + + this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness); + this._updateUI(); + }, + /** + * Updates opacity when user starts clicking on opacity slider. + * + * @private + * @param {Event} ev + */ + _onMouseDownOpacitySlider: function (ev) { + this.opacitySliderFlag = true; + ev.preventDefault(); + this._onMouseMoveOpacitySlider(ev); + }, + /** + * Updates opacity value on mouse drag over opacity slider. + * + * @private + * @param {Event} ev + */ + _onMouseMoveOpacitySlider: function (ev) { + if (!this.opacitySliderFlag || this.options.noTransparency) { + return; + } + + var y = ev.pageY - this.$opacitySlider.offset().top; + var opacity = Math.round(100 * (1 - y / this.$opacitySlider.height())); + opacity = utils.confine(opacity, 0, 100); + + this._updateOpacity(opacity); + this._updateUI(); + }, + /** + * Called when input value is changed -> Updates UI: Set picker and slider + * position and set colors. + * + * @private + * @param {Event} ev + */ + _onChangeInputs: function (ev) { + switch ($(ev.target).data('colorMethod')) { + case 'hex': + this._updateHex(this.$('.o_hex_input').val()); + break; + case 'rgb': + this._updateRgba( + parseInt(this.$('.o_red_input').val()), + parseInt(this.$('.o_green_input').val()), + parseInt(this.$('.o_blue_input').val()) + ); + break; + case 'hsl': + this._updateHsl( + parseInt(this.$('.o_hue_input').val()), + parseInt(this.$('.o_saturation_input').val()), + parseInt(this.$('.o_lightness_input').val()) + ); + break; + case 'opacity': + this._updateOpacity(parseInt(this.$('.o_opacity_input').val())); + break; + } + this._updateUI(); + this._colorSelected(); + }, +}); + +//-------------------------------------------------------------------------- +// Static +//-------------------------------------------------------------------------- + +/** + * Converts RGB color components to HSL components. + * + * @static + * @param {integer} r - [0, 255] + * @param {integer} g - [0, 255] + * @param {integer} b - [0, 255] + * @returns {Object|false} + * - hue [0, 360[ + * - saturation [0, 100] + * - lightness [0, 100] + */ +ColorpickerWidget.convertRgbToHsl = function (r, g, b) { + if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 + || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 + || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { + return false; + } + + var red = r / 255; + var green = g / 255; + var blue = b / 255; + var maxColor = Math.max(red, green, blue); + var minColor = Math.min(red, green, blue); + var delta = maxColor - minColor; + var hue = 0; + var saturation = 0; + var lightness = (maxColor + minColor) / 2; + if (delta) { + if (maxColor === red) { + hue = (green - blue) / delta; + } + if (maxColor === green) { + hue = 2 + (blue - red) / delta; + } + if (maxColor === blue) { + hue = 4 + (red - green) / delta; + } + if (maxColor) { + saturation = delta / (1 - Math.abs(2 * lightness - 1)); + } + } + hue = 60 * hue | 0; + return { + hue: hue < 0 ? hue += 360 : hue, + saturation: (saturation * 100) | 0, + lightness: (lightness * 100) | 0, + }; +}; +/** + * Converts HSL color components to RGB components. + * + * @static + * @param {integer} h - [0, 360[ + * @param {integer} s - [0, 100] + * @param {integer} l - [0, 100] + * @returns {Object|false} + * - red [0, 255] + * - green [0, 255] + * - blue [0, 255] + */ +ColorpickerWidget.convertHslToRgb = function (h, s, l) { + if (typeof (h) !== 'number' || isNaN(h) || h < 0 || h > 360 + || typeof (s) !== 'number' || isNaN(s) || s < 0 || s > 100 + || typeof (l) !== 'number' || isNaN(l) || l < 0 || l > 100) { + return false; + } + + var huePrime = h / 60; + var saturation = s / 100; + var lightness = l / 100; + var chroma = saturation * (1 - Math.abs(2 * lightness - 1)); + var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1)); + var lightnessAdjustment = lightness - chroma / 2; + var precision = 255; + chroma = (chroma + lightnessAdjustment) * precision | 0; + secondComponent = (secondComponent + lightnessAdjustment) * precision | 0; + lightnessAdjustment = lightnessAdjustment * precision | 0; + if (huePrime >= 0 && huePrime < 1) { + return { + red: chroma, + green: secondComponent, + blue: lightnessAdjustment, + }; + } + if (huePrime >= 1 && huePrime < 2) { + return { + red: secondComponent, + green: chroma, + blue: lightnessAdjustment, + }; + } + if (huePrime >= 2 && huePrime < 3) { + return { + red: lightnessAdjustment, + green: chroma, + blue: secondComponent, + }; + } + if (huePrime >= 3 && huePrime < 4) { + return { + red: lightnessAdjustment, + green: secondComponent, + blue: chroma, + }; + } + if (huePrime >= 4 && huePrime < 5) { + return { + red: secondComponent, + green: lightnessAdjustment, + blue: chroma, + }; + } + if (huePrime >= 5 && huePrime <= 6) { + return { + red: chroma, + green: lightnessAdjustment, + blue: secondComponent, + }; + } + return false; +}; +/** + * Converts RGBA color components to a normalized CSS color: if the opacity + * is invalid or equal to 100, a hex is returned; otherwise a rgba() css color + * is returned. + * + * Those choice have multiple reason: + * - A hex color is more common to c/c from other utilities on the web and is + * also shorter than rgb() css colors + * - Opacity in hexadecimal notations is not supported on all browsers and is + * also less common to use. + * + * @static + * @param {integer} r - [0, 255] + * @param {integer} g - [0, 255] + * @param {integer} b - [0, 255] + * @param {float} a - [0, 100] + * @returns {string} + */ +ColorpickerWidget.convertRgbaToCSSColor = function (r, g, b, a) { + if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 + || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 + || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { + return false; + } + if (typeof (a) !== 'number' || isNaN(a) || a < 0 || Math.abs(a - 100) < Number.EPSILON) { + const rr = r < 16 ? '0' + r.toString(16) : r.toString(16); + const gg = g < 16 ? '0' + g.toString(16) : g.toString(16); + const bb = b < 16 ? '0' + b.toString(16) : b.toString(16); + return (`#${rr}${gg}${bb}`).toUpperCase(); + } + return `rgba(${r}, ${g}, ${b}, ${parseFloat((a / 100.0).toFixed(3))})`; +}; +/** + * Converts a CSS color (rgb(), rgba(), hexadecimal) to RGBA color components. + * + * Note: we don't support using and displaying hexadecimal color with opacity + * but this method allows to receive one and returns the correct opacity value. + * + * @static + * @param {string} cssColor - hexadecimal code or rgb() or rgba() + * @returns {Object|false} + * - red [0, 255] + * - green [0, 255] + * - blue [0, 255] + * - opacity [0, 100.0] + */ +ColorpickerWidget.convertCSSColorToRgba = function (cssColor) { + // Check if cssColor is a rgba() or rgb() color + const rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); + if (rgba) { + if (rgba[4] === undefined) { + rgba[4] = 1; + } + return { + red: parseInt(rgba[1]), + green: parseInt(rgba[2]), + blue: parseInt(rgba[3]), + opacity: Math.round(parseFloat(rgba[4]) * 100), + }; + } + + // Otherwise, check if cssColor is an hexadecimal code color + if (/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(cssColor)) { + return { + red: parseInt(cssColor.substr(1, 2), 16), + green: parseInt(cssColor.substr(3, 2), 16), + blue: parseInt(cssColor.substr(5, 2), 16), + opacity: (cssColor.length === 9 ? (parseInt(cssColor.substr(7, 2), 16) / 255) : 1) * 100, + }; + } + + // TODO maybe implement a support for receiving css color like 'red' or + // 'transparent' (which are now considered non-css color by isCSSColor...) + // Note: however, if ever implemented be careful of 'white'/'black' which + // actually are color names for our color system... + + return false; +}; +/** + * Converts a CSS color (rgb(), rgba(), hexadecimal) to a normalized version + * of the same color (@see convertRgbaToCSSColor). + * + * Normalized color can be safely compared using string comparison. + * + * @static + * @param {string} cssColor - hexadecimal code or rgb() or rgba() + * @returns {string} - the normalized css color or the given css color if it + * failed to be normalized + */ +ColorpickerWidget.normalizeCSSColor = function (cssColor) { + const rgba = ColorpickerWidget.convertCSSColorToRgba(cssColor); + if (!rgba) { + return cssColor; + } + return ColorpickerWidget.convertRgbaToCSSColor(rgba.red, rgba.green, rgba.blue, rgba.opacity); +}; +/** + * Checks if a given string is a css color. + * + * @static + * @param {string} cssColor + * @returns {boolean} + */ +ColorpickerWidget.isCSSColor = function (cssColor) { + return ColorpickerWidget.convertCSSColorToRgba(cssColor) !== false; +}; + +const ColorpickerDialog = Dialog.extend({ + /** + * @override + */ + init: function (parent, options) { + this.options = options || {}; + this._super(parent, _.extend({ + size: 'small', + title: _t('Pick a color'), + buttons: [ + {text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)}, + {text: _t('Discard'), close: true}, + ], + }, this.options)); + }, + /** + * @override + */ + start: function () { + const proms = [this._super(...arguments)]; + this.colorPicker = new ColorpickerWidget(this, _.extend({ + colorPreview: true, + }, this.options)); + proms.push(this.colorPicker.appendTo(this.$el)); + return Promise.all(proms); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onFinalPick: function () { + this.trigger_up('colorpicker:saved', this.colorPicker.colorComponents); + }, +}); + +return { + ColorpickerDialog: ColorpickerDialog, + ColorpickerWidget: ColorpickerWidget, +}; +}); diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js new file mode 100644 index 00000000..f6354920 --- /dev/null +++ b/addons/web/static/src/js/widgets/data_export.js @@ -0,0 +1,688 @@ +odoo.define('web.DataExport', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var data = require('web.data'); +var framework = require('web.framework'); +var pyUtils = require('web.py_utils'); + +var QWeb = core.qweb; +var _t = core._t; + +var DataExport = Dialog.extend({ + template: 'ExportDialog', + events: { + 'change .o_exported_lists_select': '_onChangeExportList', + 'change .o_import_compat input': '_onChangeCompatibleInput', + 'click .o_add_field': '_onClickAddField', + 'click .o_delete_exported_list': '_onClickDeleteExportListBtn', + 'click .o_expand': '_onClickExpand', + 'click .o_remove_field': '_onClickRemoveField', + 'click .o_save_list .o_save_list_btn': '_onClickSaveListBtn', + 'click .o_save_list .o_cancel_list_btn': '_resetTemplateField', + 'click .o_export_tree_item': '_onClickTreeItem', + 'dblclick .o_export_tree_item:not(.haschild)': '_onDblclickTreeItem', + 'keydown .o_export_tree_item': '_onKeydownTreeItem', + 'keydown .o_save_list_name': '_onKeydownSaveList', + 'input .o_export_search_input': '_onSearchInput', + }, + /** + * @constructor + * @param {Widget} parent + * @param {Object} record + * @param {string[]} defaultExportFields + */ + init: function (parent, record, defaultExportFields, groupedBy, activeDomain, idsToExport) { + var options = { + title: _t("Export Data"), + buttons: [ + {text: _t("Export"), click: this._onExportData, classes: 'btn-primary'}, + {text: _t("Close"), close: true}, + ], + }; + this._super(parent, options); + this.records = {}; + this.record = record; + this.defaultExportFields = defaultExportFields; + this.groupby = groupedBy; + this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext()); + this.rowIndex = 0; + this.rowIndexLevel = 0; + this.isCompatibleMode = false; + this.domain = activeDomain || this.record.domain; + this.idsToExport = activeDomain ? false: idsToExport; + }, + /** + * @override + */ + start: function () { + var self = this; + var proms = [this._super.apply(this, arguments)]; + + // The default for the ".modal_content" element is "max-height: 100%;" + // but we want it to always expand to "height: 100%;" for this modal. + // This can be achieved thanks to CSS modification without touching + // the ".modal-content" rules... but not with Internet explorer (11). + this.$modal.find('.modal-content').css('height', '100%'); + + this.$fieldsList = this.$('.o_fields_list'); + + proms.push(this._rpc({route: '/web/export/formats'}).then(doSetupExportFormats)); + proms.push(this._onChangeCompatibleInput().then(function () { + _.each(self.defaultExportFields, function (field) { + var record = self.records[field]; + self._addField(record.id, record.string); + }); + })); + + proms.push(this._showExportsList()); + + // Bind sortable events after Dialog is open + this.opened().then(function () { + self.$('.o_fields_list').sortable({ + axis: 'y', + handle: '.o_short_field', + forcePlaceholderSize: true, + placeholder: 'o-field-placeholder', + update: self.proxy('_resetTemplateField'), + }); + }); + return Promise.all(proms); + + function doSetupExportFormats(formats) { + var $fmts = self.$('.o_export_format'); + + _.each(formats, function (format) { + var $radio = $('<input/>', {type: 'radio', value: format.tag, name: 'o_export_format_name', class: 'form-check-input', id: 'o_radio' + format.label}); + var $label = $('<label/>', {html: format.label, class: 'form-check-label', for: 'o_radio' + format.label}); + + if (format.error) { + $radio.prop('disabled', true); + $label.html(_.str.sprintf("%s — %s", format.label, format.error)); + } + + $fmts.append($("<div class='radio form-check form-check-inline pl-4'></div>").append($radio, $label)); + }); + + self.$exportFormatInputs = $fmts.find('input'); + self.$exportFormatInputs.filter(':enabled').first().prop('checked', true); + } + }, + + /** + * Export all data with default values (fields, domain) + */ + export() { + let exportedFields = this.defaultExportFields.map(field => ({ + name: field, + label: this.record.fields[field].string, + store: this.record.fields[field].store, + type: this.record.fields[field].type, + })); + this._exportData(exportedFields, 'xlsx', false); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add the field in the export list + * + * @private + * @param {string} fieldID + * @param {string} label + */ + _addField: function (fieldID, label) { + var $fieldList = this.$('.o_fields_list'); + if (!$fieldList.find(".o_export_field[data-field_id='" + fieldID + "']").length) { + $fieldList.append( + $('<li>', {'class': 'o_export_field', 'data-field_id': fieldID}).append( + $('<span>', {'class': "fa fa-arrows o_short_field mx-1"}), + label.trim(), + $('<span>', {'class': 'fa fa-trash m-1 pull-right o_remove_field', 'title': _t("Remove field")}) + ) + ); + } + }, + + /** + * Submit the user data and export the file + * + * @private + */ + _exportData(exportedFields, exportFormat, idsToExport) { + + if (_.isEmpty(exportedFields)) { + Dialog.alert(this, _t("Please select fields to export...")); + return; + } + if (this.isCompatibleMode) { + exportedFields.unshift({ name: 'id', label: _t('External ID') }); + } + + framework.blockUI(); + this.getSession().get_file({ + url: '/web/export/' + exportFormat, + data: { + data: JSON.stringify({ + model: this.record.model, + fields: exportedFields, + ids: idsToExport, + domain: this.domain, + groupby: this.groupby, + context: pyUtils.eval('contexts', [this.record.getContext()]), + import_compat: this.isCompatibleMode, + }) + }, + complete: framework.unblockUI, + error: (error) => this.call('crash_manager', 'rpc_error', error), + }); + }, + /** + * @private + * @returns {string[]} exportFields + */ + _getFields: function () { + var exportFields = this.$('.o_export_field').map(function () { + return $(this).data('field_id'); + }).get(); + if (exportFields.length === 0) { + Dialog.alert(this, _t("Please select fields to save export list...")); + } + return exportFields; + }, + /** + * Fetch the field info for the relational field. This method will be + * invoked when the user expands the relational field from keyboard/mouse. + * + * @private + * @param {Object} record + */ + _onExpandAction: function (record) { + var self = this; + if (!record.children) { + return; + } + + var model = record.params.model; + var prefix = record.params.prefix; + var name = record.params.name; + var excludeFields = []; + if (record.relation_field) { + excludeFields.push(record.relation_field); + } + + if (!record.loaded) { + this._rpc({ + route: '/web/export/get_fields', + params: { + model: model, + prefix: prefix, + parent_name: name, + import_compat: this.isCompatibleMode, + parent_field_type: record.field_type, + parent_field: record.params.parent_field, + exclude: excludeFields, + }, + }).then(function (results) { + record.loaded = true; + self._onShowData(results, record.id); + }); + } else { + this._showContent(record.id); + } + }, + /** + * After the fetching the fields info for the relational field, this method + * will render a list of a field for expanded relational field. + * + * @private + * @param {Object[]} records + * @param {string} expansion + */ + _onShowData: function (records, expansion) { + var self = this; + if (expansion) { + this.$('.o_export_tree_item[data-id="' + expansion + '"]') + .addClass('show') + .find('.o_expand_parent') + .toggleClass('fa-chevron-right fa-chevron-down') + .next() + .after(QWeb.render('Export.TreeItems', {fields: records, debug: config.isDebug()})); + } else { + this.$('.o_left_field_panel').empty().append( + $('<div/>').addClass('o_field_tree_structure') + .append(QWeb.render('Export.TreeItems', {fields: records, debug: config.isDebug()})) + ); + } + + _.extend(this.records, _.object(_.pluck(records, 'id'), records)); + this.$records = this.$('.o_export_tree_item'); + this.$records.each(function (i, el) { + var $el = $(el); + $el.find('.o_tree_column').first().toggleClass('o_required', !!self.records[$el.data('id')].required); + }); + }, + /** + * @private + */ + _addNewTemplate: function () { + this.$('.o_exported_lists').addClass('d-none'); + + this.$(".o_save_list") + .show() + .find(".o_save_list_name") + .val("") + .focus(); + }, + /** + * @private + */ + _resetTemplateField: function () { + this.$('.o_exported_lists_select').val(""); + this.$('.o_delete_exported_list').addClass('d-none'); + this.$('.o_exported_lists').removeClass('d-none'); + + this.$(".o_save_list") + .hide() + .find(".o_save_list_name").val(""); + }, + /** + * If relational fields info is already fetched then this method is + * used to display fields. + * + * @private + * @param {string} fieldID + */ + _showContent: function (fieldID) { + var $item = this.$('.o_export_tree_item[data-id="' + fieldID + '"]'); + $item.toggleClass('show'); + var isOpen = $item.hasClass('show'); + + $item.children('.o_expand_parent').toggleClass('fa-chevron-down', !!isOpen).toggleClass('fa-chevron-right', !isOpen); + + var $childField = $item.find('.o_export_tree_item'); + var childLength = (fieldID.split('/')).length + 1; + for (var i = 0 ; i < $childField.length ; i++) { + var $child = $childField.eq(i); + if (!isOpen) { + $child.hide(); + } else if (childLength === $childField.eq(i).data('id').split('/').length) { + if ($child.hasClass('show')) { + $child.removeClass('show'); + $child.children('.o_expand_parent').removeClass('fa-chevron-down').addClass('fa-chevron-right'); + } + $child.show(); + } + } + }, + /** + * Fetches the saved export list for the current model + * + * @private + * @returns {Deferred} + */ + _showExportsList: function () { + var self = this; + if (this.$('.o_exported_lists_select').is(':hidden')) { + this.$('.o_exported_lists').show(); + return Promise.resolve(); + } + + return this._rpc({ + model: 'ir.exports', + method: 'search_read', + fields: ['name'], + domain: [['resource', '=', this.record.model]] + }).then(function (exportList) { + self.$('.o_exported_lists').append(QWeb.render('Export.SavedList', { + existing_exports: exportList, + })); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method will fill fields to export when user change exported field list + * + * @private + */ + _onChangeExportList: function () { + var self = this; + var exportID = this.$('.o_exported_lists_select option:selected').val(); + this.$('.o_delete_exported_list').toggleClass('d-none', !exportID); + if (exportID && exportID !== 'new_template') { + this.$('.o_fields_list').empty(); + this._rpc({ + route: '/web/export/namelist', + params: { + model: this.record.model, + export_id: parseInt(exportID, 10), + }, + }).then(function (fieldList) { + _.each(fieldList, function (field) { + self._addField(field.name, field.label); + }); + }); + } else if (exportID === 'new_template') { + self._addNewTemplate(); + } + }, + /** + * @private + * @returns {Deferred} + */ + _onChangeCompatibleInput: function () { + var self = this; + this.isCompatibleMode = this.$('.o_import_compat input').is(':checked'); + + this.$('.o_field_tree_structure').remove(); + this._resetTemplateField(); + return this._rpc({ + route: '/web/export/get_fields', + params: { + model: this.record.model, + import_compat: this.isCompatibleMode, + }, + }).then(function (records) { + var compatibleFields = _.map(records, function (record) { return record.id; }); + self._onShowData(records); + self.$('.o_fields_list').empty(); + + _.chain(self.$fieldsList.find('.o_export_field')) + .map(function (field) { return $(field).data('field_id'); }) + .union(self.defaultExportFields) + .intersection(compatibleFields) + .each(function (field) { + var record = _.find(records, function (rec) { + return rec.id === field; + }); + self._addField(record.id, record.string); + }); + self.$('#o-export-search-filter').val(''); + }); + }, + /** + * Add a field to export list + * + * @private + * @param {Event} ev + */ + _onClickAddField: function(ev) { + ev.stopPropagation(); + var $field = $(ev.currentTarget); + this._resetTemplateField(); + this._addField($field.closest('.o_export_tree_item').data('id'), $field.closest('.o_tree_column').text()); + }, + /** + * Delete selected export list item from the saved export list + * + * @private + */ + _onClickDeleteExportListBtn: function () { + var self = this; + var selectExp = this.$('.o_exported_lists_select option:selected'); + var options = { + confirm_callback: function () { + if (selectExp.val()) { + self.exports.unlink([parseInt(selectExp.val(), 10)]); + selectExp.remove(); + if (self.$('.o_exported_lists_select option').length <= 1) { + self.$('.o_exported_lists').hide(); + } + } + } + }; + Dialog.confirm(this, _t("Do you really want to delete this export template?"), options); + }, + /** + * @private + * @param {Event} ev + */ + _onClickExpand: function (ev) { + this._onExpandAction(this.records[$(ev.target).closest('.o_export_tree_item').data('id')]); + }, + /** + * Remove selected field from export field list + * + * @private + * @param {Event} ev + */ + _onClickRemoveField: function (ev) { + $(ev.currentTarget).closest('.o_export_field').remove(); + this._resetTemplateField(); + }, + /** + * This method will create a record in 'ir.exports' model with list of + * selected fields. + * + * @private + */ + _onClickSaveListBtn: function () { + var self = this; + var $saveList = this.$('.o_save_list'); + + var value = $saveList.find('input').val(); + if (!value) { + Dialog.alert(this, _t("Please enter save field list name")); + return; + } + + var fields = this._getFields(); + if (fields.length === 0) { + return; + } + + $saveList.hide(); + + this.exports.create({ + name: value, + resource: this.record.model, + export_fields: _.map(fields, function (field) { + return [0, 0, { name: field }]; + }), + }).then(function (exportListID) { + if (!exportListID) { + return; + } + var $select = self.$('.o_exported_lists_select'); + if ($select.length === 0 || $select.is(':hidden')) { + self._showExportsList(); + } + $select.append(new Option(value, exportListID)); + self.$('.o_exported_lists').removeClass('d-none'); + $select.val(exportListID); + }); + }, + /** + * @private + * @param ev + */ + _onClickTreeItem: function (ev) { + ev.stopPropagation(); + var $elem = $(ev.currentTarget); + + var rowIndex = $elem.prevAll('.o_export_tree_item').length; + var rowIndexLevel = $elem.parents('.o_export_tree_item').length; + + if (ev.shiftKey && rowIndexLevel === this.rowIndexLevel) { + var minIndex = Math.min(rowIndex, this.rowIndex); + var maxIndex = Math.max(rowIndex, this.rowIndex); + + this.$records.filter(function () { return ($elem.parent()[0] === $(this).parent()[0]); }) + .slice(minIndex, maxIndex + 1) + .addClass('o_selected') + .filter(':not(:last)') + .each(processChildren); + } + + this.rowIndex = rowIndex; + this.rowIndexLevel = rowIndexLevel; + + if (ev.ctrlKey) { + $elem.toggleClass('o_selected').focus(); + } else if (ev.shiftKey) { + $elem.addClass('o_selected').focus(); + } else { + this.$('.o_selected').removeClass('o_selected'); + $elem.addClass('o_selected').focus(); + } + + function processChildren() { + var $child = $(this); + if ($child.hasClass('show')) { + $child.children('.o_export_tree_item') + .addClass('o_selected') + .each(processChildren); + } + } + }, + /** + * Submit the user data and export the file + * + * @private + */ + _onExportData() { + let exportedFields = this.$('.o_export_field').map((i, field) => ({ + name: $(field).data('field_id'), + label: field.textContent, + } + )).get(); + let exportFormat = this.$exportFormatInputs.filter(':checked').val(); + this._exportData(exportedFields, exportFormat, this.idsToExport); + }, + /** + * Add a field to export field list on double click + * + * @private + * @param {Event} ev + */ + _onDblclickTreeItem: function (ev) { + var self = this; + this._resetTemplateField(); + function addElement(el) { + self._addField(el.getAttribute('data-id'), el.querySelector('.o_tree_column').textContent); + } + var target = ev.currentTarget; + target.classList.remove('o_selected'); + // Add parent fields to export + [].reverse.call($(target).parents('.o_export_tree_item')).each(function () { + addElement(this); + }); + // add field itself + addElement(target); + }, + /** + * @private + * @param ev + */ + _onKeydownSaveList: function (ev) { + if (ev.keyCode === $.ui.keyCode.ENTER) { + this._onClickSaveListBtn(); + } + }, + /** + * Handles the keyboard navigation for the fields + * + * @private + * @param ev + */ + _onKeydownTreeItem: function (ev) { + ev.stopPropagation(); + var $el = $(ev.currentTarget); + var record = this.records[$el.data('id')]; + + switch (ev.keyCode || ev.which) { + case $.ui.keyCode.LEFT: + if ($el.hasClass('show')) { + this._onExpandAction(record); + } + break; + case $.ui.keyCode.RIGHT: + if (!$el.hasClass('show')) { + this._onExpandAction(record); + } + break; + case $.ui.keyCode.UP: + var $prev = $el.prev('.o_export_tree_item'); + if ($prev.length === 1) { + while ($prev.hasClass('show')) { + $prev = $prev.children('.o_export_tree_item').last(); + } + } else { + $prev = $el.parent('.o_export_tree_item'); + if ($prev.length === 0) { + break; + } + } + + $el.removeClass('o_selected').blur(); + $prev.addClass("o_selected").focus(); + break; + case $.ui.keyCode.DOWN: + var $next; + if ($el.hasClass('show')) { + $next = $el.children('.o_export_tree_item').first(); + } else { + $next = $el.next('.o_export_tree_item'); + if ($next.length === 0) { + $next = $el.parent('.o_export_tree_item').next('.o_export_tree_item'); + if ($next.length === 0) { + break; + } + } + } + + $el.removeClass('o_selected').blur(); + $next.addClass('o_selected').focus(); + break; + } + }, + /** + * Search fields from a field list. + * + * @private + */ + _onSearchInput: function (ev) { + var searchText = $(ev.currentTarget).val().trim().toUpperCase(); + if (!searchText) { + this.$('.o_no_match').remove(); + this.$(".o_export_tree_item").show(); + this.$(".o_export_tree_item.haschild:not(.show) .o_export_tree_item").hide(); + return; + } + + var matchItems = this.$(".o_tree_column").filter(function () { + var title = this.getAttribute('title'); + return this.innerText.toUpperCase().indexOf(searchText) >= 0 + || title && title.toUpperCase().indexOf(searchText) >= 0; + }).parent(); + this.$(".o_export_tree_item").hide(); + if (matchItems.length) { + this.$('.o_no_match').remove(); + _.each(matchItems, function (col) { + var $col = $(col); + $col.show(); + $col.parents('.haschild.show').show(); + if (!$col.parent().hasClass('show') && !$col.parent().hasClass('o_field_tree_structure')) { + $col.hide(); + } + }); + } else if (!this.$('.o_no_match').length) { + this.$(".o_field_tree_structure").append($("<h3/>", { + class: 'text-center text-muted mt-5 o_no_match', + text: _t("No match found.") + })); + } + }, +}); + +return DataExport; + +}); diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js new file mode 100644 index 00000000..fdbd1f7d --- /dev/null +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -0,0 +1,358 @@ +odoo.define('web.datepicker', function (require) { +"use strict"; + +var core = require('web.core'); +var field_utils = require('web.field_utils'); +var time = require('web.time'); +var Widget = require('web.Widget'); + +var _t = core._t; + +var DateWidget = Widget.extend({ + template: "web.datepicker", + type_of_date: "date", + events: { + 'error.datetimepicker': 'errorDatetime', + 'change .o_datepicker_input': 'changeDatetime', + 'click input': '_onInputClicked', + 'input input': '_onInput', + 'keydown': '_onKeydown', + 'show.datetimepicker': '_onDateTimePickerShow', + 'hide.datetimepicker': '_onDateTimePickerHide', + }, + /** + * @override + */ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.name = parent.name; + this.options = _.extend({ + locale: moment.locale(), + format : this.type_of_date === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(), + minDate: moment({ y: 1000 }), + maxDate: moment({ y: 9999, M: 11, d: 31 }), + useCurrent: false, + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + previous: 'fa fa-chevron-left', + next: 'fa fa-chevron-right', + today: 'fa fa-calendar-check-o', + clear: 'fa fa-trash', + close: 'fa fa-check primary', + }, + calendarWeeks: true, + buttons: { + showToday: false, + showClear: false, + showClose: false, + }, + widgetParent: 'body', + keyBinds: null, + }, options || {}); + + this.__libInput = 0; + // tempusdominus doesn't offer any elegant way to check whether the + // datepicker is open or not, so we have to listen to hide/show events + // and manually keep track of the 'open' state + this.__isOpen = false; + }, + /** + * @override + */ + start: function () { + this.$input = this.$('input.o_datepicker_input'); + this.__libInput++; + this.$el.datetimepicker(this.options); + this.__libInput--; + this._setReadonly(false); + }, + /** + * @override + */ + destroy: function () { + if (this._onScroll) { + window.removeEventListener('wheel', this._onScroll, true); + } + this.__libInput++; + this.$el.datetimepicker('destroy'); + this.__libInput--; + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * set datetime value + */ + changeDatetime: function () { + if (this.__libInput > 0) { + if (this.options.warn_future) { + this._warnFuture(this.getValue()); + } + this.trigger("datetime_changed"); + return; + } + var oldValue = this.getValue(); + if (this.isValid()) { + this._setValueFromUi(); + var newValue = this.getValue(); + var hasChanged = !oldValue !== !newValue; + if (oldValue && newValue) { + var formattedOldValue = oldValue.format(time.getLangDatetimeFormat()); + var formattedNewValue = newValue.format(time.getLangDatetimeFormat()); + if (formattedNewValue !== formattedOldValue) { + hasChanged = true; + } + } + if (hasChanged) { + if (this.options.warn_future) { + this._warnFuture(newValue); + } + this.trigger("datetime_changed"); + } + } else { + var formattedValue = oldValue ? this._formatClient(oldValue) : null; + this.$input.val(formattedValue); + } + }, + /** + * Library clears the wrong date format so just ignore error + */ + errorDatetime: function (e) { + return false; + }, + /** + * Focuses the datepicker input. This function must be called in order to + * prevent 'input' events triggered by the lib to bubble up, and to cause + * unwanted effects (like triggering 'field_changed' events) + */ + focus: function () { + this.__libInput++; + this.$input.focus(); + this.__libInput--; + }, + /** + * @returns {Moment|false} + */ + getValue: function () { + var value = this.get('value'); + return value && value.clone(); + }, + /** + * @returns {boolean} + */ + isValid: function () { + var value = this.$input.val(); + if (value === "") { + return true; + } else { + try { + this._parseClient(value); + return true; + } catch (e) { + return false; + } + } + }, + /** + * @returns {Moment|false} value + */ + maxDate: function (date) { + this.__libInput++; + this.$el.datetimepicker('maxDate', date || null); + this.__libInput--; + }, + /** + * @returns {Moment|false} value + */ + minDate: function (date) { + this.__libInput++; + this.$el.datetimepicker('minDate', date || null); + this.__libInput--; + }, + /** + * @param {Moment|false} value + */ + setValue: function (value) { + this.set({'value': value}); + var formatted_value = value ? this._formatClient(value) : null; + this.$input.val(formatted_value); + this._setLibInputValue(value); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * add a warning to communicate that a date in the future has been set + * + * @private + * @param {Moment} currentDate + */ + _warnFuture: function (currentDate) { + if (!this.$warning) { + this.$warning = $('<span>', { + class: 'fa fa-exclamation-triangle o_tz_warning o_datepicker_warning', + }); + var title = _t("This date is in the future. Make sure this is what you expect."); + this.$warning.attr('title', title); + this.$input.after(this.$warning); + } + // Get rid of time and TZ crap for comparison + if (currentDate && currentDate.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD')) { + this.$warning.show(); + } else { + this.$warning.hide(); + } + }, + + /** + * @private + * @param {Moment} v + * @returns {string} + */ + _formatClient: function (v) { + return field_utils.format[this.type_of_date](v, null, {timezone: false}); + }, + /** + * @private + * @param {string|false} v + * @returns {Moment} + */ + _parseClient: function (v) { + return field_utils.parse[this.type_of_date](v, null, {timezone: false}); + }, + /** + * @private + * @param {Moment|false} value + */ + _setLibInputValue: function (value) { + this.__libInput++; + this.$el.datetimepicker('date', value || null); + this.__libInput--; + }, + /** + * @private + * @param {boolean} readonly + */ + _setReadonly: function (readonly) { + this.readonly = readonly; + this.$input.prop('readonly', this.readonly); + }, + /** + * set the value from the input value + * + * @private + */ + _setValueFromUi: function () { + var value = this.$input.val() || false; + this.setValue(this._parseClient(value)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reacts to the datetimepicker being hidden + * Used to unbind the scroll event from the datetimepicker + * + * @private + */ + _onDateTimePickerHide: function () { + this.__isOpen = false; + this.changeDatetime(); + if (this._onScroll) { + window.removeEventListener('wheel', this._onScroll, true); + } + this.changeDatetime(); + }, + /** + * Reacts to the datetimepicker being shown + * Could set/verify our widget value + * And subsequently update the datetimepicker + * + * @private + */ + _onDateTimePickerShow: function () { + this.__isOpen = true; + if (this.$input.val().length !== 0 && this.isValid()) { + this.$input.select(); + } + var self = this; + this._onScroll = function (ev) { + if (ev.target !== self.$input.get(0)) { + self.__libInput++; + self.$el.datetimepicker('hide'); + self.__libInput--; + } + }; + window.addEventListener('wheel', this._onScroll, true); + }, + /** + * @private + * @param {KeyEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ESCAPE) { + if (this.__isOpen) { + // we don't want any other effects than closing the datepicker, + // like leaving the edition of a row in editable list view + ev.stopImmediatePropagation(); + this.__libInput++; + this.$el.datetimepicker('hide'); + this.__libInput--; + this.focus(); + } + } + }, + /** + * Prevents 'input' events triggered by the library to bubble up, as they + * might have unwanted effects (like triggering 'field_changed' events in + * the context of field widgets) + * + * @private + * @param {Event} ev + */ + _onInput: function (ev) { + if (this.__libInput > 0) { + ev.stopImmediatePropagation(); + } + }, + /** + * @private + */ + _onInputClicked: function () { + this.__libInput++; + this.$el.datetimepicker('toggle'); + this.__libInput--; + this.focus(); + }, +}); + +var DateTimeWidget = DateWidget.extend({ + type_of_date: "datetime", + init: function (parent, options) { + this._super(parent, _.extend({ + buttons: { + showToday: false, + showClear: false, + showClose: true, + }, + }, options || {})); + }, +}); + +return { + DateWidget: DateWidget, + DateTimeWidget: DateTimeWidget, +}; + +}); diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js new file mode 100644 index 00000000..9fe71ce1 --- /dev/null +++ b/addons/web/static/src/js/widgets/domain_selector.js @@ -0,0 +1,987 @@ +odoo.define("web.DomainSelector", function (require) { +"use strict"; + +var core = require("web.core"); +var datepicker = require("web.datepicker"); +var Domain = require("web.Domain"); +var field_utils = require ("web.field_utils"); +var ModelFieldSelector = require("web.ModelFieldSelector"); +var Widget = require("web.Widget"); + +var _t = core._t; +var _lt = core._lt; + +// "child_of", "parent_of", "like", "not like", "=like", "=ilike" +// are only used if user entered them manually or if got from demo data +var operator_mapping = { + "=": "=", + "!=": _lt("is not ="), + ">": ">", + "<": "<", + ">=": ">=", + "<=": "<=", + "ilike": _lt("contains"), + "not ilike": _lt("does not contain"), + "in": _lt("in"), + "not in": _lt("not in"), + + "child_of": _lt("child of"), + "parent_of": _lt("parent of"), + "like": "like", + "not like": "not like", + "=like": "=like", + "=ilike": "=ilike", + + // custom + "set": _lt("is set"), + "not set": _lt("is not set"), +}; + +/** + * Abstraction for widgets which can represent and allow edition of a domain. + */ +var DomainNode = Widget.extend({ + events: { + // If click on the node add or delete button, notify the parent and let + // it handle the addition/removal + "click .o_domain_add_node_button": "_onAddButtonClick", + "click .o_domain_delete_node_button": "_onDeleteButtonClick", + // Handle visual feedback and animation + "mouseenter button": "_onButtonEntered", + "mouseleave button": "_onButtonLeft", + }, + /** + * A DomainNode needs a model and domain to work. It can also receive a set + * of options. + * + * @param {Object} parent + * @param {string} model - the model name + * @param {Array|string} domain - the prefix representation of the domain + * @param {Object} [options] - an object with possible values: + * @param {boolean} [options.readonly=true] - true if is readonly + * @param {Array} [options.default] - default domain used when creating a + * new node + * @param {string[]} [options.operators=null] + * a list of available operators (null = all of supported ones) + * @param {boolean} [options.debugMode=false] - true if should be in debug + * + * @see ModelFieldSelector for other options + */ + init: function (parent, model, domain, options) { + this._super.apply(this, arguments); + + this.model = model; + this.options = _.extend({ + readonly: true, + operators: null, + debugMode: false, + }, options || {}); + + this.readonly = this.options.readonly; + this.debug = this.options.debugMode; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Should return if the node is representing a well-formed domain, whose + * field chains properly belong to the associated model. + * + * @abstract + * @returns {boolean} + */ + isValid: function () {}, + /** + * Should return the prefix domain the widget is currently representing + * (an array). + * + * @abstract + * @returns {Array} + */ + getDomain: function () {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the add button is clicked -> trigger_up an event to ask + * creation of a new child in its parent. + * + * @param {Event} e + */ + _onAddButtonClick: function (e) { + e.preventDefault(); + e.stopPropagation(); + this.trigger_up("add_node_clicked", {newBranch: !!$(e.currentTarget).data("branch"), child: this}); + }, + /** + * Called when the delete button is clicked -> trigger_up an event to ask + * deletion of this node from its parent. + * + * @param {Event} e + */ + _onDeleteButtonClick: function (e) { + e.preventDefault(); + e.stopPropagation(); + this.trigger_up("delete_node_clicked", {child: this}); + }, + /** + * Called when a "controlpanel" button is hovered -> add classes to the + * domain node to add animation effects. + * + * @param {Event} e + */ + _onButtonEntered: function (e) { + e.preventDefault(); + e.stopPropagation(); + var $target = $(e.currentTarget); + this.$el.toggleClass("o_hover_btns", $target.hasClass("o_domain_delete_node_button")); + this.$el.toggleClass("o_hover_add_node", $target.hasClass("o_domain_add_node_button")); + this.$el.toggleClass("o_hover_add_inset_node", !!$target.data("branch")); + }, + /** + * Called when a "controlpanel" button is not hovered anymore -> remove + * classes from the domain node to stop animation effects. + * + * @param {Event} e + */ + _onButtonLeft: function (e) { + e.preventDefault(); + e.stopPropagation(); + this.$el.removeClass("o_hover_btns o_hover_add_node o_hover_add_inset_node"); + }, +}); + +/** + * DomainNode which can handle subdomains (a domain which is composed of + * multiple parts). It thus will be composed of other DomainTree instances + * and/or leaf parts of a domain (@see DomainLeaf). + */ +var DomainTree = DomainNode.extend({ + template: "DomainTree", + events: _.extend({}, DomainNode.prototype.events, { + "click .o_domain_tree_operator_selector .dropdown-item": "_onOperatorChange", + }), + custom_events: { + // If a domain child sends a request to add a child or remove one, call + // the appropriate methods. Propagates the event until success. + "add_node_clicked": "_onNodeAdditionAsk", + "delete_node_clicked": "_onNodeDeletionAsk", + }, + /** + * @constructor + * @see DomainNode.init + * The initialization of a DomainTree creates a "children" array attribute + * which will contain the the DomainNode children. It also deduces the + * operator from the domain. + * @see DomainTree._addFlattenedChildren + */ + init: function (parent, model, domain) { + this._super.apply(this, arguments); + var parsedDomain = this._parseDomain(domain); + if (parsedDomain) { + this._initialize(parsedDomain); + } + }, + /** + * @see DomainNode.start + * @returns {Promise} + */ + start: function () { + this._postRender(); + return Promise.all([ + this._super.apply(this, arguments), + this._renderChildrenTo(this.$childrenContainer) + ]); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @see DomainNode.isValid + * @returns {boolean} + */ + isValid: function () { + for (var i = 0 ; i < this.children.length ; i++) { + var cValid = this.children[i].isValid(); + if (!cValid) { + return cValid; + } + } + return this._isValid; + }, + /** + * @see DomainNode.getDomain + * @returns {Array} + */ + getDomain: function () { + var childDomains = []; + var nbChildren = 0; + _.each(this.children, function (child) { + var childDomain = child.getDomain(); + if (childDomain.length) { + nbChildren++; + childDomains = childDomains.concat(child.getDomain()); + } + }); + var nbChildRequired = this.operator === "!" ? 1 : 2; + var operators = _.times(nbChildren - nbChildRequired + 1, _.constant(this.operator)); + return operators.concat(childDomains); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a domain part to the widget. + * -> trigger_up "domain_changed" if the child is added + * + * @private + * @param {Array} domain - the prefix-like domain to build and add to the + * widget + * @param {DomainNode} afterNode - the node after which the new domain part + * must be added (at the end if not given) + * @returns {boolean} true if the part was added + * false otherwise (the afterNode was not found) + */ + _addChild: function (domain, afterNode) { + var i = afterNode ? _.indexOf(this.children, afterNode) : this.children.length; + if (i < 0) return false; + + this.children.splice(i+1, 0, instantiateNode(this, this.model, domain, this.options)); + this.trigger_up("domain_changed", {child: this}); + return true; + }, + /** + * Adds a child which represents the given domain. If the child has children + * and that the child main domain operator is the same as the current widget + * one, the 2-children prefix hierarchy is then simplified by making the + * child's children the widget's own children. + * + * @private + * @param {Array|string} domain - the domain of the child to add + */ + _addFlattenedChildren: function (domain) { + var node = instantiateNode(this, this.model, domain, this.options); + if (node === null) { + return; + } + if (!node.children || node.operator !== this.operator) { + this.children.push(node); + return; + } + _.each(node.children, (function (child) { + child.setParent(this); + this.children.push(child); + }).bind(this)); + node.destroy(); + }, + /** + * Changes the operator of the domain tree and notifies the parent if + * necessary (not silent). + * + * @private + * @param {string} operator - the new operator + * @param {boolean} silent - true if the parents should not be notified of + * the change + */ + _changeOperator: function (operator, silent) { + this.operator = operator; + if (!silent) this.trigger_up("domain_changed", {child: this}); + }, + /** + * @see DomainTree.init + * @private + */ + _initialize: function (domain) { + this._isValid = true; + this.operator = domain[0]; + this.children = []; + if (domain.length <= 1) { + return; + } + + // Add flattened children by search the appropriate number of children + // in the rest of the domain (after the operator) + var nbLeafsToFind = 1; + for (var i = 1 ; i < domain.length ; i++) { + if (domain[i] === "&" || domain[i] === "|") { + nbLeafsToFind++; + } else if (domain[i] !== "!") { + nbLeafsToFind--; + } + + if (!nbLeafsToFind) { + var partLeft = domain.slice(1, i+1); + var partRight = domain.slice(i+1); + if (partLeft.length) { + this._addFlattenedChildren(partLeft); + } + if (partRight.length) { + this._addFlattenedChildren(partRight); + } + break; + } + } + this._isValid = (nbLeafsToFind === 0); + + // Mark "!" tree children so that they do not allow to add other + // children around them + if (this.operator === "!") { + this.children[0].noControlPanel = true; + } + }, + /** + * @see DomainTree.start + * Initializes variables which depend on the rendered widget. + * @private + */ + _postRender: function () { + this.$childrenContainer = this.$("> .o_domain_node_children_container"); + }, + /** + * Removes a given child from the widget. + * -> trigger_up domain_changed if the child is removed + * + * @private + * @param {DomainNode} oldChild - the child instance to remove + * @returns {boolean} true if the child was removed, false otherwise (the + * widget does not own the child) + */ + _removeChild: function (oldChild) { + var i = _.indexOf(this.children, oldChild); + if (i < 0) return false; + + this.children[i].destroy(); + this.children.splice(i, 1); + this.trigger_up("domain_changed", {child: this}); + return true; + }, + /** + * @see DomainTree.start + * Appends the children domain node to the given node. This is used to + * render the children widget in a dummy element before adding them in the + * DOM, otherwhise they could be misordered as they rendering is not + * synchronous. + * + * @private + * @param {jQuery} $to - the jQuery node to which the children must be added + * @returns {Promise} + */ + _renderChildrenTo: function ($to) { + var $div = $("<div/>"); + return Promise.all(_.map(this.children, (function (child) { + return child.appendTo($div); + }).bind(this))).then((function () { + _.each(this.children, function (child) { + child.$el.appendTo($to); // Forced to do it this way so that the + // children are not misordered + }); + }).bind(this)); + }, + /** + * @param {string} domain + * @returns {Array[]} + */ + _parseDomain: function (domain) { + var parsedDomain = false; + try { + parsedDomain = Domain.prototype.stringToArray(domain); + this.invalidDomain = false; + } catch (err) { + // TODO: domain could contain `parent` for example, which is + // currently not handled by the DomainSelector + this.invalidDomain = true; + this.children = []; + } + return parsedDomain; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the operator select value is changed -> change the internal + * operator state + * + * @param {Event} e + */ + _onOperatorChange: function (e) { + e.preventDefault(); + e.stopPropagation(); + this._changeOperator($(e.target).data("operator")); + }, + /** + * Called when a node addition was asked -> add the new domain part if on + * the right node or let the propagation continue. + * + * @param {OdooEvent} e + */ + _onNodeAdditionAsk: function (e) { + var domain = this.options.default || [["id", "=", 1]]; + if (e.data.newBranch) { + domain = [this.operator === "&" ? "|" : "&"].concat(domain).concat(domain); + } + if (this._addChild(domain, e.data.child)) { + e.stopPropagation(); + } + }, + /** + * Called when a node deletion was asked -> remove the domain part if on + * the right node or let the propagation continue. + * + * @param {OdooEvent} e + */ + _onNodeDeletionAsk: function (e) { + if (this._removeChild(e.data.child)) { + e.stopPropagation(); + } + }, +}); + +/** + * The DomainSelector widget can be used to build prefix char domain. It is the + * DomainTree specialization to use to have a fully working widget. + * + * Known limitations: + * + * - Some operators like "child_of", "parent_of", "like", "not like", + * "=like", "=ilike" will come only if you use them from demo data or + * debug input. + * - Some kind of domain can not be build right now + * e.g ("country_id", "in", [1,2,3]) but you can insert from debug input. + */ +var DomainSelector = DomainTree.extend({ + template: "DomainSelector", + events: _.extend({}, DomainTree.prototype.events, { + "click .o_domain_add_first_node_button": "_onAddFirstButtonClick", + "change .o_domain_debug_input": "_onDebugInputChange", + }), + custom_events: _.extend({}, DomainTree.prototype.custom_events, { + domain_changed: "_onDomainChange", + }), + + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + if (self.invalidDomain) { + var msg = _t("This domain is not supported."); + self.$el.html(msg); + } + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Changes the internal domain value and forces a reparsing and rerendering. + * If the internal domain value was already equal to the given one, this + * does nothing. + * + * @param {string} domain + * @returns {Promise} resolved when the rerendering is finished + */ + setDomain: function (domain) { + if (domain === Domain.prototype.arrayToString(this.getDomain())) { + return Promise.resolve(); + } + var parsedDomain = this._parseDomain(domain); + if (parsedDomain) { + return this._redraw(parsedDomain); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @see DomainTree._initialize + */ + _initialize: function (domain) { + // Check if the domain starts with implicit "&" operators and make them + // explicit. As the DomainSelector is a specialization of a DomainTree, + // it is waiting for a tree and not a leaf. So [] and [A] will be made + // explicit with ["&"], ["&", A] so that tree parsing is made correctly. + // Note: the domain is considered to be a valid one + if (domain.length > 1) { + Domain.prototype.normalizeArray(domain); + } else { + domain = ["&"].concat(domain); + } + return this._super(domain); + }, + /** + * @see DomainTree._postRender + * Warns the user if the domain is not valid after rendering. + */ + _postRender: function () { + this._super.apply(this, arguments); + + // Display technical domain if in debug mode + this.$debugInput = this.$(".o_domain_debug_input"); + if (this.$debugInput.length) { + this.$debugInput.val(Domain.prototype.arrayToString(this.getDomain())); + } + + // Warn the user if the domain is not valid after rendering + if (!this._isValid) { + this.do_warn(false, _t("Domain not supported")); + } + }, + /** + * This method is ugly but achieves the right behavior without flickering. + * + * @param {Array|string} domain + * @returns {Promise} + */ + _redraw: function (domain) { + var oldChildren = this.children.slice(); + this._initialize(domain || this.getDomain()); + return this._renderChildrenTo($("<div/>")).then((function () { + _.each(oldChildren, function (child) { child.destroy(); }); + this.renderElement(); + this._postRender(); + _.each(this.children, (function (child) { child.$el.appendTo(this.$childrenContainer); }).bind(this)); + }).bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "add a filter" button is clicked -> adds a first domain + * node + */ + _onAddFirstButtonClick: function () { + this._addChild(this.options.default || [["id", "=", 1]]); + }, + /** + * Called when the debug input value is changed -> constructs the tree + * representation if valid or warn the user if invalid. + * + * @param {Event} e + */ + _onDebugInputChange: function (e) { + // When the debug input changes, the string prefix domain is read. If it + // is syntax-valid the widget is re-rendered and notifies the parents. + // If not, a warning is shown to the user and the input is ignored. + var domain; + try { + domain = Domain.prototype.stringToArray($(e.currentTarget).val()); + } catch (err) { // If there is a syntax error, just ignore the change + this.do_warn(_t("Syntax error"), _t("Domain not properly formed")); + return; + } + this._redraw(domain).then((function () { + this.trigger_up("domain_changed", {child: this, alreadyRedrawn: true}); + }).bind(this)); + }, + /** + * Called when a (child's) domain has changed -> redraw the entire tree + * representation if necessary + * + * @param {OdooEvent} e + */ + _onDomainChange: function (e) { + // If a subdomain notifies that it underwent some modifications, the + // DomainSelector catches the message and performs a full re-rendering. + if (!e.data.alreadyRedrawn) { + this._redraw(); + } + }, +}); + +/** + * DomainNode which handles a domain which cannot be split in another + * subdomains, i.e. composed of a field chain, an operator and a value. + */ +var DomainLeaf = DomainNode.extend({ + template: "DomainLeaf", + events: _.extend({}, DomainNode.prototype.events, { + "change .o_domain_leaf_operator_select": "_onOperatorSelectChange", + "change .o_domain_leaf_value_input": "_onValueInputChange", + + // Handle the tags widget part (TODO should be an independant widget) + "click .o_domain_leaf_value_add_tag_button": "on_add_tag", + "keyup .o_domain_leaf_value_tags input": "on_add_tag", + "click .o_domain_leaf_value_remove_tag_button": "on_remove_tag", + }), + custom_events: { + "field_chain_changed": "_onFieldChainChange", + }, + /** + * @see DomainNode.init + */ + init: function (parent, model, domain, options) { + this._super.apply(this, arguments); + + var currentDomain = Domain.prototype.stringToArray(domain); + this.chain = currentDomain[0][0]; + this.operator = currentDomain[0][1]; + this.value = currentDomain[0][2]; + + this.operator_mapping = operator_mapping; + }, + /** + * Prepares the information the rendering of the widget will need by + * pre-instantiating its internal field selector widget. + * + * @returns {Promise} + */ + willStart: function () { + var defs = [this._super.apply(this, arguments)]; + + // In edit mode, instantiate a field selector. This is done here in + // willStart and prepared by appending it to a dummy element because the + // DomainLeaf rendering need some information which cannot be computed + // before the ModelFieldSelector is fully rendered (TODO). + this.fieldSelector = new ModelFieldSelector( + this, + this.model, + this.chain !== undefined ? this.chain.toString().split(".") : [], + this.options + ); + defs.push(this.fieldSelector.appendTo($("<div/>")).then((function () { + var wDefs = []; + + if (!this.readonly) { + // Set list of operators according to field type + var selectedField = this.fieldSelector.getSelectedField() || {}; + this.operators = this._getOperatorsFromType(selectedField.type); + if (_.contains(["child_of", "parent_of", "like", "not like", "=like", "=ilike"], this.operator)) { + // In case user entered manually or from demo data + this.operators[this.operator] = operator_mapping[this.operator]; + } else if (!this.operators[this.operator]) { + // In case the domain uses an unsupported operator for the + // field type + this.operators[this.operator] = "?"; + } + + // Set list of values according to field type + this.selectionChoices = null; + if (selectedField.type === "boolean") { + this.selectionChoices = [["1", _t("set (true)")], ["0", _t("not set (false)")]]; + } else if (selectedField.type === "selection") { + this.selectionChoices = selectedField.selection; + } + + // Adapt display value and operator for rendering + this.displayValue = this.value; + try { + if (selectedField && !selectedField.relation && !_.isArray(this.value)) { + this.displayValue = field_utils.format[selectedField.type](this.value, selectedField); + } + } catch (err) {/**/} + this.displayOperator = this.operator; + if (selectedField.type === "boolean") { + this.displayValue = this.value ? "1" : "0"; + } else if ((this.operator === "!=" || this.operator === "=") && this.value === false) { + this.displayOperator = this.operator === "!=" ? "set" : "not set"; + } + + // TODO the value could be a m2o input, etc... + if (_.contains(["date", "datetime"], selectedField.type)) { + this.valueWidget = new (selectedField.type === "datetime" ? datepicker.DateTimeWidget : datepicker.DateWidget)(this); + wDefs.push(this.valueWidget.appendTo("<div/>").then((function () { + this.valueWidget.$el.addClass("o_domain_leaf_value_input"); + this.valueWidget.setValue(moment(this.value)); + this.valueWidget.on("datetime_changed", this, function () { + this._changeValue(this.valueWidget.getValue()); + }); + }).bind(this))); + } + + return Promise.all(wDefs); + } + }).bind(this))); + + return Promise.all(defs); + }, + /** + * @see DomainNode.start + * Appends the prepared field selector and value widget. + * + * @returns {Promise} + */ + start: function () { + this.fieldSelector.$el.prependTo(this.$("> .o_domain_leaf_info, > .o_domain_leaf_edition")); // place the field selector + if (!this.readonly && this.valueWidget) { // In edit mode, place the value widget if any + this.$(".o_domain_leaf_value_input").replaceWith(this.valueWidget.$el); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @see DomainNode.isValid + * @returns {boolean} + */ + isValid: function () { + return this.fieldSelector && this.fieldSelector.isValid(); + }, + /** + * @see DomainNode.getDomain + * @returns {Array} + */ + getDomain: function () { + return [[this.chain, this.operator, this.value]]; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Handles a field chain change in the domain. In that case, the operator + * should be adapted to a valid one for the new field and the value should + * also be adapted to the new field and/or operator. + * + * -> trigger_up domain_changed event to ask for a re-rendering (if not + * silent) + * + * @param {string[]} chain - the new field chain + * @param {boolean} silent - true if the method call should not trigger_up a + * domain_changed event + */ + _changeFieldChain: function (chain, silent) { + this.chain = chain.join("."); + this.fieldSelector.setChain(chain).then((function () { + if (!this.fieldSelector.isValid()) return; + + var selectedField = this.fieldSelector.getSelectedField() || {}; + var operators = this._getOperatorsFromType(selectedField.type); + if (operators[this.operator] === undefined) { + this._changeOperator("=", true); + } + this._changeValue(this.value, true); + + if (!silent) this.trigger_up("domain_changed", {child: this}); + }).bind(this)); + }, + /** + * Handles an operator change in the domain. In that case, the value should + * be adapted to a valid one for the new operator. + * + * -> trigger_up domain_changed event to ask for a re-rendering + * (if not silent) + * + * @param {string} operator - the new operator + * @param {boolean} silent - true if the method call should not trigger_up a + * domain_changed event + */ + _changeOperator: function (operator, silent) { + this.operator = operator; + + if (_.contains(["set", "not set"], this.operator)) { + this.operator = this.operator === "not set" ? "=" : "!="; + this.value = false; + } else if (_.contains(["in", "not in"], this.operator)) { + this.value = _.isArray(this.value) ? this.value : this.value ? ("" + this.value).split(",") : []; + } else { + if (_.isArray(this.value)) { + this.value = this.value.join(","); + } + this._changeValue(this.value, true); + } + + if (!silent) this.trigger_up("domain_changed", {child: this}); + }, + /** + * Handles a formatted value change in the domain. In that case, the value + * should be adapted to a valid technical one. + * + * -> trigger_up "domain_changed" event to ask for a re-rendering (if not + * silent) + * + * @param {*} value - the new formatted value + * @param {boolean} silent - true if the method call should not trigger_up a + * domain_changed event + */ + _changeValue: function (value, silent) { + var couldNotParse = false; + var selectedField = this.fieldSelector.getSelectedField() || {}; + try { + this.value = field_utils.parse[selectedField.type](value, selectedField); + } catch (err) { + this.value = value; + couldNotParse = true; + } + + if (selectedField.type === "boolean") { + if (!_.isBoolean(this.value)) { // Convert boolean-like value to boolean + this.value = !!parseFloat(this.value); + } + } else if (selectedField.type === "selection") { + if (!_.some(selectedField.selection, (function (option) { return option[0] === this.value; }).bind(this))) { + this.value = selectedField.selection[0][0]; + } + } else if (_.contains(["date", "datetime"], selectedField.type)) { + if (couldNotParse || _.isBoolean(this.value)) { + this.value = field_utils.parse[selectedField.type](field_utils.format[selectedField.type](moment())).toJSON(); // toJSON to get date with server format + } else { + this.value = this.value.toJSON(); // toJSON to get date with server format + } + } else { + // Never display "true" or "false" strings from boolean value + if (_.isBoolean(this.value)) { + this.value = ""; + } else if (_.isObject(this.value) && !_.isArray(this.value)) { // Can be object if parsed to x2x representation + this.value = this.value.id || value || ""; + } + } + + if (!silent) this.trigger_up("domain_changed", {child: this}); + }, + /** + * Returns the mapping of "technical operator" to "display operator value" + * of the operators which are available for the given field type. + * + * @private + * @param {string} type - the field type + * @returns {Object} a map of all associated operators and their label + */ + _getOperatorsFromType: function (type) { + var operators = {}; + + switch (type) { + case "boolean": + operators = { + "=": _t("is"), + "!=": _t("is not"), + }; + break; + + case "char": + case "text": + case "html": + operators = _.pick(operator_mapping, "=", "!=", "ilike", "not ilike", "set", "not set", "in", "not in"); + break; + + case "many2many": + case "one2many": + case "many2one": + operators = _.pick(operator_mapping, "=", "!=", "ilike", "not ilike", "set", "not set"); + break; + + case "integer": + case "float": + case "monetary": + operators = _.pick(operator_mapping, "=", "!=", ">", "<", ">=", "<=", "ilike", "not ilike", "set", "not set"); + break; + + case "selection": + operators = _.pick(operator_mapping, "=", "!=", "set", "not set"); + break; + + case "date": + case "datetime": + operators = _.pick(operator_mapping, "=", "!=", ">", "<", ">=", "<=", "set", "not set"); + break; + + default: + operators = _.extend({}, operator_mapping); + break; + } + + if (this.options.operators) { + operators = _.pick.apply(_, [operators].concat(this.options.operators)); + } + + return operators; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the operator select value is change -> change the operator + * internal state and adapt + * + * @param {Event} e + */ + _onOperatorSelectChange: function (e) { + this._changeOperator($(e.currentTarget).val()); + }, + /** + * Called when the value input value is changed -> change the internal value + * state and adapt + * + * @param {Event} e + */ + _onValueInputChange: function (e) { + if (e.currentTarget !== e.target) return; + this._changeValue($(e.currentTarget).val()); + }, + /** + * Called when the field selector value is changed -> change the internal + * chain state and adapt + * + * @param {OdooEvent} e + */ + _onFieldChainChange: function (e) { + this._changeFieldChain(e.data.chain); + }, + + // TODO The two following functions should be in an independant widget + on_add_tag: function (e) { + if (e.type === "keyup" && e.which !== $.ui.keyCode.ENTER) return; + if (!_.contains(["not in", "in"], this.operator)) return; + + var values = _.isArray(this.value) ? this.value.slice() : []; + + var $input = this.$(".o_domain_leaf_value_tags input"); + var val = $input.val().trim(); + if (val && values.indexOf(val) < 0) { + values.push(val); + _.defer(this._changeValue.bind(this, values)); + $input.focus(); + } + }, + on_remove_tag: function (e) { + var values = _.isArray(this.value) ? this.value.slice() : []; + var val = this.$(e.currentTarget).data("value"); + + var index = values.indexOf(val); + if (index >= 0) { + values.splice(index, 1); + _.defer(this._changeValue.bind(this, values)); + } + }, +}); + +/** + * Instantiates a DomainTree if the given domain contains several parts and a + * DomainLeaf if it only contains one part. Returns null otherwise. + * + * @param {Object} parent + * @param {string} model - the model name + * @param {Array|string} domain - the prefix representation of the domain + * @param {Object} options - @see DomainNode.init.options + * @returns {DomainTree|DomainLeaf|null} + */ +function instantiateNode(parent, model, domain, options) { + if (domain.length > 1) { + return new DomainTree(parent, model, domain, options); + } else if (domain.length === 1) { + return new DomainLeaf(parent, model, domain, options); + } + return null; +} + +return DomainSelector; +}); diff --git a/addons/web/static/src/js/widgets/domain_selector_dialog.js b/addons/web/static/src/js/widgets/domain_selector_dialog.js new file mode 100644 index 00000000..f6fe7f0c --- /dev/null +++ b/addons/web/static/src/js/widgets/domain_selector_dialog.js @@ -0,0 +1,54 @@ +odoo.define("web.DomainSelectorDialog", function (require) { +"use strict"; + +var core = require("web.core"); +var Dialog = require("web.Dialog"); +var DomainSelector = require("web.DomainSelector"); + +var _t = core._t; + +/** + * @class DomainSelectorDialog + */ +return Dialog.extend({ + init: function (parent, model, domain, options) { + this.model = model; + this.options = _.extend({ + readonly: true, + debugMode: false, + }, options || {}); + + var buttons; + if (this.options.readonly) { + buttons = [ + {text: _t("Close"), close: true}, + ]; + } else { + buttons = [ + {text: _t("Save"), classes: "btn-primary", close: true, click: function () { + this.trigger_up("domain_selected", {domain: this.domainSelector.getDomain()}); + }}, + {text: _t("Discard"), close: true}, + ]; + } + + this._super(parent, _.extend({}, { + title: _t("Domain"), + buttons: buttons, + }, options || {})); + + this.domainSelector = new DomainSelector(this, model, domain, options); + }, + start: function () { + var self = this; + this.opened().then(function () { + // this restores default modal height (bootstrap) and allows field selector to overflow + self.$el.css('overflow', 'visible').closest('.modal-dialog').css('height', 'auto'); + }); + return Promise.all([ + this._super.apply(this, arguments), + this.domainSelector.appendTo(this.$el) + ]); + }, +}); +}); diff --git a/addons/web/static/src/js/widgets/iframe_widget.js b/addons/web/static/src/js/widgets/iframe_widget.js new file mode 100644 index 00000000..8ecbd5f1 --- /dev/null +++ b/addons/web/static/src/js/widgets/iframe_widget.js @@ -0,0 +1,65 @@ +odoo.define('web.IFrameWidget', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +/** + * Generic widget to create an iframe that listens for clicks + * + * It should be extended by overwriting the methods:: + * + * init: function(parent) { + * this._super(parent, <url_of_iframe>); + * }, + * _onIFrameClicked: function(e){ + * filter the clicks you want to use and apply + * an action on it + * } + */ +var IFrameWidget = Widget.extend({ + tagName: 'iframe', + /** + * @constructor + * @param {Widget} parent + * @param {string} url + */ + init: function (parent, url) { + this._super(parent); + this.url = url; + }, + /** + * @override + * @returns {Promise} + */ + start: function () { + this.$el.css({height: '100%', width: '100%', border: 0}); + this.$el.attr({src: this.url}); + this.$el.on("load", this._bindEvents.bind(this)); + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when the iframe is ready + */ + _bindEvents: function (){ + this.$el.contents().click(this._onIFrameClicked.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {MouseEvent} event + */ + _onIFrameClicked: function (event){ + } +}); + +return IFrameWidget; + +}); diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js new file mode 100644 index 00000000..1856bccf --- /dev/null +++ b/addons/web/static/src/js/widgets/model_field_selector.js @@ -0,0 +1,615 @@ +odoo.define("web.ModelFieldSelector", function (require) { +"use strict"; + +var core = require("web.core"); +var Widget = require("web.Widget"); + +var _t = core._t; + +/** + * Field Selector Cache - TODO Should be improved to use external cache ? + * - Stores fields per model used in field selector + * @see ModelFieldSelector._getModelFieldsFromCache + */ +var modelFieldsCache = { + cache: {}, + cacheDefs: {}, +}; + +core.bus.on('clear_cache', null, function () { + modelFieldsCache.cache = {}; + modelFieldsCache.cacheDefs = {}; +}); + +/** + * The ModelFieldSelector widget can be used to display/select a particular + * field chain from a given model. + */ +var ModelFieldSelector = Widget.extend({ + template: "ModelFieldSelector", + events: {}, + editionEvents: { + // Handle popover opening and closing + "focusin": "_onFocusIn", + "focusout": "_onFocusOut", + "click .o_field_selector_close": "_onCloseClick", + + // Handle popover field navigation + "click .o_field_selector_prev_page": "_onPrevPageClick", + "click .o_field_selector_next_page": "_onNextPageClick", + "click li.o_field_selector_select_button": "_onLastFieldClick", + + // Handle a direct change in the debug input + "change input.o_field_selector_debug": "_onDebugInputChange", + + // Handle a change in the search input + "keyup .o_field_selector_search > input": "_onSearchInputChange", + + // Handle keyboard and mouse navigation to build the field chain + "mouseover li.o_field_selector_item": "_onItemHover", + "keydown": "_onKeydown", + }, + /** + * @constructor + * The ModelFieldSelector requires a model and a field chain to work with. + * + * @param {string} model - the model name (e.g. "res.partner") + * @param {string[]} chain - list of the initial field chain parts + * @param {Object} [options] - some key-value options + * @param {string} [options.order='string'] + * an ordering key for displayed fields + * @param {boolean} [options.readonly=true] - true if should be readonly + * @param {function} [options.filter] + * a function to filter the fetched fields + * @param {Object} [options.filters] + * some key-value options to filter the fetched fields + * @param {boolean} [options.filters.searchable=true] + * true if only the searchable fields have to be used + * @param {Object[]} [options.fields=null] + * the list of fields info to use when no relation has + * been followed (null indicates the widget has to request + * the fields itself) + * @param {boolean|function} [options.followRelations=true] + * true if can follow relation when building the chain + * @param {boolean} [options.showSearchInput=true] + * false to hide a search input to filter displayed fields + * @param {boolean} [options.debugMode=false] + * true if the widget is in debug mode, false otherwise + */ + init: function (parent, model, chain, options) { + this._super.apply(this, arguments); + + this.model = model; + this.chain = chain; + this.options = _.extend({ + order: 'string', + readonly: true, + filters: {}, + fields: null, + filter: function () {return true;}, + followRelations: true, + debugMode: false, + showSearchInput: true, + }, options || {}); + this.options.filters = _.extend({ + searchable: true, + }, this.options.filters); + + if (typeof this.options.followRelations !== 'function') { + this.options.followRelations = this.options.followRelations ? + function () {return true;} : + function () {return false;}; + } + + this.pages = []; + this.dirty = false; + + if (!this.options.readonly) { + _.extend(this.events, this.editionEvents); + } + + this.searchValue = ''; + }, + /** + * @see Widget.willStart() + * @returns {Promise} + */ + willStart: function () { + return Promise.all([ + this._super.apply(this, arguments), + this._prefill() + ]); + }, + /** + * @see Widget.start + * @returns {Promise} + */ + start: function () { + this.$value = this.$(".o_field_selector_value"); + this.$popover = this.$(".o_field_selector_popover"); + this.$input = this.$popover.find(".o_field_selector_popover_footer > input"); + this.$searchInput = this.$popover.find(".o_field_selector_search > input"); + this.$valid = this.$(".o_field_selector_warning"); + + this._render(); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the field information selected by the field chain. + * + * @returns {Object} + */ + getSelectedField: function () { + return _.findWhere(this.pages[this.chain.length - 1], {name: _.last(this.chain)}); + }, + /** + * Indicates if the field chain is valid. If the field chain has not been + * processed yet (the widget is not ready), this method will return + * undefined. + * + * @returns {boolean} + */ + isValid: function () { + return this.valid; + }, + /** + * Saves a new field chain (array) and re-render. + * + * @param {string[]} chain - the new field chain + * @returns {Promise} resolved once the re-rendering is finished + */ + setChain: function (chain) { + if (_.isEqual(chain, this.chain)) { + return Promise.resolve(); + } + + this.chain = chain; + return this._prefill().then(this._render.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a field name to the current field chain and marks it as dirty. + * + * @private + * @param {string} fieldName - the new field name to add at the end of the + * current field chain + */ + _addChainNode: function (fieldName) { + this.dirty = true; + this.chain = this.chain.slice(0, this.pages.length-1); + this.chain.push(fieldName); + + this.searchValue = ''; + this.$searchInput.val(''); + }, + /** + * Searches a field in the last page by its name. + * + * @private + * @param {string} name - the name of the field to find + * @returns {Object} the field data found in the last popover page thanks + * to its name + /*/ + _getLastPageField: function (name) { + return _.findWhere(_.last(this.pages), { + name: name, + }); + }, + /** + * Searches the cache for the given model fields, according to the given + * filter. If the cache does not know about the model, the cache is updated. + * + * @private + * @param {string} model + * @param {Object} filters @see ModelFieldSelector.init.options.filters + * @returns {Object[]} a list of the model fields info, sorted by field + * non-technical names + */ + _getModelFieldsFromCache: function (model, filters) { + var self = this; + var def = modelFieldsCache.cacheDefs[model]; + if (!def) { + def = modelFieldsCache.cacheDefs[model] = this._rpc({ + model: model, + method: 'fields_get', + args: [ + false, + ["store", "searchable", "type", "string", "relation", "selection", "related"] + ], + context: this.getSession().user_context, + }) + .then((function (fields) { + modelFieldsCache.cache[model] = sortFields(fields, model, self.options.order); + }).bind(this)); + } + return def.then((function () { + return _.filter(modelFieldsCache.cache[model], function (f) { + return (!filters.searchable || f.searchable) && self.options.filter(f); + }); + }).bind(this)); + }, + /** + * Adds a new page to the popover following the given field relation and + * adapts the chain node according to this given field. + * + * @private + * @param {Object} field - the field to add to the chain node + */ + _goToNextPage: function (field) { + if (!_.isEqual(this._getLastPageField(field.name), field)) return; + + this._validate(true); + this._addChainNode(field.name); + this._pushPageData(field.relation).then(this._render.bind(this)); + }, + /** + * Removes the last page, adapts the field chain and displays the new + * last page. + * + * @private + */ + _goToPrevPage: function () { + if (this.pages.length <= 0) return; + + this._validate(true); + this._removeChainNode(); + if (this.pages.length > 1) { + this.pages.pop(); + } + this._render(); + }, + /** + * Closes the popover and marks the field as selected. If the field chain + * changed, it notifies its parents. If not open, this does nothing. + * + * @private + */ + _hidePopover: function () { + if (!this._isOpen) return; + + this._isOpen = false; + this.$popover.addClass('d-none'); + + if (this.dirty) { + this.dirty = false; + this.trigger_up("field_chain_changed", {chain: this.chain}); + } + }, + /** + * Prepares the popover by filling its pages according to the current field + * chain. + * + * @private + * @returns {Promise} resolved once the whole field chain has been + * processed + */ + _prefill: function () { + this.pages = []; + return this._pushPageData(this.model).then((function () { + this._validate(true); + return (this.chain.length ? processChain.call(this, this.chain.slice().reverse()) : Promise.resolve()); + }).bind(this)); + + function processChain(chain) { + var fieldName = chain.pop(); + var field = this._getLastPageField(fieldName); + if (field && field.relation) { + if (chain.length) { // Fetch next chain node if any and possible + return this._pushPageData(field.relation).then(processChain.bind(this, chain)); + } else { // Simply update the last popover page + return this._pushPageData(field.relation); + } + } else if (field && chain.length === 0) { // Last node fetched + return Promise.resolve(); + } else if (!field && fieldName === "1") { // TRUE_LEAF + this._validate(true); + } else if (!field && fieldName === "0") { // FALSE_LEAF + this._validate(true); + } else { // Wrong node chain + this._validate(false); + } + return Promise.resolve(); + } + }, + /** + * Gets the fields of a particular model and adds them to a new last + * popover page. + * + * @private + * @param {string} model - the model name whose fields have to be fetched + * @returns {Promise} resolved once the fields have been added + */ + _pushPageData: function (model) { + var def; + if (this.model === model && this.options.fields) { + def = Promise.resolve(sortFields(this.options.fields, model, this.options.order)); + } else { + def = this._getModelFieldsFromCache(model, this.options.filters); + } + return def.then((function (fields) { + this.pages.push(fields); + }).bind(this)); + }, + /** + * Removes the last field name at the end of the current field chain and + * marks it as dirty. + * + * @private + */ + _removeChainNode: function () { + this.dirty = true; + this.chain = this.chain.slice(0, this.pages.length-1); + this.chain.pop(); + }, + /** + * Updates the rendering of the value (the serie of tags separated by + * arrows). It also adapts the content of the popover. + * + * @private + */ + _render: function () { + + // Render the chain value + this.$value.html(core.qweb.render(this.template + ".value", { + chain: this.chain, + pages: this.pages, + })); + + // Toggle the warning message + this.$valid.toggleClass('d-none', !!this.isValid()); + + // Adapt the popover content + var page = _.last(this.pages); + var title = ""; + if (this.pages.length > 1) { + var prevField = _.findWhere(this.pages[this.pages.length - 2], { + name: (this.chain.length === this.pages.length) ? this.chain[this.chain.length - 2] : _.last(this.chain), + }); + if (prevField) title = prevField.string; + } + this.$(".o_field_selector_popover_header .o_field_selector_title").text(title); + + var lines = _.filter(page, this.options.filter); + if (this.searchValue) { + var matches = fuzzy.filter(this.searchValue, _.pluck(lines, 'string')); + lines = _.map(_.pluck(matches, 'index'), function (i) { + return lines[i]; + }); + } + + this.$(".o_field_selector_page").replaceWith(core.qweb.render(this.template + ".page", { + lines: lines, + followRelations: this.options.followRelations, + debug: this.options.debugMode, + })); + this.$input.val(this.chain.join(".")); + }, + /** + * Selects the given field and adapts the chain node according to it. + * It also closes the popover and so notifies the parents about the change. + * + * @param {Object} field - the field to select + */ + _selectField: function (field) { + if (!_.isEqual(this._getLastPageField(field.name), field)) return; + + this._validate(true); + this._addChainNode(field.name); + this._render(); + this._hidePopover(); + }, + /** + * Shows the popover to select the field chain. This assumes that the + * popover has finished its rendering (fully rendered widget or resolved + * deferred of @see setChain). If already open, this does nothing. + * + * @private + */ + _showPopover: function () { + if (this._isOpen) return; + + this._isOpen = true; + this.$popover.removeClass('d-none'); + }, + /** + * Toggles the valid status of the widget and display the error message if + * it is not valid. + * + * @private + * @param {boolean} valid - true if the widget is valid, false otherwise + */ + _validate: function (valid) { + this.valid = !!valid; + + if (!this.valid) { + this.do_warn( + false, + _t("Invalid field chain. You may have used a non-existing field name or followed a non-relational field.") + ); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the widget is focused -> opens the popover + */ + _onFocusIn: function () { + clearTimeout(this._hidePopoverTimeout); + this._showPopover(); + }, + /** + * Called when the widget is blurred -> closes the popover + */ + _onFocusOut: function () { + this._hidePopoverTimeout = _.defer(this._hidePopover.bind(this)); + }, + /** + * Called when the popover "cross" icon is clicked -> closes the popover + */ + _onCloseClick: function () { + this._hidePopover(); + }, + /** + * Called when the popover "previous" icon is clicked -> removes last chain + * node + */ + _onPrevPageClick: function () { + this._goToPrevPage(); + }, + /** + * Called when a popover relation field button is clicked -> adds it to + * the chain + * + * @param {Event} e + */ + _onNextPageClick: function (e) { + e.stopPropagation(); + this._goToNextPage(this._getLastPageField($(e.currentTarget).data("name"))); + }, + /** + * Called when a popover non-relation field button is clicked -> adds it to + * chain and closes the popover + * + * @param {Event} e + */ + _onLastFieldClick: function (e) { + this._selectField(this._getLastPageField($(e.currentTarget).data("name"))); + }, + /** + * Called when the debug input value is changed -> adapts the chain + */ + _onDebugInputChange: function () { + var userChainStr = this.$input.val(); + var userChain = userChainStr.split("."); + if (!this.options.followRelations && userChain.length > 1) { + this.do_warn( + _t("Relation not allowed"), + _t("You cannot follow relations for this field chain construction") + ); + userChain = [userChain[0]]; + } + this.setChain(userChain).then((function () { + this.trigger_up("field_chain_changed", {chain: this.chain}); + }).bind(this)); + }, + /** + * Called when the search input value is changed -> adapts the popover + */ + _onSearchInputChange: function () { + this.searchValue = this.$searchInput.val(); + this._render(); + }, + /** + * Called when a popover field button item is hovered -> toggles its + * "active" status + * + * @param {Event} e + */ + _onItemHover: function (e) { + this.$("li.o_field_selector_item").removeClass("active"); + $(e.currentTarget).addClass("active"); + }, + /** + * Called when the user uses the keyboard when the widget is focused + * -> handles field keyboard navigation + * + * @param {Event} e + */ + _onKeydown: function (e) { + if (!this.$popover.is(":visible")) return; + var inputHasFocus = this.$input.is(":focus"); + var searchInputHasFocus = this.$searchInput.is(":focus"); + + switch (e.which) { + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + e.preventDefault(); + var $active = this.$("li.o_field_selector_item.active"); + var $to = $active[e.which === $.ui.keyCode.DOWN ? "next" : "prev"](".o_field_selector_item"); + if ($to.length) { + $active.removeClass("active"); + $to.addClass("active"); + this.$popover.focus(); + + var $page = $to.closest(".o_field_selector_page"); + var full_height = $page.height(); + var el_position = $to.position().top; + var el_height = $to.outerHeight(); + var current_scroll = $page.scrollTop(); + if (el_position < 0) { + $page.scrollTop(current_scroll - el_height); + } else if (full_height < el_position + el_height) { + $page.scrollTop(current_scroll + el_height); + } + } + break; + case $.ui.keyCode.RIGHT: + if (inputHasFocus) break; + e.preventDefault(); + var name = this.$("li.o_field_selector_item.active").data("name"); + if (name) { + var field = this._getLastPageField(name); + if (field.relation) { + this._goToNextPage(field); + } + } + break; + case $.ui.keyCode.LEFT: + if (inputHasFocus) break; + e.preventDefault(); + this._goToPrevPage(); + break; + case $.ui.keyCode.ESCAPE: + e.stopPropagation(); + this._hidePopover(); + break; + case $.ui.keyCode.ENTER: + if (inputHasFocus || searchInputHasFocus) break; + e.preventDefault(); + this._selectField(this._getLastPageField(this.$("li.o_field_selector_item.active").data("name"))); + break; + } + } +}); + +return ModelFieldSelector; + +/** + * Allows to transform a mapping field name -> field info in an array of the + * field infos, sorted by field user name ("string" value). The field infos in + * the final array contain an additional key "name" with the field name. + * + * @param {Object} fields - the mapping field name -> field info + * @param {string} model + * @returns {Object[]} the field infos sorted by field "string" (field infos + * contain additional keys "model" and "name" with the field + * name) + */ +function sortFields(fields, model, order) { + var array = _.chain(fields) + .pairs() + .sortBy(function (p) { return p[1].string; }); + if (order !== 'string') { + array = array.sortBy(function (p) {return p[1][order]; }); + } + return array.map(function (p) { + return _.extend({ + name: p[0], + model: model, + }, p[1]); + }).value(); +} +}); diff --git a/addons/web/static/src/js/widgets/name_and_signature.js b/addons/web/static/src/js/widgets/name_and_signature.js new file mode 100644 index 00000000..abc58a87 --- /dev/null +++ b/addons/web/static/src/js/widgets/name_and_signature.js @@ -0,0 +1,662 @@ +odoo.define('web.name_and_signature', function (require) { +'use strict'; + +var core = require('web.core'); +var config = require('web.config'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +var _t = core._t; + +/** + * This widget allows the user to input his name and to draw his signature. + * Alternatively the signature can also be generated automatically based on + * the given name and a selected font, or loaded from an image file. + */ +var NameAndSignature = Widget.extend({ + template: 'web.sign_name_and_signature', + xmlDependencies: ['/web/static/src/xml/name_and_signature.xml'], + events: { + // name + 'input .o_web_sign_name_input': '_onInputSignName', + // signature + 'click .o_web_sign_signature': '_onClickSignature', + 'change .o_web_sign_signature': '_onChangeSignature', + // draw + 'click .o_web_sign_draw_button': '_onClickSignDrawButton', + 'click .o_web_sign_draw_clear a': '_onClickSignDrawClear', + // auto + 'click .o_web_sign_auto_button': '_onClickSignAutoButton', + 'click .o_web_sign_auto_select_style a': '_onClickSignAutoSelectStyle', + 'click .o_web_sign_auto_font_selection a': '_onClickSignAutoFontSelection', + 'mouseover .o_web_sign_auto_font_selection a': '_onMouseOverSignAutoFontSelection', + 'touchmove .o_web_sign_auto_font_selection a': '_onTouchStartSignAutoFontSelection', + // load + 'click .o_web_sign_load_button': '_onClickSignLoadButton', + 'change .o_web_sign_load_file input': '_onChangeSignLoadInput', + }, + + /** + * Allows options. + * + * @constructor + * @param {Widget} parent + * @param {Object} [options={}] + * @param {number} [options.displaySignatureRatio=3.0] - The ratio used when + * (re)computing the size of the signature (width = height * ratio) + * @param {string} [options.defaultName=''] - The default name of + * the signer. + * @param {string} [options.defaultFont=''] - The unique and default + * font for auto mode. If empty, all fonts are visible. + * * @param {string} [options.fontColor='DarkBlue'] - Color of signature + * (must be a string color) + * @param {string} [options.noInputName=false] - If set to true, + * the user can not enter his name. If there aren't defaultName, + * auto mode is hidden. + * @param {string} [options.mode='draw'] - @see this.setMode + * @param {string} [options.signatureType='signature'] - The type of + * signature used in 'auto' mode. Can be one of the following values: + * + * - 'signature': it will adapt the characters width to fit the whole + * text in the image. + * - 'initial': it will adapt the space between characters to fill + * the image with the text. The text will be the first letter of + * every word in the name, separated by dots. + */ + init: function (parent, options) { + this._super.apply(this, arguments); + options = options || {}; + this.htmlId = _.uniqueId(); + this.defaultName = options.defaultName || ''; + this.defaultFont = options.defaultFont || ''; + this.fontColor = options.fontColor || 'DarkBlue'; + this.displaySignatureRatio = options.displaySignatureRatio || 3.0; + this.signatureType = options.signatureType || 'signature'; + this.signMode = options.mode || 'draw'; + this.noInputName = options.noInputName || false; + this.currentFont = 0; + this.drawTimeout = null; + this.drawPreviewTimeout = null; + }, + /** + * Loads the fonts. + * + * @override + */ + willStart: function () { + var self = this; + return Promise.all([ + this._super.apply(this, arguments), + this._rpc({route: '/web/sign/get_fonts/' + self.defaultFont}).then(function (data) { + self.fonts = data; + }) + ]); + }, + /** + * Finds the DOM elements, initializes the signature area, + * and focus the name field. + * + * @override + */ + start: function () { + var self = this; + // signature and name input + this.$signatureGroup = this.$('.o_web_sign_signature_group'); + this.$signatureField = this.$('.o_web_sign_signature'); + this.$nameInput = this.$('.o_web_sign_name_input'); + this.$nameInputGroup = this.$('.o_web_sign_name_group'); + + // mode selection buttons + this.$drawButton = this.$('a.o_web_sign_draw_button'); + this.$autoButton = this.$('a.o_web_sign_auto_button'); + this.$loadButton = this.$('a.o_web_sign_load_button'); + + // mode: draw + this.$drawClear = this.$('.o_web_sign_draw_clear'); + + // mode: auto + this.$autoSelectStyle = this.$('.o_web_sign_auto_select_style'); + this.$autoFontSelection = this.$('.o_web_sign_auto_font_selection'); + this.$autoFontList = this.$('.o_web_sign_auto_font_list'); + for (var i in this.fonts) { + var $img = $('<img/>').addClass('img-fluid'); + var $a = $('<a/>').addClass('btn p-0').append($img).data('fontNb', i); + this.$autoFontList.append($a); + } + + // mode: load + this.$loadFile = this.$('.o_web_sign_load_file'); + this.$loadInvalid = this.$('.o_web_sign_load_invalid'); + + if (this.fonts && this.fonts.length < 2) { + this.$autoSelectStyle.hide(); + } + + if (this.noInputName) { + if (this.defaultName === "") { + this.$autoButton.hide(); + } + this.$nameInputGroup.hide(); + } + + // Resize the signature area if it is resized + $(window).on('resize.o_web_sign_name_and_signature', _.debounce(function () { + if (self.isDestroyed()) { + // May happen since this is debounced + return; + } + self.resizeSignature(); + }, 250)); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(window).off('resize.o_web_sign_name_and_signature'); + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Focuses the name. + */ + focusName: function () { + // Don't focus on mobile + if (!config.device.isMobile) { + this.$nameInput.focus(); + } + }, + /** + * Gets the name currently given by the user. + * + * @returns {string} name + */ + getName: function () { + return this.$nameInput.val(); + }, + /** + * Gets the signature currently drawn. The data format is that produced + * natively by Canvas - base64 encoded (likely PNG) bitmap data. + * + * @returns {string[]} Array that contains the signature as a bitmap. + * The first element is the mimetype, the second element is the data. + */ + getSignatureImage: function () { + return this.$signatureField.jSignature('getData', 'image'); + }, + /** + * Gets the signature currently drawn, in a format ready to be used in + * an <img/> src attribute. + * + * @returns {string} the signature currently drawn, src ready + */ + getSignatureImageSrc: function () { + return this.$signatureField.jSignature('getData'); + }, + /** + * Returns whether the drawing area is currently empty. + * + * @returns {boolean} Whether the drawing area is currently empty. + */ + isSignatureEmpty: function () { + var signature = this.$signatureField.jSignature('getData'); + return signature && this.emptySignature ? this.emptySignature === signature : true; + }, + resizeSignature: function() { + if (!this.$signatureField) { + return; + } + // recompute size based on the current width + this.$signatureField.css({width: 'unset'}); + const width = this.$signatureField.width(); + const height = parseInt(width / this.displaySignatureRatio); + + // necessary because the lib is adding invisible div with margin + // signature field too tall without this code + this.$signatureField.css({ + width: width, + height: height, + }); + this.$signatureField.find('canvas').css({ + width: width, + height: height, + }); + return {width, height}; + }, + /** + * (Re)initializes the signature area: + * - set the correct width and height of the drawing based on the width + * of the container and the ratio option + * - empty any previous content + * - correctly reset the empty state + * - call @see setMode with reset + * + * @returns {Deferred} + */ + resetSignature: function () { + if (!this.$signatureField) { + // no action if called before start + return Promise.reject(); + } + + const {width, height} = this.resizeSignature(); + + this.$signatureField + .empty() + .jSignature({ + 'decor-color': '#D1D0CE', + 'background-color': 'rgba(255,255,255,0)', + 'show-stroke': false, + 'color': this.fontColor, + 'lineWidth': 2, + 'width': width, + 'height': height, + }); + this.emptySignature = this.$signatureField.jSignature('getData'); + + this.setMode(this.signMode, true); + + this.focusName(); + + return Promise.resolve(); + }, + /** + * Changes the signature mode. Toggles the display of the relevant + * controls and resets the drawing. + * + * @param {string} mode - the mode to use. Can be one of the following: + * - 'draw': the user draws the signature manually with the mouse + * - 'auto': the signature is drawn automatically using a selected font + * - 'load': the signature is loaded from an image file + * @param {boolean} [reset=false] - Set to true to reset the elements + * even if the @see mode has not changed. By default nothing happens + * if the @see mode is already selected. + */ + setMode: function (mode, reset) { + if (reset !== true && mode === this.signMode) { + // prevent flickering and unnecessary compute + return; + } + + this.signMode = mode; + + this.$drawClear.toggleClass('d-none', this.signMode !== 'draw'); + this.$autoSelectStyle.toggleClass('d-none', this.signMode !== 'auto'); + this.$loadFile.toggleClass('d-none', this.signMode !== 'load'); + + this.$drawButton.toggleClass('active', this.signMode === 'draw'); + this.$autoButton.toggleClass('active', this.signMode === 'auto'); + this.$loadButton.toggleClass('active', this.signMode === 'load'); + + this.$signatureField.jSignature(this.signMode === 'draw' ? 'enable' : 'disable'); + this.$signatureField.jSignature('reset'); + + if (this.signMode === 'auto') { + // draw based on name + this._drawCurrentName(); + } else { + // close style dialog + this.$autoFontSelection.addClass('d-none'); + } + + if (this.signMode !== 'load') { + // close invalid file alert + this.$loadInvalid.addClass('d-none'); + } + }, + /** + * Gets the current name and signature, validates them, and returns + * the result. If they are invalid, displays the errors to the user. + * + * @returns {boolean} whether the current name and signature are valid + */ + validateSignature: function () { + var name = this.getName(); + var isSignatureEmpty = this.isSignatureEmpty(); + this.$nameInput.parent().toggleClass('o_has_error', !name) + .find('.form-control, .custom-select').toggleClass('is-invalid', !name); + this.$signatureGroup.toggleClass('border-danger', isSignatureEmpty); + return name && !isSignatureEmpty; + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Draws the current name with the current font in the signature field. + * + * @private + */ + _drawCurrentName: function () { + var font = this.fonts[this.currentFont]; + var text = this._getCleanedName(); + var canvas = this.$signatureField.find('canvas')[0]; + var img = this._getSVGText(font, text, canvas.width, canvas.height); + return this._printImage(img); + }, + /** + * Returns the given name after cleaning it by removing characters that + * are not supposed to be used in a signature. If @see signatureType is set + * to 'initial', returns the first letter of each word, separated by dots. + * + * @private + * @returns {string} cleaned name + */ + _getCleanedName: function () { + var text = this.getName(); + if (this.signatureType === 'initial') { + return (text.split(' ').map(function (w) { + return w[0]; + }).join('.') + '.'); + } + return text; + }, + /** + * Gets an SVG matching the given parameters, output compatible with the + * src attribute of <img/>. + * + * @private + * @param {string} font: base64 encoded font to use + * @param {string} text: the name to draw + * @param {number} width: the width of the resulting image in px + * @param {number} height: the height of the resulting image in px + * @returns {string} image = mimetype + image data + */ + _getSVGText: function (font, text, width, height) { + var $svg = $(core.qweb.render('web.sign_svg_text', { + width: width, + height: height, + font: font, + text: text, + type: this.signatureType, + color: this.fontColor, + })); + $svg.attr({ + 'xmlns': "http://www.w3.org/2000/svg", + 'xmlns:xlink': "http://www.w3.org/1999/xlink", + }); + + return "data:image/svg+xml," + encodeURI($svg[0].outerHTML); + }, + /** + * Displays the given image in the signature field. + * If needed, resizes the image to fit the existing area. + * + * @private + * @param {string} imgSrc - data of the image to display + */ + _printImage: function (imgSrc) { + var self = this; + + var image = new Image; + image.onload = function () { + // don't slow down the UI if the drawing is slow, and prevent + // drawing twice when calling this method in rapid succession + clearTimeout(self.drawTimeout); + self.drawTimeout = setTimeout(function () { + var width = 0; + var height = 0; + var ratio = image.width / image.height; + + var $canvas = self.$signatureField.find('canvas'); + var context = $canvas[0].getContext('2d'); + + if (image.width / $canvas[0].width > image.height / $canvas[0].height) { + width = $canvas[0].width; + height = parseInt(width / ratio); + } else { + height = $canvas[0].height; + width = parseInt(height * ratio); + } + self.$signatureField.jSignature('reset'); + var ignoredContext = _.pick(context, ['shadowOffsetX', 'shadowOffsetY']); + _.extend(context, {shadowOffsetX: 0, shadowOffsetY: 0}); + context.drawImage(image, + 0, + 0, + image.width, + image.height, + ($canvas[0].width - width) / 2, + ($canvas[0].height - height) / 2, + width, + height + ); + _.extend(context, ignoredContext); + self.trigger_up('signature_changed'); + }, 0); + }; + image.src = imgSrc; + }, + /** + * Sets the font to use in @see mode 'auto'. Redraws the signature if + * the font has been changed. + * + * @private + * @param {number} index - index of the font in @see this.fonts + */ + _setFont: function (index) { + if (index !== this.currentFont) { + this.currentFont = index; + this._drawCurrentName(); + } + }, + /** + * Updates the preview buttons by rendering the signature for each font. + * + * @private + */ + _updatePreviewButtons: function () { + var self = this; + // don't slow down the UI if the drawing is slow, and prevent + // drawing twice when calling this method in rapid succession + clearTimeout(this.drawPreviewTimeout); + this.drawPreviewTimeout = setTimeout(function () { + var height = 100; + var width = parseInt(height * self.displaySignatureRatio); + var $existingButtons = self.$autoFontList.find('a'); + for (var i = 0; i < self.fonts.length; i++) { + var imgSrc = self._getSVGText( + self.fonts[i], + self._getCleanedName() || _t("Your name"), + width, + height + ); + $existingButtons.eq(i).find('img').attr('src', imgSrc); + } + }, 0); + }, + /** + * Waits for the signature to be not empty and triggers up the event + * `signature_changed`. + * This is necessary because some methods of jSignature are async but + * they don't return a promise and don't trigger any event. + * + * @private + * @param {Deferred} [def=Deferred] - Deferred that will be returned by + * the method and resolved when the signature is not empty anymore. + * @returns {Deferred} + */ + _waitForSignatureNotEmpty: function (def) { + def = def || $.Deferred(); + if (!this.isSignatureEmpty()) { + this.trigger_up('signature_changed'); + def.resolve(); + } else { + // Use the existing def to prevent the method from creating a new + // one at every loop. + setTimeout(this._waitForSignatureNotEmpty.bind(this, def), 10); + } + return def; + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Handles click on the signature: closes the font selection. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onClickSignature: function (ev) { + this.$autoFontSelection.addClass('d-none'); + }, + /** + * Handles click on the Auto button: activates @see mode 'auto'. + * + * @private + * @param {Event} ev + */ + _onClickSignAutoButton: function (ev) { + ev.preventDefault(); + this.setMode('auto'); + }, + /** + * Handles click on a font: uses it and closes the font selection. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onClickSignAutoFontSelection: function (ev) { + this.$autoFontSelection.addClass('d-none').removeClass('d-flex').css('width', 0); + this._setFont(parseInt($(ev.currentTarget).data('font-nb'))); + }, + /** + * Handles click on Select Style: opens and updates the font selection. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onClickSignAutoSelectStyle: function (ev) { + var self = this; + var width = Math.min( + self.$autoFontSelection.find('a').first().height() * self.displaySignatureRatio * 1.25, + this.$signatureField.width() + ); + + ev.preventDefault(); + self._updatePreviewButtons(); + + this.$autoFontSelection.removeClass('d-none').addClass('d-flex'); + this.$autoFontSelection.show().animate({'width': width}, 500, function () {}); + }, + /** + * Handles click on the Draw button: activates @see mode 'draw'. + * + * @private + * @param {Event} ev + */ + _onClickSignDrawButton: function (ev) { + ev.preventDefault(); + this.setMode('draw'); + }, + /** + * Handles click on clear: empties the signature field. + * + * @see mode 'draw' + * @private + * @param {Event} ev + */ + _onClickSignDrawClear: function (ev) { + ev.preventDefault(); + this.$signatureField.jSignature('reset'); + }, + /** + * Handles click on the Load button: activates @see mode 'load'. + * + * @private + * @param {Event} ev + */ + _onClickSignLoadButton: function (ev) { + ev.preventDefault(); + // open file upload automatically (saves 1 click) + this.$loadFile.find('input').click(); + this.setMode('load'); + }, + /** + * Triggers up the signature change event. + * + * @private + * @param {Event} ev + */ + _onChangeSignature: function (ev) { + this.trigger_up('signature_changed'); + }, + /** + * Handles change on load file input: displays the loaded image if the + * format is correct, or diplays an error otherwise. + * + * @see mode 'load' + * @private + * @param {Event} ev + * @return bool|undefined + */ + _onChangeSignLoadInput: function (ev) { + var self = this; + var f = ev.target.files[0]; + if (f === undefined) { + return false; + } + if (f.type.substr(0, 5) !== 'image') { + this.$signatureField.jSignature('reset'); + this.$loadInvalid.removeClass('d-none'); + return false; + } + this.$loadInvalid.addClass('d-none'); + + utils.getDataURLFromFile(f).then(function (result) { + self._printImage(result); + }); + }, + /** + * Handles input on name field: if the @see mode is 'auto', redraws the + * signature with the new name. Also updates the font selection if open. + * + * @private + * @param {Event} ev + */ + _onInputSignName: function (ev) { + if (this.signMode !== 'auto') { + return; + } + this._drawCurrentName(); + if (!this.$autoFontSelection.hasClass('d-none')) { + this._updatePreviewButtons(); + } + }, + /** + * Handles mouse over on font selection: uses this font. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onMouseOverSignAutoFontSelection: function (ev) { + this._setFont(parseInt($(ev.currentTarget).data('font-nb'))); + }, + /** + * Handles touch start on font selection: uses this font. + * + * @see mode 'auto' + * @private + * @param {Event} ev + */ + _onTouchStartSignAutoFontSelection: function (ev) { + this._setFont(parseInt($(ev.currentTarget).data('font-nb'))); + }, +}); + +return { + NameAndSignature: NameAndSignature, +}; +}); diff --git a/addons/web/static/src/js/widgets/notification.js b/addons/web/static/src/js/widgets/notification.js new file mode 100644 index 00000000..0e9cdabe --- /dev/null +++ b/addons/web/static/src/js/widgets/notification.js @@ -0,0 +1,176 @@ +odoo.define('web.Notification', function (require) { +'use strict'; + +var Widget = require('web.Widget'); + +/** + * Widget which is used to display a warning/information message on the top + * right of the screen. + * + * If you want to display such a notification, you probably do not want to do it + * by importing this file. The proper way is to use the do_warn or do_notify + * methods on the Widget class. + */ +var Notification = Widget.extend({ + template: 'Notification', + events: { + 'hidden.bs.toast': '_onClose', + 'click .o_notification_buttons button': '_onClickButton', + 'mouseenter': '_onMouseEnter', + 'mouseleave': '_onMouseLeave', + }, + _autoCloseDelay: 4000, + _animation: true, + + /** + * @override + * @param {Widget} parent + * @param {Object} params + * @param {string} params.title + * @param {string} params.subtitle + * @param {string} [params.message] + * @param {string} [params.type='warning'] 'info', 'success', 'warning', 'danger' or '' + * @param {boolean} [params.sticky=false] if true, the notification will + * stay visible until the user clicks on it. + * @param {string} [params.className] + * @param {function} [params.onClose] callback when the user click on the x + * or when the notification is auto close (no sticky) + * @param {Object[]} params.buttons + * @param {function} params.buttons[0].click callback on click + * @param {boolean} [params.buttons[0].primary] display the button as primary + * @param {string} [params.buttons[0].text] button label + * @param {string} [params.buttons[0].icon] font-awsome className or image src + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this.title = params.title; + this.subtitle = params.subtitle; + this.message = params.message; + this.buttons = params.buttons || []; + this.sticky = !!this.buttons.length || !!params.sticky; + this.type = params.type === undefined ? 'warning' : params.type; + this.className = params.className || ''; + this._closeCallback = params.onClose; + + if (this.type === 'danger') { + this.icon = 'fa-exclamation'; + this.className += ' bg-danger'; + } else if (this.type === 'warning') { + this.icon = 'fa-lightbulb-o'; + this.className += ' bg-warning'; + } else if (this.type === 'success') { + this.icon = 'fa-check'; + this.className += ' bg-success'; + } else if (this.type === 'info') { + this.icon = 'fa-info'; + this.className += ' bg-info'; + } + + if (this.buttons && this.buttons.length) { + this.icon = 'fa-question-circle-o'; + } + }, + /** + * @override + */ + start: function () { + this.$el.toast({ + animation: this._animation, + autohide: false, + }); + void this.$el[0].offsetWidth; // Force a paint refresh before showing the toast + if (!this.sticky) { + this.autohide = _.cancellableThrottleRemoveMeSoon(this.close, this._autoCloseDelay, {leading: false}); + this.$el.on('shown.bs.toast', () => { + this.autohide(); + }); + } + this.$el.toast('show'); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this.$el.toast('dispose'); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Destroys the widget with a nice animation. + * + * @private + * @param {boolean} [silent=false] if true, the notification does not call + * _closeCallback method + */ + close: function (silent) { + this.silent = silent; + this.$el.toast('hide'); + + // Make 'close' work if the notification is not shown yet but will be. + // Should not be needed but the calendar notification system is an + // example of feature that does not work without this yet. + var self = this; + this.$el.one('shown.bs.toast', function () { + self.$el.toast('hide'); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickButton: function (ev) { + ev.preventDefault(); + if (this._buttonClicked) { + return; + } + this._buttonClicked = true; + var index = $(ev.currentTarget).index(); + var button = this.buttons[index]; + if (button.click) { + button.click(); + } + this.close(true); + }, + /** + * @private + * @param {Event} ev + */ + _onClose: function (ev) { + this.trigger_up('close'); + if (!this.silent && !this._buttonClicked) { + if (this._closeCallback) { + this._closeCallback(); + } + } + this.destroy(); + }, + /** + * @private + */ + _onMouseEnter: function () { + if (!this.sticky) { + this.autohide.cancel(); + } + }, + /** + * @private + */ + _onMouseLeave: function () { + if (!this.sticky) { + this.autohide(); + } + }, +}); + +return Notification; +}); diff --git a/addons/web/static/src/js/widgets/pie_chart.js b/addons/web/static/src/js/widgets/pie_chart.js new file mode 100644 index 00000000..f4254ff3 --- /dev/null +++ b/addons/web/static/src/js/widgets/pie_chart.js @@ -0,0 +1,102 @@ +odoo.define('web.PieChart', function (require) { +"use strict"; + +/** + * This widget render a Pie Chart. It is used in the dashboard view. + */ + +var core = require('web.core'); +var Domain = require('web.Domain'); +var viewRegistry = require('web.view_registry'); +var Widget = require('web.Widget'); +var widgetRegistry = require('web.widget_registry'); + +var qweb = core.qweb; + +var PieChart = Widget.extend({ + className: 'o_pie_chart', + xmlDependencies: ['/web/static/src/xml/chart.xml'], + + /** + * @override + * @param {Widget} parent + * @param {Object} record + * @param {Object} node node from arch + */ + init: function (parent, record, node) { + this._super.apply(this, arguments); + + var modifiers = node.attrs.modifiers; + var domain = record.domain.concat( + Domain.prototype.stringToArray(modifiers.domain || '[]')); + var arch = qweb.render('web.PieChart', { + modifiers: modifiers, + title: node.attrs.title || modifiers.title || modifiers.measure, + }); + + var pieChartContext = JSON.parse(JSON.stringify(record.context)); + delete pieChartContext.graph_mode; + delete pieChartContext.graph_measure; + delete pieChartContext.graph_groupbys; + + this.subViewParams = { + modelName: record.model, + withButtons: false, + withControlPanel: false, + withSearchPanel: false, + isEmbedded: true, + useSampleModel: record.isSample, + mode: 'pie', + }; + this.subViewParams.searchQuery = { + context: pieChartContext, + domain: domain, + groupBy: [], + timeRanges: {}, + }; + + this.viewInfo = { + arch: arch, + fields: record.fields, + viewFields: record.fieldsInfo.dashboard, + }; + }, + /** + * Instantiates the pie chart view and starts the graph controller. + * + * @override + */ + willStart: function () { + var self = this; + var def1 = this._super.apply(this, arguments); + + var SubView = viewRegistry.get('graph'); + var subView = new SubView(this.viewInfo, this.subViewParams); + var def2 = subView.getController(this).then(function (controller) { + self.controller = controller; + return self.controller.appendTo(document.createDocumentFragment()); + }); + return Promise.all([def1, def2]); + }, + /** + * @override + */ + start: function () { + this.$el.append(this.controller.$el); + return this._super.apply(this, arguments); + }, + /** + * Call `on_attach_callback` for each subview + * + * @override + */ + on_attach_callback: function () { + this.controller.on_attach_callback(); + }, +}); + +widgetRegistry.add('pie_chart', PieChart); + +return PieChart; + +}); diff --git a/addons/web/static/src/js/widgets/rainbow_man.js b/addons/web/static/src/js/widgets/rainbow_man.js new file mode 100644 index 00000000..fa1b1fac --- /dev/null +++ b/addons/web/static/src/js/widgets/rainbow_man.js @@ -0,0 +1,71 @@ +odoo.define('web.RainbowMan', function (require) { +"use strict"; + +/** + * The RainbowMan widget is the widget displayed by default as a 'fun/rewarding' + * effect in some cases. For example, when the user marked a large deal as won, + * or when he cleared its inbox. + * + * This widget is mostly a picture and a message with a rainbow animation around + * If you want to display a RainbowMan, you probably do not want to do it by + * importing this file. The usual way to do that would be to use the effect + * service (by triggering the 'show_effect' event) + */ + +var Widget = require('web.Widget'); +var core = require('web.core'); + +var _t = core._t; + +var RainbowMan = Widget.extend({ + template: 'rainbow_man.notification', + xmlDependencies: ['/web/static/src/xml/rainbow_man.xml'], + /** + * @override + * @constructor + * @param {Object} [options] + * @param {string} [options.message] Message to be displayed on rainbowman card + * @param {string} [options.fadeout='medium'] Delay for rainbowman to disappear. 'fast' will make rainbowman dissapear quickly, 'medium' and 'slow' will wait little longer before disappearing (can be used when options.message is longer), 'no' will keep rainbowman on screen until user clicks anywhere outside rainbowman + * @param {string} [options.img_url] URL of the image to be displayed + */ + init: function (options) { + this._super.apply(this, arguments); + var rainbowDelay = {slow: 4500, medium: 3500, fast: 2000, no: false}; + this.options = _.defaults(options || {}, { + fadeout: 'medium', + img_url: '/web/static/src/img/smile.svg', + message: _t('Well Done!'), + }); + this.delay = rainbowDelay[this.options.fadeout]; + }, + /** + * @override + */ + start: function () { + var self = this; + // destroy rainbow man when the user clicks outside + // this is done in a setTimeout to prevent the click that triggered the + // rainbow man to close it directly + setTimeout(function () { + core.bus.on('click', self, function (ev) { + if (ev.originalEvent && ev.target.className.indexOf('o_reward') === -1) { + this.destroy(); + } + }); + }); + if (this.delay) { + setTimeout(function () { + self.$el.addClass('o_reward_fading'); + setTimeout(function () { + self.destroy(); + }, 600); // destroy only after fadeout animation is completed + }, this.delay); + } + this.$('.o_reward_msg_content').append(this.options.message); + return this._super.apply(this, arguments); + } +}); + +return RainbowMan; + +}); diff --git a/addons/web/static/src/js/widgets/ribbon.js b/addons/web/static/src/js/widgets/ribbon.js new file mode 100644 index 00000000..a682c65a --- /dev/null +++ b/addons/web/static/src/js/widgets/ribbon.js @@ -0,0 +1,48 @@ +odoo.define('web.ribbon', function (require) { + 'use strict'; + + /** + * This widget adds a ribbon on the top right side of the form + * + * - You can specify the text with the title attribute. + * - You can specify the tooltip with the tooltip attribute. + * - You can specify a background color for the ribbon with the bg_color attribute + * using bootstrap classes : + * (bg-primary, bg-secondary, bg-success, bg-danger, bg-warning, bg-info, + * bg-light, bg-dark, bg-white) + * + * If you don't specify the bg_color attribute the bg-success class will be used + * by default. + */ + + var widgetRegistry = require('web.widget_registry'); + var Widget = require('web.Widget'); + + var RibbonWidget = Widget.extend({ + template: 'web.ribbon', + xmlDependencies: ['/web/static/src/xml/ribbon.xml'], + + /** + * @param {Object} options + * @param {string} options.attrs.title + * @param {string} options.attrs.text same as title + * @param {string} options.attrs.tooltip + * @param {string} options.attrs.bg_color + */ + init: function (parent, data, options) { + this._super.apply(this, arguments); + this.text = options.attrs.title || options.attrs.text; + this.tooltip = options.attrs.tooltip; + this.className = options.attrs.bg_color ? options.attrs.bg_color : 'bg-success'; + if (this.text.length > 15) { + this.className += ' o_small'; + } else if (this.text.length > 10) { + this.className += ' o_medium'; + } + }, + }); + + widgetRegistry.add('web_ribbon', RibbonWidget); + + return RibbonWidget; +}); diff --git a/addons/web/static/src/js/widgets/signature.js b/addons/web/static/src/js/widgets/signature.js new file mode 100644 index 00000000..25cb16fd --- /dev/null +++ b/addons/web/static/src/js/widgets/signature.js @@ -0,0 +1,97 @@ +odoo.define('web.signature_widget', function (require) { +"use strict"; + +const framework = require('web.framework'); +const SignatureDialog = require('web.signature_dialog'); +const widgetRegistry = require('web.widget_registry'); +const Widget = require('web.Widget'); + + +const WidgetSignature = Widget.extend({ + custom_events: Object.assign({}, Widget.prototype.custom_events, { + upload_signature: '_onUploadSignature', + }), + events: Object.assign({}, Widget.prototype.events, { + 'click .o_sign_label': '_onClickSignature', + }), + template: 'SignButton', + /** + * @constructor + * @param {Widget} parent + * @param {Object} record + * @param {Object} nodeInfo + */ + init: function (parent, record, nodeInfo) { + this._super.apply(this, arguments); + this.res_id = record.res_id; + this.res_model = record.model; + this.state = record; + this.node = nodeInfo; + // signature_field is the field on which the signature image will be + // saved (`signature` by default). + this.signature_field = this.node.attrs.signature_field || 'signature'; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Open a dialog to sign. + * + * @private + */ + _onClickSignature: function () { + const nameAndSignatureOptions = { + displaySignatureRatio: 3, + mode: 'draw', + noInputName: true, + signatureType: 'signature', + }; + + if (this.node.attrs.full_name) { + let signName; + const fieldFullName = this.state.data[this.node.attrs.full_name]; + if (fieldFullName && fieldFullName.type === 'record') { + signName = fieldFullName.data.display_name; + } else { + signName = fieldFullName; + } + nameAndSignatureOptions.defaultName = signName || undefined; + } + + nameAndSignatureOptions.defaultFont = this.node.attrs.default_font || ''; + this.signDialog = new SignatureDialog(this, { + nameAndSignatureOptions: nameAndSignatureOptions, + }); + this.signDialog.open(); + }, + /** + * Upload the signature image (write it on the corresponding field) and + * close the dialog. + * + * @returns {Promise} + * @private + */ + _onUploadSignature: function (ev) { + const file = ev.data.signatureImage[1]; + const always = () => { + this.trigger_up('reload'); + framework.unblockUI(); + }; + framework.blockUI(); + const rpcProm = this._rpc({ + model: this.res_model, + method: 'write', + args: [[this.res_id], { + [this.signature_field]: file, + }], + }); + rpcProm.then(always).guardedCatch(always); + return rpcProm; + }, +}); + +widgetRegistry.add('signature', WidgetSignature); + +}); diff --git a/addons/web/static/src/js/widgets/switch_company_menu.js b/addons/web/static/src/js/widgets/switch_company_menu.js new file mode 100644 index 00000000..06d33862 --- /dev/null +++ b/addons/web/static/src/js/widgets/switch_company_menu.js @@ -0,0 +1,127 @@ +odoo.define('web.SwitchCompanyMenu', function(require) { +"use strict"; + +/** + * When Odoo is configured in multi-company mode, users should obviously be able + * to switch their interface from one company to the other. This is the purpose + * of this widget, by displaying a dropdown menu in the systray. + */ + +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); + +var _t = core._t; + +var SwitchCompanyMenu = Widget.extend({ + template: 'SwitchCompanyMenu', + events: { + 'click .dropdown-item[data-menu] div.log_into': '_onSwitchCompanyClick', + 'keydown .dropdown-item[data-menu] div.log_into': '_onSwitchCompanyClick', + 'click .dropdown-item[data-menu] div.toggle_company': '_onToggleCompanyClick', + 'keydown .dropdown-item[data-menu] div.toggle_company': '_onToggleCompanyClick', + }, + // force this item to be the first one to the left of the UserMenu in the systray + sequence: 1, + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.isMobile = config.device.isMobile; + this._onSwitchCompanyClick = _.debounce(this._onSwitchCompanyClick, 1500, true); + }, + + /** + * @override + */ + willStart: function () { + var self = this; + this.allowed_company_ids = String(session.user_context.allowed_company_ids) + .split(',') + .map(function (id) {return parseInt(id);}); + this.user_companies = session.user_companies.allowed_companies; + this.current_company = this.allowed_company_ids[0]; + this.current_company_name = _.find(session.user_companies.allowed_companies, function (company) { + return company[0] === self.current_company; + })[1]; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent|KeyEvent} ev + */ + _onSwitchCompanyClick: function (ev) { + if (ev.type == 'keydown' && ev.which != $.ui.keyCode.ENTER && ev.which != $.ui.keyCode.SPACE) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + var dropdownItem = $(ev.currentTarget).parent(); + var dropdownMenu = dropdownItem.parent(); + var companyID = dropdownItem.data('company-id'); + var allowed_company_ids = this.allowed_company_ids; + if (dropdownItem.find('.fa-square-o').length) { + // 1 enabled company: Stay in single company mode + if (this.allowed_company_ids.length === 1) { + if (this.isMobile) { + dropdownMenu = dropdownMenu.parent(); + } + dropdownMenu.find('.fa-check-square').removeClass('fa-check-square').addClass('fa-square-o'); + dropdownItem.find('.fa-square-o').removeClass('fa-square-o').addClass('fa-check-square'); + allowed_company_ids = [companyID]; + } else { // Multi company mode + allowed_company_ids.push(companyID); + dropdownItem.find('.fa-square-o').removeClass('fa-square-o').addClass('fa-check-square'); + } + } + $(ev.currentTarget).attr('aria-pressed', 'true'); + session.setCompanies(companyID, allowed_company_ids); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent|KeyEvent} ev + */ + _onToggleCompanyClick: function (ev) { + if (ev.type == 'keydown' && ev.which != $.ui.keyCode.ENTER && ev.which != $.ui.keyCode.SPACE) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + var dropdownItem = $(ev.currentTarget).parent(); + var companyID = dropdownItem.data('company-id'); + var allowed_company_ids = this.allowed_company_ids; + var current_company_id = allowed_company_ids[0]; + if (dropdownItem.find('.fa-square-o').length) { + allowed_company_ids.push(companyID); + dropdownItem.find('.fa-square-o').removeClass('fa-square-o').addClass('fa-check-square'); + $(ev.currentTarget).attr('aria-checked', 'true'); + } else { + allowed_company_ids.splice(allowed_company_ids.indexOf(companyID), 1); + dropdownItem.find('.fa-check-square').addClass('fa-square-o').removeClass('fa-check-square'); + $(ev.currentTarget).attr('aria-checked', 'false'); + } + session.setCompanies(current_company_id, allowed_company_ids); + }, + +}); + +if (session.display_switch_company_menu) { + SystrayMenu.Items.push(SwitchCompanyMenu); +} + +return SwitchCompanyMenu; + +}); diff --git a/addons/web/static/src/js/widgets/translation_dialog.js b/addons/web/static/src/js/widgets/translation_dialog.js new file mode 100644 index 00000000..efdba7b3 --- /dev/null +++ b/addons/web/static/src/js/widgets/translation_dialog.js @@ -0,0 +1,183 @@ +odoo.define('web.TranslationDialog', function (require) { + 'use strict'; + + var core = require('web.core'); + var Dialog = require('web.Dialog'); + var session = require('web.session'); + + var _t = core._t; + + var TranslationDialog = Dialog.extend({ + xmlDependencies: (Dialog.prototype.xmlDependencies || []) + .concat(['/web/static/src/xml/translation_dialog.xml']), + template: 'TranslationDialog', + + /** + * @constructor + * @param {Widget} parent + * @param {Object} [options] + * @param {string} [options.domain] the domain needed to get the translation terms + * @param {string} [options.fieldName] the name of the field currently translated (from the model of the form view) + * @param {string} [options.searchName] the name of the actual field that is the reference for translation (in the form of model,field) + * @param {string} [options.userLanguageValue] the value of the translation in the language of the user, as seen in the from view (might be empty) + * @param {string} [options.dataPointID] the data point id of the record for which we do the translations + * @param {boolean} [options.isComingFromTranslationAlert] the initiator of the dialog, might be a link on a field or the translation alert on top of the form + * @param {boolean} [options.isText] is the field a text field (multiline) or char (single line) + * @param {boolean} [options.showSrc] is the source of the translation should be rendered (for partial translations, i.e. XML content) + * + */ + init: function (parent, options) { + options = options || {}; + + this.fieldName = options.fieldName; + this.domain = options.domain; + this.searchName = options.searchName; + this.userLanguageValue = options.userLanguageValue; + this.domain.push(['name', "=", `${this.searchName}`]); + this.dataPointID = options.dataPointID; + this.isComingFromTranslationAlert = options.isComingFromTranslationAlert; + this.currentInterfaceLanguage = session.user_context.lang; + this.isText = options.isText; + this.showSrc = options.showSrc; + + this._super(parent, _.extend({ + size: 'large', + title: _t('Translate: ') + `${this.fieldName}`, + buttons: [ + { text: _t('Save'), classes: 'btn-primary', close: true, click: this._onSave.bind(this) }, + { text: _t('Discard'), close: true }, + ], + }, options)); + }, + /** + * @override + */ + willStart: function () { + return Promise.all([ + this._super(), + this._loadLanguages().then((l) => { + this.languages = l; + return this._loadTranslations().then((t) => { + this.translations = t; + }); + }), + ]).then(() => { + this.data = this.translations.map((term) => { + let relatedLanguage = this.languages.find((language) => language[0] === term.lang); + if (!term.value && !this.showSrc) { + term.value = term.src; + } + return { + id: term.id, + lang: term.lang, + langName: relatedLanguage[1], + source: term.src, + // we set the translation value coming from the database, except for the language + // the user is currently utilizing. Then we set the translation value coming + // from the value of the field in the form + value: (term.lang === this.currentInterfaceLanguage && + !this.showSrc && + !this.isComingFromTranslationAlert) ? + this.userLanguageValue : term.value || '' + }; + }); + this.data.sort((left, right) => + (left.langName < right.langName || (left.langName === right.langName && left.source < right.source)) ? -1 : 1); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * Load the translation terms for the installed language, for the current model and res_id + * @private + */ + _loadTranslations: function () { + const domain = [...this.domain, ['lang', 'in', this.languages.map(l => l[0])]]; + return this._rpc({ + model: 'ir.translation', + method: 'search_read', + fields: ['lang', 'src', 'value'], + domain: domain, + }); + }, + /** + * Load the installed languages long names and code + * + * The result of the call is put in cache on the prototype of this dialog. + * If any new language is installed, a full page refresh will happen, + * so there is no need invalidate it. + * @private + */ + _loadLanguages: function () { + if (TranslationDialog.prototype.installedLanguagesCache) + return Promise.resolve(TranslationDialog.prototype.installedLanguagesCache); + + return this._rpc({ + model: 'res.lang', + method: 'get_installed', + fields: ['code', 'name', 'iso_code'], + }).then((installedLanguages) => { + TranslationDialog.prototype.installedLanguagesCache = installedLanguages; + return TranslationDialog.prototype.installedLanguagesCache + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * Save all the terms that have been updated + * @private + * @returns a promise that is resolved when all the save have occured + */ + _onSave: function () { + var updatedTerm = {}; + var updateFormViewField; + + this.el.querySelectorAll('input[type=text],textarea').forEach((t) => { + var initialValue = this.data.find((d) => d.id == t.dataset.id); + if (initialValue.value !== t.value) { + updatedTerm[t.dataset.id] = t.value; + + if (initialValue.lang === this.currentInterfaceLanguage && !this.showSrc) { + // when the user has changed the term for the language he is + // using in the interface, this change should be reflected + // in the form view + // partial translations being handled server side are + // also ignored + var changes = {}; + changes[this.fieldName] = updatedTerm[initialValue.id]; + updateFormViewField = { + dataPointID: this.dataPointID, + changes: changes, + doNotSetDirty: false, + }; + } + } + }); + + // updatedTerm only contains the id and values of the terms that + // have been updated by the user + var saveUpdatedTermsProms = Object.keys(updatedTerm).map((id) => { + var writeTranslation = { + model: 'ir.translation', + method: 'write', + context: this.context, + args: [[parseInt(id, 10)], { value: updatedTerm[id] }] + }; + return this._rpc(writeTranslation); + }); + return Promise.all(saveUpdatedTermsProms).then(() => { + // we might have to update the value of the field on the form + // view that opened the translation dialog + if (updateFormViewField) { + this.trigger_up('field_changed', updateFormViewField); + } + }); + } + }); + + return TranslationDialog; +}); |
