summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2017-03-05 10:05:56 +0100
committerAaron Bohy <aab@odoo.com>2017-04-11 19:44:38 +0200
commit905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch)
tree265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js
parent54d4c73af4ad05c7640d28c1892247c9538b8017 (diff)
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started basically from scratch. The goal was to unify all the various views under a common framework, to make them testable, to make then usable in different conditions (in studio, or in the frontend), and to make our lives easier. Some important points are: - we introduced new coding guidelines (camelCase, 80 chars width, ...) - we have a brand new testing framework (still QUnit based) - kanban view moved to the web addon - calendar view (formerly web_calender) moved to web as well - the tree view was removed - all new code should be documented We hope that this code is the start of a new era for the Odoo web client, we want to have a high quality codebase, well documented, well tested, well designed. Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js')
-rw-r--r--addons/web/static/src/js/_deprecated/data.js (renamed from addons/web/static/src/js/framework/data.js)372
-rw-r--r--addons/web/static/src/js/_deprecated/data_model.js (renamed from addons/web/static/src/js/framework/data_model.js)0
-rw-r--r--addons/web/static/src/js/_deprecated/formats.js (renamed from addons/web/static/src/js/framework/formats.js)0
-rw-r--r--addons/web/static/src/js/_deprecated/model.js (renamed from addons/web/static/src/js/framework/model.js)0
-rw-r--r--addons/web/static/src/js/apps.js5
-rw-r--r--addons/web/static/src/js/boot.js16
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js (renamed from addons/web/static/src/js/abstract_web_client.js)77
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js (renamed from addons/web/static/src/js/action_manager.js)65
-rw-r--r--addons/web/static/src/js/chrome/control_panel.js (renamed from addons/web/static/src/js/control_panel.js)0
-rw-r--r--addons/web/static/src/js/chrome/loading.js (renamed from addons/web/static/src/js/widgets/loading.js)11
-rw-r--r--addons/web/static/src/js/chrome/menu.js (renamed from addons/web/static/src/js/menu.js)0
-rw-r--r--addons/web/static/src/js/chrome/pager.js (renamed from addons/web/static/src/js/widgets/pager.js)163
-rw-r--r--addons/web/static/src/js/chrome/search_filters.js (renamed from addons/web/static/src/js/views/search_filters.js)26
-rw-r--r--addons/web/static/src/js/chrome/search_inputs.js (renamed from addons/web/static/src/js/views/search_inputs.js)0
-rw-r--r--addons/web/static/src/js/chrome/search_menus.js (renamed from addons/web/static/src/js/views/search_menus.js)116
-rw-r--r--addons/web/static/src/js/chrome/search_view.js (renamed from addons/web/static/src/js/views/search_view.js)34
-rw-r--r--addons/web/static/src/js/chrome/sidebar.js (renamed from addons/web/static/src/js/widgets/sidebar.js)6
-rw-r--r--addons/web/static/src/js/chrome/systray_menu.js (renamed from addons/web/static/src/js/widgets/systray_menu.js)28
-rw-r--r--addons/web/static/src/js/chrome/user_menu.js112
-rw-r--r--addons/web/static/src/js/chrome/view_manager.js610
-rw-r--r--addons/web/static/src/js/chrome/web_client.js (renamed from addons/web/static/src/js/web_client.js)1
-rw-r--r--addons/web/static/src/js/compatibility.js288
-rw-r--r--addons/web/static/src/js/core/abstract_service.js20
-rw-r--r--addons/web/static/src/js/core/ajax.js (renamed from addons/web/static/src/js/framework/ajax.js)0
-rw-r--r--addons/web/static/src/js/core/bus.js (renamed from addons/web/static/src/js/framework/bus.js)0
-rw-r--r--addons/web/static/src/js/core/class.js (renamed from addons/web/static/src/js/framework/class.js)0
-rw-r--r--addons/web/static/src/js/core/collections.js44
-rw-r--r--addons/web/static/src/js/core/concurrency.js237
-rw-r--r--addons/web/static/src/js/core/dialog.js (renamed from addons/web/static/src/js/framework/dialog.js)10
-rw-r--r--addons/web/static/src/js/core/dom.js164
-rw-r--r--addons/web/static/src/js/core/domain.js225
-rw-r--r--addons/web/static/src/js/core/local_storage.js (renamed from addons/web/static/src/js/framework/local_storage.js)0
-rw-r--r--addons/web/static/src/js/core/misc.js182
-rw-r--r--addons/web/static/src/js/core/mixins.js (renamed from addons/web/static/src/js/framework/mixins.js)109
-rw-r--r--addons/web/static/src/js/core/pyeval.js (renamed from addons/web/static/src/js/framework/pyeval.js)43
-rw-r--r--addons/web/static/src/js/core/qweb.js (renamed from addons/web/static/src/js/framework/qweb.js)0
-rw-r--r--addons/web/static/src/js/core/registry.js97
-rw-r--r--addons/web/static/src/js/core/session.js (renamed from addons/web/static/src/js/framework/session.js)3
-rw-r--r--addons/web/static/src/js/core/time.js (renamed from addons/web/static/src/js/framework/time.js)0
-rw-r--r--addons/web/static/src/js/core/translation.js (renamed from addons/web/static/src/js/framework/translation.js)0
-rw-r--r--addons/web/static/src/js/core/utils.js436
-rw-r--r--addons/web/static/src/js/core/widget.js (renamed from addons/web/static/src/js/framework/widget.js)79
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js328
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js1300
-rw-r--r--addons/web/static/src/js/fields/field_registry.js66
-rw-r--r--addons/web/static/src/js/fields/field_utils.js240
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js1493
-rw-r--r--addons/web/static/src/js/fields/upgrade_fields.js177
-rw-r--r--addons/web/static/src/js/framework/crash_manager.js211
-rw-r--r--addons/web/static/src/js/framework/data_manager.js280
-rw-r--r--addons/web/static/src/js/framework/dom_utils.js68
-rw-r--r--addons/web/static/src/js/framework/domain_utils.js19
-rw-r--r--addons/web/static/src/js/framework/misc.js438
-rw-r--r--addons/web/static/src/js/framework/registry.js54
-rw-r--r--addons/web/static/src/js/framework/utils.js566
-rw-r--r--addons/web/static/src/js/framework/view.js211
-rw-r--r--addons/web/static/src/js/libs.js138
-rw-r--r--addons/web/static/src/js/services/ajax_service.js16
-rw-r--r--addons/web/static/src/js/services/config.js (renamed from addons/web/static/src/js/config.js)0
-rw-r--r--addons/web/static/src/js/services/core.js1
-rw-r--r--addons/web/static/src/js/services/crash_manager.js212
-rw-r--r--addons/web/static/src/js/services/data_manager.js221
-rw-r--r--addons/web/static/src/js/services/local_storage_service.js25
-rw-r--r--addons/web/static/src/js/view_manager.js479
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js498
-rw-r--r--addons/web/static/src/js/views/abstract_model.js69
-rw-r--r--addons/web/static/src/js/views/abstract_renderer.js119
-rw-r--r--addons/web/static/src/js/views/abstract_view.js181
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js178
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js1996
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js115
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js171
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js267
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js620
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_quick_create.js140
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js450
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js129
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js164
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js642
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js603
-rw-r--r--addons/web/static/src/js/views/form/form_view.js44
-rw-r--r--addons/web/static/src/js/views/form_common.js1115
-rw-r--r--addons/web/static/src/js/views/form_relational_widgets.js1726
-rw-r--r--addons/web/static/src/js/views/form_upgrade_widgets.js101
-rw-r--r--addons/web/static/src/js/views/form_view.js1653
-rw-r--r--addons/web/static/src/js/views/form_widgets.js1803
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js128
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js188
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js334
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js69
-rw-r--r--addons/web/static/src/js/views/graph_view.js144
-rw-r--r--addons/web/static/src/js/views/graph_widget.js340
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js268
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js358
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js126
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_quick_create.js140
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js403
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js255
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js83
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js353
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js649
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js583
-rw-r--r--addons/web/static/src/js/views/list/list_view.js58
-rw-r--r--addons/web/static/src/js/views/list_common.js405
-rw-r--r--addons/web/static/src/js/views/list_view.js2075
-rw-r--r--addons/web/static/src/js/views/list_view_editable.js866
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js368
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js704
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js142
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js103
-rw-r--r--addons/web/static/src/js/views/pivot_view.js892
-rw-r--r--addons/web/static/src/js/views/tree_view.js249
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js376
-rw-r--r--addons/web/static/src/js/views/view_registry.js44
-rw-r--r--addons/web/static/src/js/widgets/change_password.js59
-rw-r--r--addons/web/static/src/js/widgets/data_export.js4
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js8
-rw-r--r--addons/web/static/src/js/widgets/debug_manager.js15
-rw-r--r--addons/web/static/src/js/widgets/domain_selector.js24
-rw-r--r--addons/web/static/src/js/widgets/iframe_widget.js64
-rw-r--r--addons/web/static/src/js/widgets/priority.js84
-rw-r--r--addons/web/static/src/js/widgets/progress_bar.js141
-rw-r--r--addons/web/static/src/js/widgets/user_menu.js81
123 files changed, 19086 insertions, 15003 deletions
diff --git a/addons/web/static/src/js/framework/data.js b/addons/web/static/src/js/_deprecated/data.js
index 1d4258019ae..090325b0057 100644
--- a/addons/web/static/src/js/framework/data.js
+++ b/addons/web/static/src/js/_deprecated/data.js
@@ -2,12 +2,12 @@ odoo.define('web.data', function (require) {
"use strict";
var Class = require('web.Class');
+var concurrency = require('web.concurrency');
var mixins = require('web.mixins');
var Model = require('web.Model');
var session = require('web.session');
var translation = require('web.translation');
var pyeval = require('web.pyeval');
-var utils = require('web.utils');
var _t = translation._t;
@@ -320,7 +320,7 @@ var DataSet = Class.extend(mixins.PropertiesMixin, {
this.index = null;
this._sort = [];
this._model = new Model(model, context);
- this.orderer = new utils.DropMisordered();
+ this.orderer = new concurrency.DropMisordered();
},
previous: function () {
this.index -= 1;
@@ -729,271 +729,6 @@ var DataSetSearch = DataSet.extend({
}
});
-var BufferedDataSet = DataSetStatic.extend({
- virtual_id_prefix: "one2many_v_id_",
- debug_mode: true,
- init: function() {
- this._super.apply(this, arguments);
- this.reset_ids([]);
- this.last_default_get = {};
- this.running_reads = [];
- this.mutex = new utils.Mutex();
- },
- default_get: function(fields, options) {
- var self = this;
- return this._super(fields, options).done(function(res) {
- self.last_default_get = _.clone(res);
- });
- },
- get_cache: function (id) {
- if (!this.cache[id]) {
- this.cache[id] = {
- 'id': id,
- 'values': {},
- 'from_read': {},
- 'changes': {},
- 'readonly_fields': {},
- 'to_create': false,
- 'to_delete': false};
- }
- return this.cache[id];
- },
- _update_cache: function (id, options) {
- // One should call this method after modifying this.from_read,
- // this.to_create or this.change. It updates this.cache and
- // this.readonly_fields.
- var cached = this.get_cache(id);
- if (options) {
- _.extend(cached.from_read, options.from_read);
- _.extend(cached.changes, options.changes);
- _.extend(cached.readonly_fields, options.readonly_fields);
- if (options.to_create !== undefined) cached.to_create = options.to_create;
- if (options.to_delete !== undefined) cached.to_delete = options.to_delete;
- }
- cached.values = _.extend({'id': id}, cached.from_read, cached.changes, cached.readonly_fields);
- return cached;
- },
- create: function(data, options) {
- var changes = _.extend({}, this.last_default_get, data);
- var cached = this._update_cache(_.uniqueId(this.virtual_id_prefix), _.extend({'changes': changes, 'to_create': true}, options));
- this.trigger("dataset_changed", data, options);
- return $.Deferred().resolve(cached.id);
- },
- write: function (id, data, options) {
- var self = this;
- var cached = this.get_cache(id);
-
- // if update after a remove, it's like an add before updating
- cached.to_delete = false;
-
- // apply change
- var def = $.Deferred();
- this.mutex.exec(function () {
- var dirty = false;
- // _.each is broken if a field "length" is present
- for (var k in data) {
- if (!_.isEqual(data[k], cached.values[k])) {
- dirty = true;
- if (_.isEqual(data[k], cached.from_read[k])) { // clean changes
- delete cached.changes[k];
- } else {
- cached.changes[k] = data[k];
- }
- } else {
- delete data[k];
- }
- }
- self._update_cache(id, options);
-
- if (dirty) {
- self.trigger("dataset_changed", id, data, options);
- }
-
- return def.resolve(data).promise();
- });
-
- return def;
- },
- unlink: function(ids, callback, error_callback) {
- var self = this;
- _.each(ids, function (id) {
- self.get_cache(id).to_delete = true;
- });
- this.set_ids(_.difference(this.ids, _.pluck(_.filter(this.cache, function (c) {return c.to_delete;}), 'id')));
- this.trigger("dataset_changed", ids, callback, error_callback);
- return utils.async_when({result: true}).done(callback);
- },
- reset_ids: function(ids, options) {
- var self = this;
- this.set_ids(ids);
- if (!options || !options.keep_read_data) {
- this.cache = {};
- } else {
- _.each(this.cache, function (cache) {
- self._update_cache(cache.id, {'changes': {}, 'to_delete': false});
- });
- }
- this.delete_all = false;
- this.cancel_read();
- },
- cancel_read: function () {
- _.invoke(_.clone(this.running_reads), 'reject');
- },
- read_ids: function (ids, fields, options) {
- // read what is necessary from the server to have ids and the given
- // fields in this.from_read
- var self = this;
- var to_get = _.filter(ids, function(id) {
- var cache = self.get_cache(id);
- return !cache.to_create && _.any(fields, function(x) {return cache.from_read[x] === undefined;});
- });
- options = options || {};
-
- var return_records = function() {
- var records = _.map(ids, function(id) {
- return _.clone(self.get_cache(id).values);
- });
- if (self.debug_mode) {
- if (_.include(records, undefined)) {
- throw "Record not correctly loaded";
- }
- }
- var sort_fields = self._sort,
- compare = function (v1, v2) {
- return (v1 < v2) ? -1
- : (v1 > v2) ? 1
- : 0;
- };
- // Array.sort is not necessarily stable. We must be careful with this because
- // sorting an array where all items are considered equal is a worst-case that
- // will randomize the array with an unstable sort! Therefore we must avoid
- // sorting if there are no sort_fields (i.e. all items are considered equal)
- // See also: http://ecma262-5.com/ELS5_Section_15.htm#Section_15.4.4.11
- // http://code.google.com/p/v8/issues/detail?id=90
- if (sort_fields.length) {
- records.sort(function (a, b) {
- return _.reduce(sort_fields, function (acc, field) {
- if (acc) { return acc; }
- var sign = 1;
- if (field[0] === '-') {
- sign = -1;
- field = field.slice(1);
- }
- if(!a[field] && a[field] !== 0){ return sign; }
- if(!b[field] && b[field] !== 0){ return (sign == -1) ? 1 : -1; }
- //m2o should be searched based on value[1] not based whole value(i.e. [id, value])
- if(_.isArray(a[field]) && a[field].length == 2 && _.isString(a[field][1])){
- return sign * compare(a[field][1], b[field][1]);
- }
- return sign * compare(a[field], b[field]);
- }, 0);
- });
- }
- return $.when(records);
- };
- if(to_get.length > 0) {
- var def = $.Deferred();
- self.running_reads.push(def);
- def.always(function() {
- self.running_reads = _.without(self.running_reads, def);
- });
- var _super = this._super;
- this.mutex.exec(function () {
- if (def.state() !== "pending") return;
- _super.call(self, to_get, fields, options).then(_.bind(def.resolve, def), _.bind(def.reject, def));
- return def;
- });
- return def.then(function(records) {
- _.each(records, function(record, index) {
- // add information into from_read
- var id = to_get[index];
- self._update_cache(id, _.extend(options, {'from_read': record}));
- });
- return return_records();
- });
- } else {
- return return_records();
- }
- },
- /**
- * Invalidates caching of a record in the dataset to ensure the next read
- * of that record will hit the server.
- *
- * Of use when an action is going to remote-alter a record which will then
- * need to be reloaded, e.g. action button.
- *
- * @param {Object} id record to remove from the BDS's cache
- */
- evict_record: function (id) {
- // Don't evict records which haven't yet been saved: there is no more
- // recent data on the server (and there potentially isn't any data),
- // and this breaks the assumptions of other methods (that the data
- // for new and altered records is both in the cache and in the change
- // or to_create collection)
- this.get_cache(id).from_read = {};
- this._update_cache(id);
- },
- call_button: function (method, args) {
- this.evict_record(args[0][0]);
- return this._super(method, args);
- },
- alter_ids: function(n_ids, options) {
- var dirty = !_.isEqual(this.ids, n_ids);
- this._super(n_ids, options);
- if (dirty) {
- this.trigger("dataset_changed", n_ids, options);
- }
- },
-});
-
-BufferedDataSet.virtual_id_regex = /^one2many_v_id_.*$/;
-
-var ProxyDataSet = DataSetSearch.extend({
- init: function() {
- this._super.apply(this, arguments);
- this.create_function = null;
- this.write_function = null;
- this.read_function = null;
- this.default_get_function = null;
- this.unlink_function = null;
- },
- read_ids: function (ids, fields, options) {
- if (this.read_function) {
- return this.read_function(ids, fields, options, this._super);
- } else {
- return this._super.apply(this, arguments);
- }
- },
- default_get: function(fields, options) {
- if (this.default_get_function) {
- return this.default_get_function(fields, options, this._super);
- } else {
- return this._super.apply(this, arguments);
- }
- },
- create: function(data, options) {
- if (this.create_function) {
- return this.create_function(data, options, this._super);
- } else {
- return this._super.apply(this, arguments);
- }
- },
- write: function (id, data, options) {
- if (this.write_function) {
- return this.write_function(id, data, options, this._super);
- } else {
- return this._super.apply(this, arguments);
- }
- },
- unlink: function(ids) {
- if (this.unlink_function) {
- return this.unlink_function(ids, this._super);
- } else {
- return this._super.apply(this, arguments);
- }
- },
-});
-
var CompoundContext = Class.extend({
init: function () {
this.__ref = "compound_context";
@@ -1016,7 +751,7 @@ var CompoundContext = Class.extend({
return this.__eval_context;
},
eval: function() {
- return pyeval.eval('context', this, undefined, {no_user_context: true});
+ return pyeval.eval('context', this);
},
});
@@ -1046,77 +781,37 @@ var CompoundDomain = Class.extend({
},
});
-function compute_domain (expr, fields) {
- if (! (expr instanceof Array))
- return !! expr;
- var stack = [];
- for (var i = expr.length - 1; i >= 0; i--) {
- var ex = expr[i];
- if (ex.length == 1) {
- var top = stack.pop();
- switch (ex) {
- case '|':
- stack.push(stack.pop() || top);
- continue;
- case '&':
- stack.push(stack.pop() && top);
- continue;
- case '!':
- stack.push(!top);
- continue;
- default:
- throw new Error(_.str.sprintf(
- _t("Unknown operator %s in domain %s"),
- ex, JSON.stringify(expr)));
- }
- }
-
- var field = fields[ex[0]];
- if (!field) {
- throw new Error(_.str.sprintf(
- _t("Unknown field %s in domain %s"),
- ex[0], JSON.stringify(expr)));
+function rawify (data) {
+ // replaces the value of relational fields by their raw value (the id, if
+ // any, for many2ones and an array of ids for x2manys), in turn
+ return _.mapObject(data, function (value) {
+ if (_.isObject(value)) {
+ return value.type === 'record' ? value.res_id : value.res_ids;
}
- var field_value = field.get_value ? field.get_value() : field.value;
- var op = ex[1];
- var val = ex[2];
+ return value;
+ });
+}
- switch (op.toLowerCase()) {
- case '=':
- case '==':
- stack.push(_.isEqual(field_value, val));
- break;
- case '!=':
- case '<>':
- stack.push(!_.isEqual(field_value, val));
- break;
- case '<':
- stack.push(field_value < val);
- break;
- case '>':
- stack.push(field_value > val);
- break;
- case '<=':
- stack.push(field_value <= val);
- break;
- case '>=':
- stack.push(field_value >= val);
- break;
- case 'in':
- if (!_.isArray(val)) val = [val];
- stack.push(_(val).contains(field_value));
- break;
- case 'not in':
- if (!_.isArray(val)) val = [val];
- stack.push(!_(val).contains(field_value));
- break;
- default:
- console.warn(
- _t("Unsupported operator %s in domain %s"),
- op, JSON.stringify(expr));
- }
+function build_eval_context (record) {
+ var field_values = rawify(_.extend({}, record.data, { active_model: record.model }));
+ if ('id' in record.data) {
+ field_values.active_id = record.data.id;
+ field_values.active_ids = [record.data.id];
}
- return _.all(stack, _.identity);
+ // FIXME: parent?
+ return new CompoundContext(field_values);
+}
+
+function build_context(record, context) {
+ context = context || {};
+ var eval_context = build_eval_context(record);
+ return new CompoundContext(context).set_eval_context(eval_context);
+}
+
+function build_domain(record, domain) {
+ domain = domain || [];
+ var eval_context = build_eval_context(record);
+ return new CompoundDomain(domain).set_eval_context(eval_context);
}
return {
@@ -1124,12 +819,11 @@ return {
DataSet: DataSet,
DataSetStatic: DataSetStatic,
DataSetSearch: DataSetSearch,
- BufferedDataSet: BufferedDataSet,
- ProxyDataSet: ProxyDataSet,
CompoundContext: CompoundContext,
CompoundDomain: CompoundDomain,
- compute_domain: compute_domain,
noDisplayContent: "<em class=\"text-warning\">" + _t("Unnamed") + "</em>",
+ build_context: build_context,
+ build_domain: build_domain,
};
});
diff --git a/addons/web/static/src/js/framework/data_model.js b/addons/web/static/src/js/_deprecated/data_model.js
index f722339167f..f722339167f 100644
--- a/addons/web/static/src/js/framework/data_model.js
+++ b/addons/web/static/src/js/_deprecated/data_model.js
diff --git a/addons/web/static/src/js/framework/formats.js b/addons/web/static/src/js/_deprecated/formats.js
index 479fa1082cd..479fa1082cd 100644
--- a/addons/web/static/src/js/framework/formats.js
+++ b/addons/web/static/src/js/_deprecated/formats.js
diff --git a/addons/web/static/src/js/framework/model.js b/addons/web/static/src/js/_deprecated/model.js
index 377ff1a1757..377ff1a1757 100644
--- a/addons/web/static/src/js/framework/model.js
+++ b/addons/web/static/src/js/_deprecated/model.js
diff --git a/addons/web/static/src/js/apps.js b/addons/web/static/src/js/apps.js
index 7250674272c..8c5135f3b7b 100644
--- a/addons/web/static/src/js/apps.js
+++ b/addons/web/static/src/js/apps.js
@@ -5,13 +5,11 @@ var core = require('web.core');
var framework = require('web.framework');
var Model = require('web.DataModel');
var session = require('web.session');
-var web_client = require('web.web_client');
var Widget = require('web.Widget');
var _t = core._t;
var apps_client = null;
-var qweb = core.qweb;
var Apps = Widget.extend({
template: 'EmptyComponent',
@@ -25,7 +23,8 @@ var Apps = Widget.extend({
},
get_client: function() {
- // return the client via a deferred, resolved or rejected depending if the remote host is available or not.
+ // return the client via a deferred, resolved or rejected depending if
+ // the remote host is available or not.
var check_client_available = function(client) {
var d = $.Deferred();
var i = new Image();
diff --git a/addons/web/static/src/js/boot.js b/addons/web/static/src/js/boot.js
index b84cd0f58f8..9d299b1da84 100644
--- a/addons/web/static/src/js/boot.js
+++ b/addons/web/static/src/js/boot.js
@@ -36,6 +36,7 @@
var job_deps = [];
var job_deferred = [];
+
var services = Object.create({});
var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/mg;
@@ -50,6 +51,7 @@
remaining_jobs: jobs,
__DEBUG__: {
+ didLogInfo: $.Deferred(),
get_dependencies: function (name, transitive) {
var deps = name instanceof Array ? name: [name],
changed;
@@ -137,6 +139,9 @@
this.process_jobs(jobs, services);
},
log: function () {
+ var missing = [];
+ var failed = [];
+
if (jobs.length) {
var debug_jobs = {};
var rejected = [];
@@ -176,8 +181,8 @@
}
}
}
- var missing = odoo.__DEBUG__.get_missing_jobs();
- var failed = odoo.__DEBUG__.get_failed_jobs();
+ missing = odoo.__DEBUG__.get_missing_jobs();
+ failed = odoo.__DEBUG__.get_failed_jobs();
var unloaded = _.filter(debug_jobs, function (job) { return job.missing; });
var log = [(_.isEmpty(failed) ? (_.isEmpty(unloaded) ? 'info' : 'warning' ) : 'error') + ':', 'Some modules could not be started'];
@@ -192,10 +197,14 @@
console[_.isEmpty(failed) || _.isEmpty(unloaded) ? 'info' : 'error'].apply(console, log);
}
}
+ odoo.__DEBUG__.js_modules = {
+ missing: missing,
+ failed: _.pluck(failed, 'name'),
+ };
+ odoo.__DEBUG__.didLogInfo.resolve();
},
process_jobs: function (jobs, services) {
var job;
- var require;
function process_job (job) {
var require = make_require(job);
@@ -252,6 +261,7 @@
}
});
+
// automatically log errors detected when loading modules
var log_when_loaded = function () {
_.delay(function () {
diff --git a/addons/web/static/src/js/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 1d9c270b6c3..498d1b41ea4 100644
--- a/addons/web/static/src/js/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -1,19 +1,46 @@
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 crash_manager = require('web.crash_manager');
+var data_manager = require('web.data_manager');
var Loading = require('web.Loading');
+var mixins = require('web.mixins');
var NotificationManager = require('web.notification').NotificationManager;
var session = require('web.session');
-var utils = require('web.utils');
var Widget = require('web.Widget');
var _t = core._t;
-var WebClient = Widget.extend({
+var AbstractWebClient = Widget.extend(mixins.ServiceProvider, {
custom_events: {
+ 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 (e) {
+ this.current_action_updated(e.data.action);
+ },
+ // GENERIC SERVICES
+ // the next events are dedicated to generic services required by
+ // downstream widgets. Mainly side effects, such as rpcs, notifications
+ // or cache.
+
+ // notifications and warnings
notification: function (e) {
if(this.notification_manager) {
this.notification_manager.notify(e.data.title, e.data.message, e.data.sticky);
@@ -24,33 +51,43 @@ var WebClient = Widget.extend({
this.notification_manager.warn(e.data.title, e.data.message, e.data.sticky);
}
},
- clear_uncommitted_changes: function (e) {
- this.clear_uncommitted_changes().then(e.data.callback);
- },
- toggle_fullscreen: function (event) {
- this.toggle_fullscreen(event.data.fullscreen);
+ 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);
},
- current_action_updated: function (e) {
- this.current_action_updated(e.data.action);
+ load_filters: function(event) {
+ return data_manager
+ .load_filters(event.data.dataset, event.data.action_id)
+ .then(event.data.on_success);
},
- perform_rpc: function(event) {
- if (event.data.on_fail) {
- event.data.on_fail();
+ // session
+ get_session: function(event) {
+ if (event.data.callback) {
+ event.data.callback(session);
}
},
- perform_model_rpc: function(event) {
- if (event.data.on_fail) {
- event.data.on_fail();
- }
+ do_action: function (event) {
+ this.do_action(event.data.action, event.data.options || {}).then(function (result) {
+ if (event.data.on_success) {
+ event.data.on_success(result);
+ }
+ });
},
},
init: function(parent) {
this.client_options = {};
+ mixins.ServiceProvider.init.call(this);
this._super(parent);
this.origin = undefined;
this._current_state = null;
- this.menu_dm = new utils.DropMisordered();
- this.action_mutex = new utils.Mutex();
+ this.menu_dm = new concurrency.DropMisordered();
+ this.action_mutex = new concurrency.Mutex();
this.set('title_part', {"zopenerp": "Odoo"});
},
start: function() {
@@ -239,7 +276,7 @@ var WebClient = Widget.extend({
// --------------------------------------------------------------
// Scrolltop handling
// --------------------------------------------------------------
- get_scrollTop: function () {
+ getScrollTop: function () {
},
//--------------------------------------------------------------
// Misc.
@@ -249,6 +286,6 @@ var WebClient = Widget.extend({
},
});
-return WebClient;
+return AbstractWebClient;
});
diff --git a/addons/web/static/src/js/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index f7b81554bb4..9dfe28e01dd 100644
--- a/addons/web/static/src/js/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -1,6 +1,14 @@
odoo.define('web.ActionManager', function (require) {
"use strict";
+/**
+ * ActionManager
+ *
+ * The action manager is quite important: it makes sure that actions (the Odoo
+ * objects, such as a client action, or a act_window) are properly started,
+ * and coordinated.
+ */
+
var Bus = require('web.Bus');
var ControlPanel = require('web.ControlPanel');
var core = require('web.core');
@@ -8,6 +16,7 @@ var crash_manager = require('web.crash_manager');
var data = require('web.data');
var data_manager = require('web.data_manager');
var Dialog = require('web.Dialog');
+var dom = require('web.dom');
var framework = require('web.framework');
var pyeval = require('web.pyeval');
var session = require('web.session');
@@ -53,7 +62,7 @@ var Action = core.Class.extend({
/**
* Not implemented for client actions
*/
- set_scrollTop: function() {
+ setScrollTop: function() {
},
/**
* Stores the DOM fragment of the action
@@ -66,7 +75,7 @@ var Action = core.Class.extend({
* Not implemented for client actions
* @return {int} the number of pixels the webclient is scrolled when leaving the action
*/
- get_scrollTop: function() {
+ getScrollTop: function() {
return 0;
},
/**
@@ -136,7 +145,7 @@ var WidgetAction = Action.extend({
// Hack to remove badly inserted nvd3 tooltips ; should be removed when upgrading nvd3 lib
$('body > .nvtooltip').remove();
- return framework.detach([{widget: this.widget}]);
+ return dom.detach([{widget: this.widget}]);
},
/**
* Destroys the widget
@@ -168,20 +177,20 @@ var ViewManagerAction = WidgetAction.extend({
*/
set_on_reverse_breadcrumb: function(callback, scrollTop) {
this._super(callback);
- this.set_scrollTop(scrollTop);
+ this.setScrollTop(scrollTop);
},
/**
* Sets the scroll position of the widgets's active_view
* @param {int} [scrollTop] the number of pixels to scroll
*/
- set_scrollTop: function(scrollTop) {
- this.widget.active_view.controller.set_scrollTop(scrollTop);
+ setScrollTop: function(scrollTop) {
+ this.widget.active_view.controller.setScrollTop(scrollTop);
},
/**
* @return {int} the number of pixels the webclient is scrolled when leaving the action
*/
- get_scrollTop: function() {
- return this.widget.active_view.controller.get_scrollTop();
+ getScrollTop: function() {
+ return this.widget.active_view.controller.getScrollTop();
},
/**
* @return {Array} array of Objects that will be interpreted to display the breadcrumbs
@@ -190,7 +199,7 @@ var ViewManagerAction = WidgetAction.extend({
var self = this;
return this.widget.view_stack.map(function (view, index) {
return {
- title: view.controller.get('title') || self.title,
+ title: view.controller && view.controller.get('title') || self.title,
index: index,
action: self,
};
@@ -254,7 +263,7 @@ var ActionManager = Widget.extend({
// Listen to event "DOM_updated" to restore the scroll position
core.bus.on('DOM_updated', this, function() {
if (this.inner_action) {
- this.trigger_up('scrollTo', {offset: this.inner_action.get_scrollTop() || 0});
+ this.trigger_up('scrollTo', {offset: this.inner_action.getScrollTop() || 0});
}
});
@@ -304,7 +313,7 @@ var ActionManager = Widget.extend({
// Set on_reverse_breadcrumb callback on previous inner_action
if (this.webclient && old_action) {
- old_action.set_on_reverse_breadcrumb(options.on_reverse_breadcrumb, this.webclient.get_scrollTop());
+ old_action.set_on_reverse_breadcrumb(options.on_reverse_breadcrumb, this.webclient.getScrollTop());
}
// Update action_stack (must be done before appendTo to properly
@@ -330,7 +339,7 @@ var ActionManager = Widget.extend({
// Hide the main ControlPanel for widgets that do not use it
self.main_control_panel.do_hide();
}
- framework.append(self.$el, new_widget_fragment, {
+ dom.append(self.$el, new_widget_fragment, {
in_DOM: self.is_in_DOM,
callbacks: [{widget: self.inner_widget}],
});
@@ -347,9 +356,9 @@ var ActionManager = Widget.extend({
self.inner_widget = old_widget;
});
},
- set_scrollTop: function(scrollTop) {
+ setScrollTop: function(scrollTop) {
if (this.inner_action) {
- this.inner_action.set_scrollTop(scrollTop);
+ this.inner_action.setScrollTop(scrollTop);
}
},
get_breadcrumbs: function () {
@@ -416,7 +425,7 @@ var ActionManager = Widget.extend({
self.clear_action_stack(to_destroy);
// Append the fragment of the action to restore to self.$el
- framework.append(self.$el, action.get_fragment(), {
+ dom.append(self.$el, action.get_fragment(), {
in_DOM: self.is_in_DOM,
callbacks: [{widget: action.widget}],
});
@@ -583,6 +592,7 @@ var ActionManager = Widget.extend({
on_reverse_breadcrumb: function() {},
hide_breadcrumb: false,
on_close: function() {},
+ on_load: function() {},
action_menu_id: null,
additional_context: {},
});
@@ -609,7 +619,8 @@ var ActionManager = Widget.extend({
options.clear_breadcrumbs = (action.target === 'main') || options.clear_breadcrumbs;
// Ensure context & domain are evaluated and can be manipulated/used
- var ncontext = new data.CompoundContext(options.additional_context, action.context || {});
+ var user_context = this.getSession().user_context;
+ var ncontext = new data.CompoundContext(user_context, options.additional_context, action.context || {});
action.context = pyeval.eval('context', ncontext);
if (action.context.active_id || action.context.active_ids) {
// Here we assume that when an `active_id` or `active_ids` is used
@@ -664,7 +675,8 @@ var ActionManager = Widget.extend({
});
}
- return $.when(this[type](action, options)).then(function() {
+ return $.when(this[type](action, options)).then(function (executor_action) {
+ options.on_load(executor_action);
return action;
});
},
@@ -698,12 +710,12 @@ var ActionManager = Widget.extend({
// explicitly passing a closing action to dialog_stop() prevents
// it from reloading the original form view
this.dialog_stop(executor.action);
- this.dialog = new Dialog(this, {
+ this.dialog = new Dialog(this, _.defaults(options || {}, {
title: executor.action.name,
dialogClass: executor.klass,
buttons: [],
size: executor.action.context.dialog_size,
- });
+ }));
// chain on_close triggers with previous dialog, if any
this.dialog.on_close = function(){
@@ -722,7 +734,8 @@ var ActionManager = Widget.extend({
this.dialog.on("closed", null, this.dialog.on_close);
this.dialog_widget = executor.widget();
if (this.dialog_widget instanceof ViewManager) {
- _.extend(this.dialog_widget.flags, {
+ executor.action.viewManager = this.dialog_widget;
+ _.defaults(this.dialog_widget.flags, {
$buttons: this.dialog.$footer,
footer_to_buttons: true,
});
@@ -740,19 +753,23 @@ var ActionManager = Widget.extend({
var fragment = document.createDocumentFragment();
return this.dialog_widget.appendTo(fragment).then(function() {
self.dialog.open();
- framework.append(self.dialog.$el, fragment, {
+ dom.append(self.dialog.$el, fragment, {
in_DOM: true,
callbacks: [{widget: self.dialog_widget}],
});
if(options.state && self.dialog_widget.do_load_state) {
return self.dialog_widget.do_load_state(options.state);
}
- }).then(function () { return executor.action; });
+ }).then(function () {
+ return executor.action;
+ });
}
var def = this.inner_action && this.webclient && this.webclient.clear_uncommitted_changes() || $.when();
return def.then(function() {
self.dialog_stop(executor.action);
- return self.push_action(executor.widget(), executor.action, options);
+ return self.push_action(executor.action.viewManager = executor.widget(), executor.action, options).then(function () {
+ return executor.action;
+ });
}).fail(function() {
return $.Deferred().reject();
});
@@ -814,7 +831,7 @@ var ActionManager = Widget.extend({
},
ir_actions_server: function (action, options) {
var self = this;
- return this.rpc('/web/action/run', {
+ return this.performRPC('/web/action/run', {
action_id: action.id,
context: action.context || {}
}).then(function (action) {
diff --git a/addons/web/static/src/js/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js
index 173d91aea25..173d91aea25 100644
--- a/addons/web/static/src/js/control_panel.js
+++ b/addons/web/static/src/js/chrome/control_panel.js
diff --git a/addons/web/static/src/js/widgets/loading.js b/addons/web/static/src/js/chrome/loading.js
index 645287ecf80..0c1b5e5025f 100644
--- a/addons/web/static/src/js/widgets/loading.js
+++ b/addons/web/static/src/js/chrome/loading.js
@@ -1,6 +1,17 @@
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 core = require('web.core');
var framework = require('web.framework');
var session = require('web.session');
diff --git a/addons/web/static/src/js/menu.js b/addons/web/static/src/js/chrome/menu.js
index 5fdfec5fb37..5fdfec5fb37 100644
--- a/addons/web/static/src/js/menu.js
+++ b/addons/web/static/src/js/chrome/menu.js
diff --git a/addons/web/static/src/js/widgets/pager.js b/addons/web/static/src/js/chrome/pager.js
index d9b37dc889a..5ecf11b391c 100644
--- a/addons/web/static/src/js/widgets/pager.js
+++ b/addons/web/static/src/js/chrome/pager.js
@@ -11,24 +11,25 @@ var direction = {
var Pager = Widget.extend({
template: "Pager",
-
events: {
'click .o_pager_previous': 'previous',
'click .o_pager_next': 'next',
'click .o_pager_value': '_edit',
},
-
/**
* The pager goes from 1 to size (included).
* The current value is current_min if limit === 1
- * or the interval [current_min, current_min + limit[ if limit > 1
+ * or the interval [current_min, current_min + limit[ if limit > 1
+ *
* @param {Widget} [parent] the parent widget
* @param {int} [size] the total number of elements
* @param {int} [current_min] the first element of the current_page
* @param {int} [limit] the number of elements per page
* @param {boolean} [options.can_edit] editable feature of the pager
- * @param {boolean} [options.single_page_hidden] (not) to display the pager if only one page
- * @param {function} [options.validate] callback returning a Deferred to validate changes
+ * @param {boolean} [options.single_page_hidden] (not) to display the pager
+ * if only one page
+ * @param {function} [options.validate] callback returning a Deferred to
+ * validate changes
*/
init: function (parent, size, current_min, limit, options) {
this.state = {
@@ -47,12 +48,13 @@ var Pager = Widget.extend({
validate: function() {
return $.Deferred().resolve();
},
+ withAccessKey: true, // can be disabled, for example, for x2m widgets
});
this._super(parent);
},
-
/**
* Renders the pager
+ *
* @returns {jQuery.Deferred}
*/
start: function () {
@@ -62,28 +64,9 @@ var Pager = Widget.extend({
return this._super();
},
- /**
- * Sets the state of the pager and renders it
- * @param {Object} [state] the values to update (size, current_min and limit)
- */
- update_state: function (state) {
- _.extend(this.state, state);
- this._render();
- },
-
- /**
- * Executes the previous action on the pager
- */
- previous: function() {
- this._change_selection(direction.previous);
- },
-
- /**
- * Executes the next action on the pager
- */
- next: function() {
- this._change_selection(direction.next);
- },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
/**
* Disables the pager's arrows
@@ -91,72 +74,40 @@ var Pager = Widget.extend({
disable: function() {
this.$('button').prop("disabled", true);
},
-
/**
* Enables the pager's arrows
*/
enable: function() {
this.$('button').prop("disabled", false);
},
-
/**
- * Private function that displays an input to edit the pager's state
+ * Executes the next action on the pager
*/
- _edit: function() {
- if (this.options.can_edit) {
- var self = this;
- var $input = $('<input>', {type: 'text', value: this.$value.html()});
-
- this.$value.html($input);
- $input.focus();
-
- // Event handlers
- $input.click(function(ev) {
- ev.stopPropagation(); // ignore clicks on the input
- });
- $input.blur(function(ev) {
- self._save($(ev.target)); // save the state when leaving the input
- });
- $input.on('keydown', function (ev) {
- ev.stopPropagation();
- if (ev.which === $.ui.keyCode.ENTER) {
- self._save($(ev.target)); // save on enter
- } else if (ev.which === $.ui.keyCode.ESCAPE) {
- self._render(); // leave on escape
- }
- });
- }
+ next: function() {
+ this._change_selection(direction.next);
},
-
/**
- * Private function that saves the state from the content of the input
- * @param {jQuery} [$input] the jQuery element containing the new state
+ * Executes the previous action on the pager
*/
- _save: function($input) {
- var self = this;
- this.options.validate().then(function() {
- var value = $input.val().split("-");
- var min = utils.confine(parseInt(value[0], 10), 1, self.state.size);
- var max = utils.confine(parseInt(value[1], 10), 1, self.state.size);
-
- if (!isNaN(min)) {
- self.state.current_min = min;
- if (!isNaN(max)) {
- self.state.limit = utils.confine(max-min+1, 1, self.state.size);
- } else {
- // The state has been given as a single value -> set the limit to 1
- self.state.limit = 1;
- }
- self.trigger('pager_changed', _.clone(self.state));
- }
- }).always(function() {
- // Render the pager's new state (removes the input)
- self._render();
- });
+ previous: function() {
+ this._change_selection(direction.previous);
},
+ /**
+ * Sets the state of the pager and renders it
+ * @param {Object} [state] the values to update (size, current_min and limit)
+ */
+ updateState: function (state) {
+ _.extend(this.state, state);
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
/**
* Private function that updates the pager's state according to a pager action
+ *
* @param {int} [direction] the action (previous or next) on the pager
*/
_change_selection: function (direction) {
@@ -181,7 +132,34 @@ var Pager = Widget.extend({
self._render();
});
},
+ /**
+ * Private function that displays an input to edit the pager's state
+ */
+ _edit: function() {
+ if (this.options.can_edit) {
+ var self = this;
+ var $input = $('<input>', {type: 'text', value: this.$value.html()});
+ this.$value.html($input);
+ $input.focus();
+
+ // Event handlers
+ $input.click(function(ev) {
+ ev.stopPropagation(); // ignore clicks on the input
+ });
+ $input.blur(function(ev) {
+ self._save($(ev.target)); // save the state when leaving the input
+ });
+ $input.on('keydown', function (ev) {
+ ev.stopPropagation();
+ if (ev.which === $.ui.keyCode.ENTER) {
+ self._save($(ev.target)); // save on enter
+ } else if (ev.which === $.ui.keyCode.ESCAPE) {
+ self._render(); // leave on escape
+ }
+ });
+ }
+ },
/**
* Private function that renders the pager's state
*/
@@ -209,7 +187,34 @@ var Pager = Widget.extend({
this.$value.html(value);
this.$limit.html(size);
}
+ }, /**
+ * Private function that saves the state from the content of the input
+ *
+ * @param {jQuery} [$input] the jQuery element containing the new state
+ */
+ _save: function($input) {
+ var self = this;
+ this.options.validate().then(function() {
+ var value = $input.val().split("-");
+ var min = utils.confine(parseInt(value[0], 10), 1, self.state.size);
+ var max = utils.confine(parseInt(value[1], 10), 1, self.state.size);
+
+ if (!isNaN(min)) {
+ self.state.current_min = min;
+ if (!isNaN(max)) {
+ self.state.limit = utils.confine(max-min+1, 1, self.state.size);
+ } else {
+ // The state has been given as a single value -> set the limit to 1
+ self.state.limit = 1;
+ }
+ self.trigger('pager_changed', _.clone(self.state));
+ }
+ }).always(function() {
+ // Render the pager's new state (removes the input)
+ self._render();
+ });
},
+
});
return Pager;
diff --git a/addons/web/static/src/js/views/search_filters.js b/addons/web/static/src/js/chrome/search_filters.js
index cedb63fa1d9..e4d58536df2 100644
--- a/addons/web/static/src/js/views/search_filters.js
+++ b/addons/web/static/src/js/chrome/search_filters.js
@@ -29,9 +29,9 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
init: function (parent, fields) {
this._super(parent);
this.fields = _(fields).chain()
- .map(function(val, key) { return _.extend({}, val, {'name': key}); })
+ .map(function (val, key) { return _.extend({}, val, {'name': key}); })
.filter(function (field) { return !field.deprecated && field.searchable; })
- .sortBy(function(field) {return field.string;})
+ .sortBy(function (field) {return field.string;})
.value();
this.attrs = {_: _, fields: this.fields, selected: null};
this.value = null;
@@ -39,10 +39,10 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
start: function () {
return this._super().done(this.proxy('changed'));
},
- changed: function() {
+ changed: function () {
var nval = this.$(".o_searchview_extended_prop_field").val();
if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
- this.select_field(_.detect(this.fields, function(x) {return x.name == nval;}));
+ this.select_field(_.detect(this.fields, function (x) {return x.name == nval;}));
}
},
operator_changed: function (e) {
@@ -53,7 +53,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
*
* @param field a field descriptor object (as returned by fields_get, augmented by the field name)
*/
- select_field: function(field) {
+ select_field: function (field) {
var self = this;
if(this.attrs.selected !== null && this.attrs.selected !== undefined) {
this.value.destroy();
@@ -66,10 +66,10 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
}
var type = field.type;
- var Field = core.search_filters_registry.get_any([type, "char"]);
+ var Field = core.search_filters_registry.getAny([type, "char"]);
this.value = new Field(this, field);
- _.each(this.value.operators, function(operator) {
+ _.each(this.value.operators, function (operator) {
$('<option>', {value: operator.value})
.text(String(operator.text))
.appendTo(self.$('.o_searchview_extended_prop_op'));
@@ -166,7 +166,7 @@ ExtendedSearchProposition.Char = ExtendedSearchProposition.Field.extend({
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
- get_value: function() {
+ get_value: function () {
return this.$el.val();
}
});
@@ -223,7 +223,7 @@ ExtendedSearchProposition.DateTime = ExtendedSearchProposition.Field.extend({
}
return str;
},
- start: function() {
+ start: function () {
return $.when(
this._super.apply(this, arguments),
this._create_new_widget("datewidget_0")
@@ -268,7 +268,7 @@ ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({
toString: function () {
return this.$el.val();
},
- get_value: function() {
+ get_value: function () {
try {
var val =this.$el.val();
return formats.parse_value(val === "" ? 0 : val, {'widget': 'integer'});
@@ -301,7 +301,7 @@ ExtendedSearchProposition.Float = ExtendedSearchProposition.Field.extend({
toString: function () {
return this.$el.val();
},
- get_value: function() {
+ get_value: function () {
try {
var val =this.$el.val();
return formats.parse_value(val === "" ? 0.0 : val, {'widget': 'float'});
@@ -324,7 +324,7 @@ ExtendedSearchProposition.Selection = ExtendedSearchProposition.Field.extend({
var option = select.options[select.selectedIndex];
return option.label || option.text;
},
- get_value: function() {
+ get_value: function () {
return this.$el.val();
}
});
@@ -339,7 +339,7 @@ ExtendedSearchProposition.Boolean = ExtendedSearchProposition.Field.extend({
return this.format_label(
_t('%(field)s %(operator)s'), field, operator);
},
- get_value: function() {
+ get_value: function () {
return true;
}
});
diff --git a/addons/web/static/src/js/views/search_inputs.js b/addons/web/static/src/js/chrome/search_inputs.js
index 99535f29797..99535f29797 100644
--- a/addons/web/static/src/js/views/search_inputs.js
+++ b/addons/web/static/src/js/chrome/search_inputs.js
diff --git a/addons/web/static/src/js/views/search_menus.js b/addons/web/static/src/js/chrome/search_menus.js
index 0b28d13fea2..7eff17d7ed6 100644
--- a/addons/web/static/src/js/views/search_menus.js
+++ b/addons/web/static/src/js/chrome/search_menus.js
@@ -94,15 +94,16 @@ return Widget.extend({
this.$inputs.first().focus();
return;
}
- var search = this.searchview.build_search_data(),
- view_manager = this.findAncestor(function (a) {
+ var user_context = this.getSession().user_context;
+ var search = this.searchview.build_search_data();
+ var view_manager = this.findAncestor(function (a) {
// HORRIBLE HACK. PLEASE SAVE ME FROM MYSELF (BUT IN A PAINLESS WAY IF POSSIBLE)
return 'active_view' in a;
- }),
- view_context = view_manager ? view_manager.active_view.controller.get_context() : {},
- results = pyeval.sync_eval_domains_and_contexts({
+ });
+ var view_context = view_manager ? view_manager.active_view.controller.get_context() : {};
+ var results = pyeval.eval_domains_and_contexts({
domains: search.domains,
- contexts: search.contexts.concat(view_context || []),
+ contexts: [user_context].concat(search.contexts.concat(view_context || [])),
group_by_seq: search.groupbys || [],
});
if (!_.isEmpty(results.group_by)) {
@@ -274,7 +275,6 @@ return Widget.extend({
odoo.define('web.FilterMenu', function (require) {
"use strict";
-var data_manager = require('web.data_manager');
var search_filters = require('web.search_filters');
var search_inputs = require('web.search_inputs');
var Widget = require('web.Widget');
@@ -301,12 +301,16 @@ return Widget.extend({
}
},
},
- init: function (parent, filters) {
+ init: function (parent, filters, fields) {
this._super(parent);
this.filters = filters || [];
this.searchview = parent;
this.propositions = [];
this.custom_filters_open = false;
+ this.fields = _.pick(fields, function (field, name) {
+ return field.selectable !== false && name !== 'id';
+ });
+ this.fields.id = { string: 'ID', type: 'id', searchable: true };
},
start: function () {
var self = this;
@@ -321,22 +325,6 @@ return Widget.extend({
}
});
},
- get_fields: function () {
- if (!this._fields_def) {
- this._fields_def = data_manager.load_fields(this.searchview.dataset).then(function (data) {
- var fields = {
- id: { string: 'ID', type: 'id', searchable: true }
- };
- _.each(data, function(field_def, field_name) {
- if (field_def.selectable !== false && field_name !== 'id') {
- fields[field_name] = field_def;
- }
- });
- return fields;
- });
- }
- return this._fields_def;
- },
toggle_custom_filter_menu: function (is_open) {
var self = this;
this.custom_filters_open = !_.isUndefined(is_open) ? is_open : !this.custom_filters_open;
@@ -353,14 +341,10 @@ return Widget.extend({
});
},
append_proposition: function () {
- var self = this;
- return this.get_fields().then(function (fields) {
- var prop = new search_filters.ExtendedSearchProposition(self, fields);
- self.propositions.push(prop);
- prop.insertBefore(self.$add_filter_menu);
- self.$apply_filter.prop('disabled', false);
- return prop;
- });
+ var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
+ this.propositions.push(prop);
+ this.$apply_filter.prop('disabled', false);
+ return prop.insertBefore(this.$add_filter_menu);
},
remove_proposition: function (prop) {
this.propositions = _.without(this.propositions, prop);
@@ -396,7 +380,6 @@ odoo.define('web.GroupByMenu', function (require) {
"use strict";
var core = require('web.core');
-var data_manager = require('web.data_manager');
var search_inputs = require('web.search_inputs');
var Widget = require('web.Widget');
@@ -416,13 +399,22 @@ return Widget.extend({
this.toggle_add_menu();
},
},
- init: function (parent, groups) {
+ init: function (parent, groups, fields) {
+ var self = this;
this._super(parent);
- this.groups = groups || [];
- this.groupable_fields = [];
this.searchview = parent;
+ this.groups = groups || [];
+ this.groupableFields = [];
+ var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+ _.each(fields, function (field, name) {
+ if (field.store && _.contains(groupable_types, field.type)) {
+ self.groupableFields.push(_.extend({}, field, {name: name}));
+ }
+ });
+ self.groupableFields = _.sortBy(this.groupableFields, 'string');
},
start: function () {
+ var self = this;
this.$menu = this.$('.o_group_by_menu');
var divider = this.$menu.find('.divider');
_.invoke(this.groups, 'insertBefore', divider);
@@ -430,48 +422,28 @@ return Widget.extend({
divider.show();
}
this.$add_group = this.$menu.find('.o_add_custom_group');
- },
- get_fields: function () {
- var self = this;
- if (!this._fields_def) {
- this._fields_def = data_manager.load_fields(this.searchview.dataset).then(function (fields) {
- var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
- var filter_group_field = _.filter(fields, function(field, name) {
- if (field.store && _.contains(groupable_types, field.type)) {
- field.name = name;
- return field;
- }
- });
- self.groupable_fields = _.sortBy(filter_group_field, 'string');
-
- self.$menu.append(QWeb.render('GroupByMenuSelector', self));
- self.$add_group_menu = self.$('.o_add_group');
- self.$group_selector = self.$('.o_group_selector');
- self.$('.o_select_group').click(function () {
- self.toggle_add_menu(false);
- var field = self.$group_selector.find(':selected').data('name');
- self.add_groupby_to_menu(field);
- });
- });
- }
- return this._fields_def;
+ this.$menu.append(QWeb.render('GroupByMenuSelector', this));
+ this.$add_group_menu = this.$('.o_add_group');
+ this.$group_selector = this.$('.o_group_selector');
+ this.$('.o_select_group').click(function () {
+ self.toggle_add_menu(false);
+ var field = self.$group_selector.find(':selected').data('name');
+ self.add_groupby_to_menu(field);
+ });
},
toggle_add_menu: function (is_open) {
- var self = this;
- this.get_fields().then(function () {
- self.$add_group
- .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
- .toggleClass('o_open_menu', is_open);
- self.$add_group_menu.toggle(is_open);
- if (self.$add_group.hasClass('o_open_menu')) {
- self.$group_selector.focus();
- }
- });
+ this.$add_group
+ .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
+ .toggleClass('o_open_menu', is_open);
+ this.$add_group_menu.toggle(is_open);
+ if (this.$add_group.hasClass('o_open_menu')) {
+ this.$group_selector.focus();
+ }
},
add_groupby_to_menu: function (field_name) {
var filter = new search_inputs.Filter({attrs:{
context:"{'group_by':'" + field_name + "''}",
- name: _.find(this.groupable_fields, {name: field_name}).string,
+ name: _.find(this.groupableFields, {name: field_name}).string,
}}, this.searchview);
var group = new search_inputs.FilterGroup([filter], this.searchview),
divider = this.$('.divider').show();
diff --git a/addons/web/static/src/js/views/search_view.js b/addons/web/static/src/js/chrome/search_view.js
index 65dbb4557e7..26305817e55 100644
--- a/addons/web/static/src/js/views/search_view.js
+++ b/addons/web/static/src/js/chrome/search_view.js
@@ -4,15 +4,12 @@ odoo.define('web.SearchView', function (require) {
var AutoComplete = require('web.AutoComplete');
var config = require('web.config');
var core = require('web.core');
-var data_manager = require('web.data_manager');
var FavoriteMenu = require('web.FavoriteMenu');
var FilterMenu = require('web.FilterMenu');
var GroupByMenu = require('web.GroupByMenu');
var pyeval = require('web.pyeval');
var search_inputs = require('web.search_inputs');
-var View = require('web.View');
var Widget = require('web.Widget');
-var local_storage = require('web.local_storage');
var _t = core._t;
var Backbone = window.Backbone;
@@ -230,12 +227,12 @@ var FacetValueView = Widget.extend({
}
});
-var SearchView = View.extend({
+var SearchView = Widget.extend({
events: {
'click .o_searchview_more': function (e) {
$(e.target).toggleClass('fa-search-plus fa-search-minus');
- var visible_search_menu = (local_storage.getItem('visible_search_menu') !== 'true');
- local_storage.setItem('visible_search_menu', visible_search_menu);
+ var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
+ this.call('local_storage', 'setItem', visibleSearchMenu !== 'true');
this.toggle_buttons();
},
'keydown .o_searchview_input, .o_searchview_facet': function (e) {
@@ -253,7 +250,7 @@ var SearchView = View.extend({
}
},
},
- defaults: _.extend({}, View.prototype.defaults, {
+ defaults: _.extend({}, Widget.prototype.defaults, {
hidden: false,
disable_filters: false,
disable_groupby: false,
@@ -273,15 +270,20 @@ var SearchView = View.extend({
* @param {Boolean} [options.hidden=false] hide the search view
* @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
*/
- init: function() {
+ init: function (parent, dataset, fvg, options) {
this._super.apply(this, arguments);
+ this.options = options;
+ this.dataset = dataset;
+ this.fields_view = fvg;
+ this.fields = this.fields_view.fields;
this.query = undefined;
this.title = this.options.action && this.options.action.name;
this.action_id = this.options.action && this.options.action.id;
this.search_fields = [];
this.filters = [];
this.groupbys = [];
- this.visible_filters = (local_storage.getItem('visible_search_menu') === 'true');
+ var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
+ this.visible_filters = (visibleSearchMenu === 'true');
this.input_subviews = []; // for user input in searchbar
this.search_defaults = this.options.search_defaults || {};
this.headless = this.options.hidden && _.isEmpty(this.search_defaults);
@@ -295,13 +297,13 @@ var SearchView = View.extend({
var self = this;
var def;
if (!this.options.disable_favorites) {
- def = data_manager.load_filters(this.dataset, this.action_id).then(function (filters) {
+ def = this.loadFilters(this.dataset, this.action_id).then(function (filters) {
self.favorite_filters = filters;
});
}
return $.when(this._super(), def);
},
- start: function() {
+ start: function () {
if (this.headless) {
this.do_hide();
}
@@ -318,11 +320,11 @@ var SearchView = View.extend({
this.prepare_search_inputs();
if (this.$buttons) {
if (!this.options.disable_filters) {
- this.filter_menu = new FilterMenu(this, this.filters);
+ this.filter_menu = new FilterMenu(this, this.filters, this.fields);
menu_defs.push(this.filter_menu.appendTo(this.$buttons));
}
if (!this.options.disable_groupby) {
- this.groupby_menu = new GroupByMenu(this, this.groupbys);
+ this.groupby_menu = new GroupByMenu(this, this.groupbys, this.fields);
menu_defs.push(this.groupby_menu.appendTo(this.$buttons));
}
if (!this.options.disable_favorites) {
@@ -332,7 +334,7 @@ var SearchView = View.extend({
}
return $.when.apply($, menu_defs).then(this.set_default_filters.bind(this));
},
- get_title: function() {
+ get_title: function () {
return this.title;
},
set_default_filters: function () {
@@ -368,7 +370,7 @@ var SearchView = View.extend({
return;
}
var search = this.build_search_data();
- this.trigger('search_data', search.domains, search.contexts, search.groupbys);
+ this.trigger_up('search', search);
},
/**
* Extract search data from the view's facets.
@@ -590,7 +592,7 @@ var SearchView = View.extend({
attrs.widget = undefined;
}
- var Obj = core.search_widgets_registry.get_any([attrs.widget, field.type]);
+ var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]);
if (Obj) {
self.search_fields.push(new (Obj) (filter.item, field, self));
}
diff --git a/addons/web/static/src/js/widgets/sidebar.js b/addons/web/static/src/js/chrome/sidebar.js
index 32513048087..79b06f9ee4d 100644
--- a/addons/web/static/src/js/widgets/sidebar.js
+++ b/addons/web/static/src/js/chrome/sidebar.js
@@ -120,10 +120,10 @@ var Sidebar = Widget.extend({
on_item_action_clicked: function(item) {
var self = this;
self.getParent().sidebar_eval_context().done(function (sidebar_eval_context) {
- var ids = self.getParent().get_selected_ids();
+ var ids = self.getParent().getSelectedIds();
var domain;
- if (self.getParent().get_active_domain) {
- domain = self.getParent().get_active_domain();
+ if (self.getParent().getActiveDomain) {
+ domain = self.getParent().getActiveDomain();
}
else {
domain = $.Deferred().resolve(undefined);
diff --git a/addons/web/static/src/js/widgets/systray_menu.js b/addons/web/static/src/js/chrome/systray_menu.js
index 0d0e18476b0..24fd02a3736 100644
--- a/addons/web/static/src/js/widgets/systray_menu.js
+++ b/addons/web/static/src/js/chrome/systray_menu.js
@@ -3,33 +3,49 @@ odoo.define('web.SystrayMenu', function (require) {
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) {
+ init: function (parent) {
this._super(parent);
this.items = [];
this.widgets = [];
this.load = $.Deferred();
},
- start: function() {
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
var self = this;
self._super.apply(this, arguments);
- self.load_items();
+ self._loadItems();
$.when.apply($, self.items).always(function () {
self.load.resolve();
});
return self.load;
},
- load_items: function() {
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiate items, using the classes located in SystrayMenu.items.
+ */
+ _loadItems: function () {
var self = this;
SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) {
return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50;
});
- _.each(SystrayMenu.Items, function(widgetCls) {
- var cur_systray_item = new widgetCls(self);
+ _.each(SystrayMenu.Items, function (WidgetClass) {
+ var cur_systray_item = new WidgetClass(self);
self.widgets.push(cur_systray_item);
self.items.push(cur_systray_item.prependTo(self.$el));
});
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 00000000000..8a33260432b
--- /dev/null
+++ b/addons/web/static/src/js/chrome/user_menu.js
@@ -0,0 +1,112 @@
+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 framework = require('web.framework');
+var Widget = require('web.Widget');
+
+
+var UserMenu = Widget.extend({
+ template: 'UserMenu',
+
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ var self = this;
+ var session = this.getSession();
+ this.$el.on('click', '.dropdown-menu li a[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 $.when();
+ }
+ var topbar_name = session.name;
+ if (session.debug) {
+ 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_small',
+ id: session.uid,
+ });
+ $avatar.attr('src', avatar_src);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onMenuAccount: function () {
+ var self = this;
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: function () {
+ self.performRPC('/web/session/account').then(function (url) {
+ framework.redirect(url);
+ }).fail(function (result, ev){
+ ev.preventDefault();
+ framework.redirect('https://accounts.odoo.com/account');
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuDocumentation: function () {
+ window.open('https://www.odoo.com/documentation/user', '_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.performRPC("/web/action/load", {
+ action_id: "base.action_res_users_my",
+ }).done(function (result) {
+ result.res_id = session.uid;
+ self.do_action(result);
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuSupport: function () {
+ window.open('https://www.odoo.com/buy', '_blank');
+ },
+});
+
+return UserMenu;
+
+});
diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js
new file mode 100644
index 00000000000..5b30d1a3c5c
--- /dev/null
+++ b/addons/web/static/src/js/chrome/view_manager.js
@@ -0,0 +1,610 @@
+odoo.define('web.ViewManager', function (require) {
+"use strict";
+
+var ControlPanelMixin = require('web.ControlPanelMixin');
+var core = require('web.core');
+var data = require('web.data');
+var data_manager = require('web.data_manager');
+var dom = require('web.dom');
+var pyeval = require('web.pyeval');
+var SearchView = require('web.SearchView');
+var view_registry = require('web.view_registry');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var ViewManager = Widget.extend(ControlPanelMixin, {
+ className: "o_view_manager_content",
+ custom_events: {
+ execute_action: function(event) {
+ var data = event.data;
+ this.do_execute_action(data.action_data, data.model, data.record_id, data.on_closed)
+ .then(data.on_success, data.on_fail);
+ },
+ search: function(event) {
+ var d = event.data;
+ _.extend(this.env, this._process_search_data(d.domains, d.contexts, d.groupbys));
+ this.active_view.controller.reload(this.env);
+ },
+ switch_view: function(event) {
+ if ('res_id' in event.data) {
+ this.env.currentId = event.data.res_id;
+ }
+ var options = {};
+ if (event.data.mode) {
+ options.mode = event.data.mode;
+ }
+ this.switch_mode(event.data.view_type, options);
+ },
+ env_updated: function(event) {
+ _.extend(this.env, event.data);
+ },
+ push_state: function(event) {
+ this.do_push_state(event.data);
+ },
+ },
+ /**
+ * Called each time the view manager is attached into the DOM
+ */
+ on_attach_callback: function() {
+ this.is_in_DOM = true;
+ if (this.active_view && this.active_view.controller.on_attach_callback) {
+ this.active_view.controller.on_attach_callback();
+ }
+ },
+ /**
+ * Called each time the view manager is detached from the DOM
+ */
+ on_detach_callback: function() {
+ this.is_in_DOM = false;
+ if (this.active_view && this.active_view.controller.on_detach_callback) {
+ this.active_view.controller.on_detach_callback();
+ }
+ },
+ /**
+ * @param {Object} [dataset]
+ * @param {Array} [views] List of [view_id, view_type[, fields_view]]
+ * @param {Object} [flags] various boolean describing UI state
+ */
+ init: function(parent, dataset, views, flags, options) {
+ var self = this;
+ this._super(parent);
+
+ this.action = options && options.action || {};
+ this.action_manager = options && options.action_manager;
+ this.flags = flags || {};
+ this.dataset = dataset;
+ this.view_order = [];
+ this.views = {};
+ this.view_stack = []; // used for breadcrumbs
+ this.active_view = null;
+ this.registry = view_registry;
+ this.title = this.action.name;
+ var actionGroupBy = self.action.context.group_by;
+ if (!actionGroupBy) {
+ actionGroupBy = []
+ } else if (typeof actionGroupBy === 'string') {
+ actionGroupBy = [actionGroupBy]
+ }
+ _.each(views, function (view) {
+ var view_type = view[1] || view.view_type;
+ var View = self.registry.get(view_type); //.prototype.config.Controller;
+ if (!View) {
+ console.error("View type", "'"+view_type+"'", "is not present in the view registry.");
+ return;
+ }
+ var view_label = View.prototype.display_name;
+ var view_descr = {
+ accesskey: View.prototype.accesskey,
+ button_label: _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}),
+ controller: null,
+ fields_view: view[2] || view.fields_view,
+ icon: View.prototype.icon,
+ label: view_label,
+ mobile_friendly: View.prototype.mobile_friendly,
+ multi_record: View.prototype.multi_record,
+ options: _.extend({
+ action: self.action,
+ limit: self.action.limit,
+ views: self.action.views,
+ groupBy: actionGroupBy,
+ }, self.flags, self.flags[view_type], view.options),
+ searchable: View.prototype.searchable,
+ title: self.title,
+ type: view_type,
+ view_id: view[0] || view.view_id,
+ };
+ self.view_order.push(view_descr);
+ self.views[view_type] = view_descr;
+ });
+ this.first_view = this.views[options && options.view_type]; // view to open first
+ this.default_view = this.get_default_view();
+
+ // env registers properties shared between views
+ this.env = {
+ modelName: this.dataset.model,
+ ids: this.dataset.ids.length ? this.dataset.ids : undefined,
+ currentId: this.dataset.ids.length ? this.dataset.ids[this.dataset.index] : undefined,
+ domain: undefined,
+ context: this.dataset.context,
+ groupBy: undefined,
+ };
+ },
+ willStart: function () {
+ var views_def;
+ var first_view_to_display = this.first_view || this.default_view;
+ if (!first_view_to_display.fields_view || (this.flags.search_view && !this.search_fields_view)) {
+ views_def = this.load_views();
+ }
+ return $.when(this._super(), views_def);
+ },
+ /**
+ * @return {Deferred} initial view and search view (if any) loading promise
+ */
+ start: function() {
+ var self = this;
+ var _super = this._super.bind(this, arguments);
+ var def;
+ if (this.flags.search_view) {
+ def = this.setup_search_view().then(function() {
+ // udpate domain, context and groupby in the env
+ var d = self.searchview.build_search_data();
+ _.extend(self.env, self._process_search_data(d.domains, d.contexts, d.groupbys));
+ });
+ }
+ return $.when(def).then(function() {
+ var defs = [];
+ defs.push(_super());
+
+ if (self.flags.views_switcher) {
+ self.render_switch_buttons();
+ }
+
+ // If a non multi-record first_view is given, switch to it but first push the default_view
+ // to the view_stack to complete the breadcrumbs
+ if (self.first_view && !self.first_view.multi_record && self.default_view.multi_record) {
+ self.view_stack.push(self.default_view);
+ }
+ var view_to_load = self.first_view || self.default_view;
+ var options = _.extend({}, view_to_load.options);
+ defs.push(self.switch_mode(view_to_load.type, options));
+
+ return $.when.apply($, defs).then(function() {
+ if (self.flags.on_load) {
+ self.flags.on_load(self);
+ }
+ });
+ });
+ },
+ /**
+ * Loads all missing field_views of views in this.views and the search view.
+ *
+ * @return {Deferred}
+ */
+ load_views: function () {
+ var self = this;
+ var views = [];
+ _.each(this.views, function (view) {
+ if (!view.fields_view) {
+ views.push([view.view_id, view.type]);
+ }
+ });
+ var options = {
+ action_id: this.action.id,
+ toolbar: this.flags.sidebar,
+ };
+ if (this.flags.search_view && !this.search_fields_view) {
+ options.load_filters = true;
+ var searchview_id = this.action.search_view_id && this.action.search_view_id[0];
+ views.push([searchview_id || false, 'search']);
+ }
+ var params = {
+ model: this.dataset.model,
+ context: this.dataset.get_context(),
+ views_descr: views,
+ };
+ return data_manager.load_views(params, options).then(function (fields_views) {
+ _.each(fields_views, function (fields_view, view_type) {
+ if (view_type === 'search') {
+ self.search_fields_view = fields_view;
+ } else {
+ self.views[view_type].fields_view = fields_view;
+ }
+ });
+ });
+ },
+ /**
+ * Returns the default view with the following fallbacks:
+ * - use the default_view defined in the flags, if any
+ * - use the first view in the view_order
+ *
+ * @returns {Object} the default view
+ */
+ get_default_view: function() {
+ return this.views[this.flags.default_view || this.view_order[0].type];
+ },
+ switch_mode: function(view_type, view_options) {
+ var self = this;
+ var view = this.views[view_type];
+
+ if (!view || this.currently_switching) {
+ return $.Deferred().reject();
+ } else {
+ this.currently_switching = true; // prevent overlapping switches
+ }
+
+ var old_view = this.active_view;
+ view_options = _.extend({}, view.options, view_options, this.env);
+
+ if (view_options.groupBy && !view_options.groupBy.length) {
+ var actionContext = view_options ? view_options.action.context : {};
+ var actionGroupBy = actionContext.group_by;
+ if (!actionGroupBy) {
+ actionGroupBy = []
+ } else if (typeof actionGroupBy === 'string') {
+ actionGroupBy = [actionGroupBy]
+ }
+ view_options.groupBy = actionGroupBy;
+ }
+
+ // Ensure that the fields_view has been loaded
+ var views_def;
+ if (!view.fields_view) {
+ views_def = this.load_views();
+ }
+
+ return $.when(views_def).then(function () {
+ if (view.multi_record) {
+ self.view_stack = [];
+ } else if (self.view_stack.length > 0 && !(_.last(self.view_stack).multi_record)) {
+ // Replace the last view by the new one if both are mono_record
+ self.view_stack.pop();
+ }
+ self.view_stack.push(view);
+
+ self.active_view = view;
+
+ if (!view.loaded) {
+ view.loaded = $.Deferred();
+ self.create_view(view, view_options).then(function(controller) {
+ view.controller = controller;
+ view.$fragment = $('<div>');
+ controller.appendTo(view.$fragment).done(function() {
+ // Remove the unnecessary outer div
+ view.$fragment = view.$fragment.contents();
+ view.loaded.resolve();
+ });
+ });
+ } else {
+ view.loaded = view.loaded.then(function() {
+ return view.controller.reload(view_options);
+ });
+ }
+
+ return $.when(view.loaded)
+ .then(function() {
+ self._display_view(old_view);
+ self.trigger('switch_mode', view_type, view_options);
+ }).fail(function(e) {
+ if (!(e && e.code === 200 && e.data.exception_type)) {
+ self.do_warn(_t("Error"), view.controller.display_name + _t(" view couldn't be loaded"));
+ }
+ // Restore internal state
+ self.active_view = old_view;
+ self.view_stack.pop();
+ });
+ }).always(function () {
+ self.currently_switching = false;
+ });
+ },
+ _display_view: function (old_view) {
+ var view_controller = this.active_view.controller;
+ var view_fragment = this.active_view.$fragment;
+
+ // Prepare the ControlPanel content and update it
+ var view_control_elements = this.render_view_control_elements();
+ var cp_status = {
+ active_view_selector: '.o_cp_switch_' + this.active_view.type,
+ breadcrumbs: this.action_manager && this.action_manager.get_breadcrumbs(),
+ cp_content: _.extend({}, this.searchview_elements, view_control_elements),
+ hidden: this.flags.headless,
+ searchview: this.searchview,
+ search_view_hidden: !this.active_view.searchable,
+ };
+ this.update_control_panel(cp_status);
+
+ // Detach the old view and store it
+ if (old_view && old_view !== this.active_view) {
+ // Store the scroll position
+ if (this.action_manager && this.action_manager.webclient) {
+ old_view.controller.setScrollTop(this.action_manager.webclient.getScrollTop());
+ }
+ // Do not detach ui-autocomplete elements to let jquery-ui garbage-collect them
+ var $to_detach = this.$el.contents().not('.ui-autocomplete');
+ old_view.$fragment = dom.detach([{widget: old_view.controller}], {$to_detach: $to_detach});
+ }
+
+ // If the user switches from a multi-record to a mono-record view,
+ // the action manager should be scrolled to the top.
+ if (old_view && old_view.controller.multi_record === true && view_controller.multi_record === false) {
+ view_controller.setScrollTop(0);
+ }
+
+ // Append the view fragment to this.$el
+ dom.append(this.$el, view_fragment, {
+ in_DOM: this.is_in_DOM,
+ callbacks: [{widget: view_controller}],
+ });
+ },
+ create_view: function(view, view_options) {
+ var self = this;
+ var arch = view.fields_view.arch;
+ var View = this.registry.get(arch.attrs.js_class || view.type);
+ var params = _.extend({}, view_options);
+ if (view.type === "form" && ((this.action.target === 'new' || this.action.target === 'inline') ||
+ (view_options && view_options.mode === 'edit'))) {
+ params.mode = params.initial_mode || 'edit';
+ }
+
+ view = new View(arch, view.fields_view.fields, params);
+ return view.createController(this).then(function(controller) {
+ controller.on('history_back', this, function() {
+ if (self.action_manager) self.action_manager.trigger('history_back');
+ });
+ controller.on("change:title", this, function() {
+ if (self.action_manager && !self.flags.headless) {
+ var breadcrumbs = self.action_manager.get_breadcrumbs();
+ self.update_control_panel({breadcrumbs: breadcrumbs}, {clear: false});
+ }
+ });
+ return controller;
+ });
+ },
+ select_view: function (index) {
+ var view_type = this.view_stack[index].type;
+ return this.switch_mode(view_type);
+ },
+ /**
+ * Renders the switch buttons for multi- and mono-record views and adds
+ * listeners on them, but does not append them to the DOM
+ * Sets switch_buttons.$mono and switch_buttons.$multi to send to the ControlPanel
+ */
+ render_switch_buttons: function() {
+ var self = this;
+
+ // Partition the views according to their multi-/mono-record status
+ var views = _.partition(this.view_order, function(view) {
+ return view.multi_record === true;
+ });
+ var multi_record_views = views[0];
+ var mono_record_views = views[1];
+
+ // Inner function to render and prepare switch_buttons
+ var _render_switch_buttons = function(views) {
+ if (views.length > 1) {
+ var $switch_buttons = $(QWeb.render('ViewManager.switch-buttons', {views: views}));
+ // Create bootstrap tooltips
+ _.each(views, function(view) {
+ $switch_buttons.filter('.o_cp_switch_' + view.type).tooltip();
+ });
+ // Add onclick event listener
+ $switch_buttons.filter('button').click(_.debounce(function(event) {
+ var view_type = $(event.target).data('view-type');
+ self.switch_mode(view_type);
+ }, 200, true));
+ return $switch_buttons;
+ }
+ };
+
+ // Render switch buttons but do not append them to the DOM as this will
+ // be done later, simultaneously to all other ControlPanel elements
+ this.switch_buttons = {};
+ this.switch_buttons.$multi = _render_switch_buttons(multi_record_views);
+ this.switch_buttons.$mono = _render_switch_buttons(mono_record_views);
+ },
+ /**
+ * Renders the control elements (buttons, sidebar, pager) of the current view.
+ * Fills this.active_view.control_elements dictionnary with the rendered
+ * elements and the adequate view switcher, to send to the ControlPanel.
+ * Warning: it should be called before calling do_show on the view as the
+ * sidebar is extended to listen on the load_record event triggered as soon
+ * as do_show is done (the sidebar should thus be instantiated before).
+ */
+ render_view_control_elements: function() {
+ if (!this.active_view.control_elements) {
+ var view_controller = this.active_view.controller;
+ var $buttons = this.flags.$buttons;
+ var elements = {};
+ if (!this.flags.headless) {
+ elements = {
+ $buttons: $("<div>"),
+ $sidebar: $("<div>"),
+ $pager: $("<div>"),
+ };
+ }
+ view_controller.renderButtons($buttons ? $buttons.empty() : elements.$buttons);
+ view_controller.renderSidebar(elements.$sidebar);
+ view_controller.renderPager(elements.$pager);
+ // Remove the unnecessary outer div
+ elements = _.mapObject(elements, function($node) {
+ return $node && $node.contents();
+ });
+ // Use the adequate view switcher (mono- or multi-record)
+ if (this.switch_buttons) {
+ if (this.active_view.multi_record) {
+ elements.$switch_buttons = this.switch_buttons.$multi;
+ } else {
+ elements.$switch_buttons = this.switch_buttons.$mono;
+ }
+ }
+
+ // Store the rendered elements in the active_view to allow restoring them later
+ this.active_view.control_elements = elements;
+ }
+ return this.active_view.control_elements;
+ },
+ /**
+ * Sets up the current viewmanager's search view.
+ * Sets $searchview and $searchview_buttons in searchview_elements to send to the ControlPanel
+ */
+ setup_search_view: function() {
+ var self = this;
+ var search_defaults = {};
+ var context = this.action.context || [];
+ _.each(context, function (value, key) {
+ var match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ search_defaults[match[1]] = value;
+ }
+ });
+
+ var options = {
+ hidden: this.flags.search_view === false,
+ disable_custom_filters: this.flags.search_disable_custom_filters,
+ $buttons: $("<div>"),
+ action: this.action,
+ search_defaults: search_defaults,
+ };
+ // Instantiate the SearchView, but do not append it nor its buttons to the DOM as this will
+ // be done later, simultaneously to all other ControlPanel elements
+ this.searchview = new SearchView(this, this.dataset, this.search_fields_view, options);
+
+ return $.when(this.searchview.appendTo($("<div>"))).done(function() {
+ self.searchview_elements = {};
+ self.searchview_elements.$searchview = self.searchview.$el;
+ self.searchview_elements.$searchview_buttons = self.searchview.$buttons.contents();
+ });
+ },
+ _process_search_data: function(domains, contexts, groupbys) {
+ // var controller = this.active_view.controller; // the correct view must be loaded here
+ var action_context = this.action.context || {};
+ var view_context = {}; //controller.get_context();
+ var results = pyeval.eval_domains_and_contexts({
+ domains: [this.action.domain || []].concat(domains || []),
+ contexts: [action_context, view_context].concat(contexts || []),
+ group_by_seq: groupbys || [],
+ eval_context: this.getSession().user_context,
+ });
+ if (results.error) {
+ throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
+ JSON.stringify(results.error)));
+ }
+ // this.dataset._model = new Model(this.dataset.model, results.context, results.domain);
+ // var groupby = results.group_by.length ? results.group_by : action_context.group_by;
+ // if (_.isString(groupby)) {
+ // groupby = [groupby];
+ // }
+ // if (!controller.grouped && !_.isEmpty(groupby)){
+ // this.dataset.set_sort([]);
+ // }
+ return {
+ context: results.context,
+ domain: results.domain,
+ groupBy: results.group_by,
+ };
+ },
+ do_push_state: function(state) {
+ if (this.action_manager) {
+ state.view_type = this.active_view.type;
+ this.action_manager.do_push_state(state);
+ }
+ },
+ do_load_state: function(state) {
+ var stateChanged = false;
+ if ('id' in state && state.id !== this.env.currentId) {
+ this.env.currentId = state.id;
+ stateChanged = true;
+ }
+ if (state.view_type && state.view_type !== this.active_view.type) {
+ stateChanged = true;
+ }
+ if (stateChanged) {
+ this.switch_mode(state.view_type);
+ }
+ },
+ /**
+ * Fetches and executes the action identified by ``action_data``.
+ *
+ * @param {Object} action_data the action descriptor data
+ * @param {String} action_data.name the action name, used to uniquely identify the action to find and execute it
+ * @param {String} [action_data.special=null] special action handlers (currently: only ``'cancel'``)
+ * @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
+ * @param {Object} [action_data.context=null] additional action context, to add to the current context
+ * @param {DataSet} dataset a dataset object used to communicate with the server
+ * @param {Object} [record_id] the identifier of the object on which the action is to be applied
+ * @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
+ */
+ do_execute_action: function (action_data, model, record_id, on_closed) {
+ var self = this;
+ var result_handler = on_closed || function () {};
+ var context = new data.CompoundContext(this.env.context, action_data.context || {});
+
+ // response handler
+ var handler = function (action) {
+ if (action && action.constructor === Object) {
+ // filter out context keys that are specific to the current action.
+ // Wrong default_* and search_default_* values will no give the expected result
+ // Wrong group_by values will simply fail and forbid rendering of the destination view
+ var ncontext = new data.CompoundContext(
+ _.object(_.reject(_.pairs(self.env.context), function(pair) {
+ return pair[0].match('^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null;
+ }))
+ );
+ ncontext.add(action_data.context || {});
+ ncontext.add({active_model: self.env.modelName});
+ if (record_id) {
+ ncontext.add({
+ active_id: record_id,
+ active_ids: [record_id],
+ });
+ }
+ ncontext.add(action.context || {});
+ action.context = ncontext;
+ return self.do_action(action, {
+ on_close: result_handler,
+ });
+ } else {
+ self.do_action({"type":"ir.actions.act_window_close"});
+ return result_handler();
+ }
+ };
+
+ if (action_data.special === 'cancel') {
+ return handler({"type":"ir.actions.act_window_close"});
+ } else if (action_data.type === "object") {
+ var args = record_id ? [[record_id]] : [this.env.ids];
+ if (action_data.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 additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
+ args = args.concat(additional_args);
+ } catch(e) {
+ console.error("Could not JSON.parse arguments", action_data.args);
+ }
+ }
+ args.push(context);
+ var dataset = new data.DataSet(this, model, this.env.context);
+ return dataset.call_button(action_data.name, args).then(handler);
+ } else if (action_data.type === "action") {
+ return data_manager.load_action(action_data.name, _.extend(pyeval.eval('context', context), {
+ active_model: this.env.modelName,
+ active_ids: this.env.ids,
+ active_id: record_id
+ })).then(handler);
+ }
+ },
+ destroy: function () {
+ if (this.control_elements) {
+ if (this.control_elements.$switch_buttons) {
+ this.control_elements.$switch_buttons.off();
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+return ViewManager;
+
+});
diff --git a/addons/web/static/src/js/web_client.js b/addons/web/static/src/js/chrome/web_client.js
index f08947d092b..17993c73638 100644
--- a/addons/web/static/src/js/web_client.js
+++ b/addons/web/static/src/js/chrome/web_client.js
@@ -3,7 +3,6 @@ odoo.define('web.WebClient', function (require) {
var AbstractWebClient = require('web.AbstractWebClient');
var core = require('web.core');
-var data = require('web.data');
var data_manager = require('web.data_manager');
var framework = require('web.framework');
var Menu = require('web.Menu');
diff --git a/addons/web/static/src/js/compatibility.js b/addons/web/static/src/js/compatibility.js
deleted file mode 100644
index d6a97b76f0e..00000000000
--- a/addons/web/static/src/js/compatibility.js
+++ /dev/null
@@ -1,288 +0,0 @@
-// ------------------------------------------------------------------------------
-// Compatibility with Odoo v8.
-//
-// With the new module system, no global variable can (and should) be accessed
-// in openerp. This file exports everything, to mimic the previous global
-// namespace structure. This is only supposed to be used by 3rd parties to
-// facilitate migration. Odoo addons should not use the 'openerp' variable at
-// all.
-// ------------------------------------------------------------------------------
-odoo.define('web.compatibility', function (require) {
-"use strict";
-
-var ActionManager = require('web.ActionManager');
-var core = require('web.core');
-var data = require('web.data');
-var Dialog = require('web.Dialog');
-var FavoriteMenu = require('web.FavoriteMenu');
-var form_common = require('web.form_common');
-var formats = require('web.formats');
-var FormView = require('web.FormView');
-var form_relational = require('web.form_relational'); // necessary
-var form_widgets = require('web.form_widgets'); // necessary
-var framework = require('web.framework');
-var ListView = require('web.ListView');
-var Model = require('web.DataModel');
-var pyeval = require('web.pyeval');
-var Registry = require('web.Registry');
-var SearchView = require('web.SearchView');
-var session = require('web.session');
-var Sidebar = require('web.Sidebar');
-var SystrayMenu = require('web.SystrayMenu');
-var time = require('web.time');
-var UserMenu = require('web.UserMenu');
-var utils = require('web.utils');
-var View = require('web.View');
-var ViewManager = require('web.ViewManager');
-var WebClient = require('web.WebClient');
-var Widget = require('web.Widget');
-
-var client_started = $.Deferred();
-
-var OldRegistry = Registry.extend({
- add: function (key, path) {
- },
- get_object: function (key) {
- return get_object(this.map[key]);
- },
-});
-
-window.openerp = window.openerp || {};
-
-$.Mutex = utils.Mutex;
-openerp._session_id = "instance0";
-openerp._t = core._t;
-openerp.get_cookie = utils.get_cookie;
-
-openerp.qweb = core.qweb;
-openerp.session = session;
-
-openerp.web = openerp.web || {};
-openerp.web._t = core._t;
-openerp.web._lt = core._lt;
-
-openerp.web.ActionManager = ActionManager;
-openerp.web.auto_str_to_date = time.auto_str_to_date;
-openerp.web.blockUI = framework.blockUI;
-openerp.web.BufferedDataSet = data.BufferedDataSet;
-openerp.web.bus = core.bus;
-openerp.web.Class = core.Class;
-openerp.web.client_actions = make_old_registry(core.action_registry);
-openerp.web.CompoundContext = data.CompoundContext;
-openerp.web.CompoundDomain = data.CompoundDomain;
-openerp.web.DataSetSearch = data.DataSetSearch;
-openerp.web.DataSet = data.DataSet;
-openerp.web.date_to_str = time.date_to_str;
-openerp.web.Dialog = Dialog;
-openerp.web.DropMisordered = utils.DropMisordered;
-
-openerp.web.form = openerp.web.form || {};
-openerp.web.form.AbstractField = form_common.AbstractField;
-openerp.web.form.compute_domain = data.compute_domain;
-openerp.web.form.DefaultFieldManager = form_common.DefaultFieldManager;
-openerp.web.form.FieldChar = core.form_widget_registry.get('char');
-openerp.web.form.FieldFloat = core.form_widget_registry.get('float');
-openerp.web.form.FieldStatus = core.form_widget_registry.get('statusbar');
-openerp.web.form.FieldMany2ManyTags = core.form_widget_registry.get('many2many_tags');
-openerp.web.form.FieldMany2One = core.form_widget_registry.get('many2one');
-openerp.web.form.FormWidget = form_common.FormWidget;
-openerp.web.form.tags = make_old_registry(core.form_tag_registry);
-openerp.web.form.widgets = make_old_registry(core.form_widget_registry);
-openerp.web.form.custom_widgets = make_old_registry(core.form_custom_registry);
-
-openerp.web.format_value = formats.format_value;
-openerp.web.FormView = FormView;
-
-openerp.web.json_node_to_xml = utils.json_node_to_xml;
-
-openerp.web.ListView = ListView;
-openerp.web.Model = Model;
-openerp.web.normalize_format = time.strftime_to_moment_format;
-openerp.web.py_eval = pyeval.py_eval;
-openerp.web.pyeval = pyeval;
-openerp.web.qweb = core.qweb;
-
-openerp.web.Registry = OldRegistry;
-
-openerp.web.search = {};
-openerp.web.search.FavoriteMenu = FavoriteMenu;
-openerp.web.SearchView = SearchView;
-openerp.web.Sidebar = Sidebar;
-openerp.web.str_to_date = time.str_to_date;
-openerp.web.str_to_datetime = time.str_to_datetime;
-openerp.web.SystrayItems = SystrayMenu.Items;
-openerp.web.unblockUI = framework.unblockUI;
-openerp.web.UserMenu = UserMenu;
-openerp.web.View = View;
-openerp.web.ViewManager = ViewManager;
-openerp.web.views = make_old_registry(core.view_registry);
-openerp.web.WebClient = WebClient;
-openerp.web.Widget = Widget;
-
-openerp.Widget = openerp.web.Widget;
-openerp.Widget.prototype.session = session;
-
-
-WebClient.include({
- init: function () {
- openerp.client = this;
- openerp.webclient = this;
- start_modules();
- client_started.resolve();
- this._super.apply(this, arguments);
- },
-});
-
-
-function make_old_registry(registry) {
- return {
- add: function (key, path) {
- client_started.done(function () {
- registry.add(key, get_object(path));
- });
- },
- };
-}
-function get_object(path) {
- var object_match = openerp;
- path = path.split('.');
- // ignore first section
- for(var i=1; i<path.length; ++i) {
- object_match = object_match[path[i]];
- }
- return object_match;
-}
-
-/**
- * OpenERP instance constructor
- *
- * @param {Array|String} modules list of modules to initialize
- */
-var inited = false;
-function start_modules (modules) {
- if (modules === undefined) {
- modules = odoo._modules;
- }
- modules = _.without(modules, "web");
- if (inited) {
- throw new Error("OpenERP was already inited");
- }
- inited = true;
- for(var i=0; i < modules.length; i++) {
- var fct = openerp[modules[i]];
- if (typeof(fct) === "function") {
- openerp[modules[i]] = {};
- for (var k in fct) {
- openerp[modules[i]][k] = fct[k];
- }
- fct(openerp, openerp[modules[i]]);
- }
- }
- openerp._modules = ['web'].concat(modules);
- return openerp;
-};
-
-// Monkey-patching of the ListView for backward compatibiliy of the colors and
-// fonts row's attributes, as they are deprecated in 9.0.
-ListView.include({
- willStart: function() {
- if (this.fields_view.arch.attrs.colors) {
- this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain()
- .compact()
- .map(function(color_pair) {
- var pair = color_pair.split(':'),
- color = pair[0],
- expr = pair[1];
- return [color, py.parse(py.tokenize(expr)), expr];
- }).value();
- }
-
- if (this.fields_view.arch.attrs.fonts) {
- this.fonts = _(this.fields_view.arch.attrs.fonts.split(';')).chain().compact()
- .map(function(font_pair) {
- var pair = font_pair.split(':'),
- font = pair[0],
- expr = pair[1];
- return [font, py.parse(py.tokenize(expr)), expr];
- }).value();
- }
-
- return this._super();
- },
- /**
- * Returns the style for the provided record in the current view (from the
- * ``@colors`` and ``@fonts`` attributes)
- *
- * @param {Record} record record for the current row
- * @returns {String} CSS style declaration
- */
- style_for: function (record) {
- var len, style= '';
-
- var context = _.extend({}, record.attributes, {
- uid: session.uid,
- current_date: moment().format('YYYY-MM-DD')
- // TODO: time, datetime, relativedelta
- });
- var i;
- var pair;
- var expression;
- if (this.fonts) {
- for(i=0, len=this.fonts.length; i<len; ++i) {
- pair = this.fonts[i];
- var font = pair[0];
- expression = pair[1];
- if (py.PY_isTrue(py.evaluate(expression, context))) {
- switch(font) {
- case 'bold':
- style += 'font-weight: bold;';
- break;
- case 'italic':
- style += 'font-style: italic;';
- break;
- case 'underline':
- style += 'text-decoration: underline;';
- break;
- }
- }
- }
- }
-
- if (!this.colors) { return style; }
- for(i=0, len=this.colors.length; i<len; ++i) {
- pair = this.colors[i];
- var color = pair[0];
- expression = pair[1];
- if (py.PY_isTrue(py.evaluate(expression, context))) {
- return style += 'color: ' + color + ';';
- }
- }
- return style;
- },
-});
-
-// IE patch
-//-------------------------------------------------------------------------
-if (typeof(console) === "undefined") {
- // Even IE9 only exposes console object if debug window opened
- window.console = {};
- ('log error debug info warn assert clear dir dirxml trace group'
- + ' groupCollapsed groupEnd time timeEnd profile profileEnd count'
- + ' exception').split(/\s+/).forEach(function(property) {
- console[property] = _.identity;
- });
-}
-
-/**
- Some hack to make placeholders work in ie9.
-*/
-if (!('placeholder' in document.createElement('input'))) {
- document.addEventListener("DOMNodeInserted",function(event){
- var nodename = event.target.nodeName.toLowerCase();
- if ( nodename === "input" || nodename === "textarea" ) {
- $(event.target).placeholder();
- }
- });
-}
-
-});
diff --git a/addons/web/static/src/js/core/abstract_service.js b/addons/web/static/src/js/core/abstract_service.js
new file mode 100644
index 00000000000..6ae0c3183da
--- /dev/null
+++ b/addons/web/static/src/js/core/abstract_service.js
@@ -0,0 +1,20 @@
+odoo.define('web.AbstractService', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+var AbstractService = Class.extend({
+ Services: [],
+ name: null,
+});
+
+var realExtend = AbstractService.extend;
+
+AbstractService.extend = function() {
+ var Service = realExtend.apply(this, arguments);
+ AbstractService.prototype.Services.push(Service);
+ return Service;
+};
+
+return AbstractService;
+});
diff --git a/addons/web/static/src/js/framework/ajax.js b/addons/web/static/src/js/core/ajax.js
index dee6f4599b4..dee6f4599b4 100644
--- a/addons/web/static/src/js/framework/ajax.js
+++ b/addons/web/static/src/js/core/ajax.js
diff --git a/addons/web/static/src/js/framework/bus.js b/addons/web/static/src/js/core/bus.js
index bf33c1fe01e..bf33c1fe01e 100644
--- a/addons/web/static/src/js/framework/bus.js
+++ b/addons/web/static/src/js/core/bus.js
diff --git a/addons/web/static/src/js/framework/class.js b/addons/web/static/src/js/core/class.js
index 8e53f0ba06d..8e53f0ba06d 100644
--- a/addons/web/static/src/js/framework/class.js
+++ b/addons/web/static/src/js/core/class.js
diff --git a/addons/web/static/src/js/core/collections.js b/addons/web/static/src/js/core/collections.js
new file mode 100644
index 00000000000..bc27a9021cc
--- /dev/null
+++ b/addons/web/static/src/js/core/collections.js
@@ -0,0 +1,44 @@
+odoo.define("web.collections", function (require) {
+ "use strict";
+
+ var Class = require("web.Class");
+
+ /**
+ * Allows to build a tree representation of a data.
+ */
+ var Tree = Class.extend({
+ /**
+ * @constructor
+ * @param {*} data - the data associated to the root node
+ */
+ init: function (data) {
+ this._data = data;
+ this._children = [];
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Returns the root's associated data.
+ *
+ * @returns {*}
+ */
+ getData: function () {
+ return this._data;
+ },
+ /**
+ * Adds a child tree.
+ *
+ * @param {Tree} tree
+ */
+ addChild: function (tree) {
+ this._children.push(tree);
+ },
+ });
+
+ return {
+ Tree: Tree,
+ };
+});
diff --git a/addons/web/static/src/js/core/concurrency.js b/addons/web/static/src/js/core/concurrency.js
new file mode 100644
index 00000000000..cab9df6529e
--- /dev/null
+++ b/addons/web/static/src/js/core/concurrency.js
@@ -0,0 +1,237 @@
+odoo.define('web.concurrency', function (require) {
+"use strict";
+
+/**
+ * Concurrency Utils
+ *
+ * This file contains a short collection of useful helpers designed to help with
+ * everything concurrency related in Odoo.
+ *
+ * The basic concurrency primitives in Odoo JS are the callback, and the
+ * promises. Promises (deferred) are more composable, so we usually use them
+ * whenever possible. We use the jQuery implementation.
+ *
+ * Those functions are really nothing special, but are simply the result of how
+ * we solved some concurrency issues, when we noticed that a pattern emerged.
+ */
+
+var Class = require('web.Class');
+
+return {
+ /**
+ * The jquery implementation for $.when has a (most of the time) useful
+ * property: it is synchronous, if the deferred is resolved immediately.
+ *
+ * This means that when we execute $.when(def), then all registered
+ * callbacks will be executed before the next line is executed. This is
+ * useful quite often, but in some rare cases, we might want to force an
+ * async behavior. This is the purpose of this function, which simply adds a
+ * setTimeout before resolving the deferred.
+ *
+ * @returns {Deferred}
+ */
+ asyncWhen: function () {
+ var async = false;
+ var def = $.Deferred();
+ $.when.apply($, arguments).done(function() {
+ var args = arguments;
+ var action = function() {
+ def.resolve.apply(def, args);
+ };
+ if (async) {
+ action();
+ } else {
+ setTimeout(action, 0);
+ }
+ }).fail(function() {
+ var args = arguments;
+ var action = function() {
+ def.reject.apply(def, args);
+ };
+ if (async) {
+ action();
+ } else {
+ setTimeout(action, 0);
+ }
+ });
+ async = true;
+ return def;
+ },
+ /**
+ * Returns a deferred resolved after 'wait' milliseconds
+ *
+ * @param {int} [wait=0] the delay in ms
+ * @return {Deferred}
+ */
+ delay: function (wait) {
+ var def = $.Deferred();
+ setTimeout(def.resolve, wait);
+ return def;
+ },
+ /**
+ * The DropMisordered abstraction is useful for situations where you have
+ * a sequence of operations that you want to do, but if one of them
+ * completes after a subsequent operation, then its result is obsolete and
+ * should be ignored.
+ *
+ * Note that is is kind of similar to the DropPrevious abstraction, but
+ * subtly different. The DropMisordered operations will all resolves if
+ * they complete in the correct order.
+ */
+ DropMisordered: Class.extend({
+ /**
+ * @constructor
+ *
+ * @param {boolean} [failMisordered=false] whether mis-ordered responses
+ * should be failed or just ignored
+ */
+ init: function (failMisordered) {
+ // local sequence number, for requests sent
+ this.lsn = 0;
+ // remote sequence number, seqnum of last received request
+ this.rsn = -1;
+ this.failMisordered = failMisordered || false;
+ },
+ /**
+ * Adds a deferred (usually an async request) to the sequencer
+ *
+ * @param {Deferred} deferred to ensure add
+ * @returns {Deferred}
+ */
+ add: function (deferred) {
+ var res = $.Deferred();
+
+ var self = this, seq = this.lsn++;
+ deferred.done(function () {
+ if (seq > self.rsn) {
+ self.rsn = seq;
+ res.resolve.apply(res, arguments);
+ } else if (self.failMisordered) {
+ res.reject();
+ }
+ }).fail(function () {
+ res.reject.apply(res, arguments);
+ });
+
+ return res.promise();
+ },
+ }),
+ /**
+ * The DropPrevious abstraction is useful when you have a sequence of
+ * operations that you want to execute, but you only care of the result of
+ * the last operation.
+ *
+ * For example, let us say that we have a _fetch method on a widget which
+ * fetches data. We want to rerender the widget after. We could do this:
+ *
+ * this._fetch().then(function (result) {
+ * self.state = result;
+ * self.render();
+ * });
+ *
+ * Now, we have at least two problems:
+ * - if this code is called twice and the second _fetch completes before the
+ * first, the end state will be the result of the first _fetch, which is
+ * not what we expect
+ * - in any cases, the user interface will rerender twice, which is bad.
+ *
+ * Now, if we have a DropPrevious:
+ *
+ * this.dropPrevious = new DropPrevious();
+ *
+ * Then we can wrap the _fetch in a DropPrevious and have the expected
+ * result:
+ *
+ * this.dropPrevious
+ * .add(this._fetch())
+ * .then(function (result) {
+ * self.state = result;
+ * self.render();
+ * });
+ */
+ DropPrevious: Class.extend({
+ /**
+ * Registers a new deferred and rejects the previous one
+ *
+ * @param {Deferred} the new deferred
+ * @returns {Promise}
+ */
+ add: function (deferred) {
+ if (this.current_def) { this.current_def.reject(); }
+ var res = $.Deferred();
+ deferred.then(res.resolve, res.reject);
+ this.current_def = res;
+ return res.promise();
+ }
+ }),
+ /**
+ * A (Odoo) mutex is a primitive for serializing computations. This is
+ * useful to avoid a situation where two computations modify some shared
+ * state and cause some corrupted state.
+ *
+ * Imagine that we have a function to fetch some data _load(), which returns
+ * a deferred which resolves to something useful. Now, we have some code
+ * looking like this:
+ *
+ * return this._load().then(function (result) {
+ * this.state = result;
+ * });
+ *
+ * If this code is run twice, but the second execution ends before the
+ * first, then the final state will be the result of the first call to
+ * _load. However, if we have a mutex:
+ *
+ * this.mutex = new Mutex();
+ *
+ * and if we wrap the calls to _load in a mutex:
+ *
+ * return this.mutex.exec(function() {
+ * return this._load().then(function (result) {
+ * this.state = result;
+ * });
+ * });
+ *
+ * Then, it is guaranteed that the final state will be the result of the
+ * second execution.
+ *
+ * A Mutex has to be a class, and not a function, because we have to keep
+ * track of some internal state.
+ */
+ Mutex: Class.extend({
+ init: function () {
+ this.def = $.Deferred().resolve();
+ },
+ /**
+ * Add a computation to the queue, it will be executed as soon as the
+ * previous computations are completed.
+ *
+ * @param {function} action a function which may return a deferred
+ * @returns {Deferred}
+ */
+ exec: function (action) {
+ var current = this.def;
+ var next = this.def = $.Deferred();
+ return current.then(function() {
+ return $.when(action()).always(function() {
+ next.resolve();
+ });
+ });
+ }
+ }),
+ /**
+ * Rejects a deferred as soon as a reference deferred is either resolved or
+ * rejected
+ *
+ * @param {Deferred} [target_def] the deferred to potentially reject
+ * @param {Deferred} [reference_def] the reference target
+ * @returns {Deferred}
+ */
+ rejectAfter: function (target_def, reference_def) {
+ var res = $.Deferred();
+ target_def.then(res.resolve, res.reject);
+ reference_def.always(res.reject);
+ return res.promise();
+ }
+};
+
+});
diff --git a/addons/web/static/src/js/framework/dialog.js b/addons/web/static/src/js/core/dialog.js
index 6dc65b63281..6214c99483e 100644
--- a/addons/web/static/src/js/framework/dialog.js
+++ b/addons/web/static/src/js/core/dialog.js
@@ -81,7 +81,15 @@ var Dialog = Widget.extend({
var text = b.text || "";
var classes = b.classes || ((buttons.length === 1)? "btn-primary" : "btn-default");
- var $b = $(QWeb.render('WidgetButton', { widget : { string: text, node: { attrs: {'class': classes, icon: b.icon} }, fa_icon: true }}));
+ var $b = $(QWeb.render('WidgetButton', {
+ widget: {
+ string: text,
+ node: {
+ attrs: {'class': classes, icon: b.icon}
+ },
+ fa_icon: true
+ }
+ }));
$b.prop('disabled', b.disabled);
$b.on('click', function(e) {
var click_def;
diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js
new file mode 100644
index 00000000000..4333061f924
--- /dev/null
+++ b/addons/web/static/src/js/core/dom.js
@@ -0,0 +1,164 @@
+odoo.define('web.dom', function (require) {
+"use strict";
+
+/**
+ * DOM Utility helpers
+ *
+ * We collect in this file some helpers to help integrate various DOM
+ * functionalities with the odoo framework. A common theme in these functions
+ * is the use of the main core.bus, which helps the framework react when
+ * something happens in the DOM.
+ */
+
+var core = require('web.core');
+
+/**
+ * Private function to notify that something has been attached in the DOM
+ * @param {htmlString or Element or Array or jQuery} [content] the content that
+ * has been attached in the DOM
+ * @params {Array} [callbacks] array of {widget: w, callback_args: args} such
+ * that on_attach_callback() will be called on each w with arguments args
+ */
+function _notify (content, callbacks) {
+ _.each(callbacks, function(c) {
+ if (c.widget && c.widget.on_attach_callback) {
+ c.widget.on_attach_callback(c.callback_args);
+ }
+ });
+ core.bus.trigger('DOM_updated', content);
+}
+
+return {
+ /**
+ * Appends content in a jQuery object and optionnally triggers an event
+ *
+ * @param {jQuery} [$target] the node where content will be appended
+ * @param {htmlString or Element or Array or jQuery} [content] DOM element,
+ * array of elements, HTML string or jQuery object to append to $target
+ * @param {Boolean} [options.in_DOM] true if $target is in the DOM
+ * @param {Array} [options.callbacks] array of objects describing the
+ * callbacks to perform (see _notify for a complete description)
+ */
+ append: function ($target, content, options) {
+ $target.append(content);
+ if (options && options.in_DOM) {
+ _notify(content, options.callbacks);
+ }
+ },
+ /**
+ * Autoresize a $textarea node, by recomputing its height when necessary
+ * @param {number} [options.min_height] by default, 50.
+ * @param {Widget} [options.parent] if set, autoresize will listen to some
+ * extra events to decide when to resize itself. This is useful for
+ * widgets that are not in the dom when the autoresize is declared.
+ */
+ autoresize: function ($textarea, options) {
+ if ($textarea.data("auto_resize")) {
+ return;
+ }
+ function resize () {
+ var heightOffset;
+ var style = window.getComputedStyle($textarea[0], null);
+ if (style.boxSizing === 'content-box') {
+ heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom));
+ } else {
+ heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
+ }
+ $fixed_text_area.width($textarea.width());
+ $fixed_text_area.val($textarea.val());
+ var height = $fixed_text_area[0].scrollHeight;
+ $textarea.css({height: Math.max(height + heightOffset, min_height)});
+ }
+
+ options = options || {};
+
+ var $fixed_text_area;
+ var min_height = (options && options.min_height) || 50;
+ if (!$fixed_text_area) {
+ $fixed_text_area = $('<textarea disabled>').css({
+ position: 'absolute',
+ opacity: 0,
+ height: 10,
+ top: -10000,
+ left: -10000,
+ });
+ $fixed_text_area.addClass($textarea[0].className);
+ $fixed_text_area.insertAfter($textarea);
+ $fixed_text_area.data("auto_resize", true);
+ }
+
+ var style = window.getComputedStyle($textarea[0], null);
+ if (style.resize === 'vertical') {
+ $textarea[0].style.resize = 'none';
+ } else if (style.resize === 'both') {
+ $textarea[0].style.resize = 'horizontal';
+ }
+ resize();
+ $textarea.data("auto_resize", true);
+
+ $textarea.on('input focus', resize);
+ if (options.parent) {
+ core.bus.on('DOM_updated', options.parent, resize);
+ core.bus.on('view_shown', options.parent, resize);
+ }
+
+ },
+ /**
+ * Detaches widgets from the DOM and performs their on_detach_callback()
+ *
+ * @param {Array} [to_detach] array of {widget: w, callback_args: args} such
+ * that w.$el will be detached and w.on_detach_callback(args) will be
+ * called
+ * @param {jQuery} [options.$to_detach] if given, detached instead of
+ * widgets' $el
+ * @return {jQuery} the detached elements
+ */
+ detach: function (to_detach, options) {
+ _.each(to_detach, function(d) {
+ if (d.widget.on_detach_callback) {
+ d.widget.on_detach_callback(d.callback_args);
+ }
+ });
+ var $to_detach = options && options.$to_detach;
+ if (!$to_detach) {
+ $to_detach = $(_.map(to_detach, function(d) {
+ return d.widget.el;
+ }));
+ }
+ return $to_detach.detach();
+ },
+ /**
+ * Returns the distance between a DOM element and the top-left corner of the
+ * window
+ *
+ * @param {element} [e] the DOM element
+ * @return {Object} the left and top distances in pixels
+ */
+ getPosition: function (e) {
+ var position = {left: 0, top: 0};
+ while (e) {
+ position.left += e.offsetLeft;
+ position.top += e.offsetTop;
+ e = e.offsetParent;
+ }
+ return position;
+ },
+ /**
+ * Prepends content in a jQuery object and optionnally triggers an event
+ *
+ * @param {jQuery} [$target] the node where content will be prepended
+ * @param {htmlString or Element or Array or jQuery} [content] DOM element,
+ * array of elements, HTML string or jQuery object to prepend to $target
+ * @param {Boolean} [options.in_DOM] true if $target is in the DOM
+ * @param {Array} [options.callbacks] array of objects describing the
+ * callbacks to perform (see _notify for a complete description)
+ */
+ prepend: function ($target, content, options) {
+ $target.prepend(content);
+ if (options && options.in_DOM) {
+ _notify(content, options.callbacks);
+ }
+ },
+};
+
+});
diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js
new file mode 100644
index 00000000000..11d6a59ec33
--- /dev/null
+++ b/addons/web/static/src/js/core/domain.js
@@ -0,0 +1,225 @@
+odoo.define("web.Domain", function (require) {
+"use strict";
+
+var collections = require("web.collections");
+var pyeval = require("web.pyeval");
+
+/**
+ * The Domain Class allows to work with a domain as a tree and provides tools
+ * to manipulate array and string representations of domains.
+ */
+var Domain = collections.Tree.extend({
+ /**
+ * @constructor
+ * @param {string|Array|boolean|undefined} domain
+ * - The given domain can be:
+ * * a string representation of the Python prefix-array
+ * representation of the domain.
+ * * a JS prefix-array representation of the domain.
+ * * a boolean where the "true" domain match all records and the
+ * "false" domain does not match any records.
+ * * undefined, considered as the false boolean.
+ */
+ init: function (domain) {
+ this._super.apply(this, arguments);
+ if (domain === true || domain === false || domain === undefined) {
+ this._data = domain || false;
+ } else {
+ this._parse(this.normalizeArray(_.clone(this.stringToArray(domain))));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Evaluates the domain with a set of values.
+ *
+ * @param {Object} values - a mapping {fieldName -> fieldValue} (note: all
+ * the fields used in the domain should be given a
+ * value otherwise the computation will break)
+ * @returns {boolean}
+ */
+ compute: function (values) {
+ if (this._data === true || this._data === false) {
+ // The domain is a always-true or a always-false domain
+ return this._data;
+ } else if (_.isArray(this._data)) {
+ // The domain is a [name, operator, value] entity
+ // First check if we have the field value in the field values set
+ if (!(this._data[0] in values)) {
+ throw new Error(_.str.sprintf(
+ "Unknown field %s in domain",
+ this._data[0]
+ ));
+ }
+ var fieldValue = values[this._data[0]];
+
+ switch (this._data[1]) {
+ case "=":
+ case "==":
+ return _.isEqual(fieldValue, this._data[2]);
+ case "!=":
+ case "<>":
+ return !_.isEqual(fieldValue, this._data[2]);
+ case "<":
+ return (fieldValue < this._data[2]);
+ case ">":
+ return (fieldValue > this._data[2]);
+ case "<=":
+ return (fieldValue <= this._data[2]);
+ case ">=":
+ return (fieldValue >= this._data[2]);
+ case "in":
+ return _.contains(
+ _.isArray(this._data[2]) ? this._data[2] : [this._data[2]],
+ fieldValue
+ );
+ case "not in":
+ return !_.contains(
+ _.isArray(this._data[2]) ? this._data[2] : [this._data[2]],
+ fieldValue
+ );
+ case "like":
+ return (fieldValue.toLowerCase().indexOf(this._data[2].toLowerCase()) >= 0);
+ case "ilike":
+ return (fieldValue.indexOf(this._data[2]) >= 0);
+ default:
+ throw new Error(_.str.sprintf(
+ "Domain %s uses an unsupported operator",
+ this._data
+ ));
+ }
+ } else { // The domain is a set of [name, operator, value] entitie(s)
+ switch (this._data) {
+ case "&":
+ return _.every(this._children, function (child) {
+ return child.compute(values);
+ });
+ case "|":
+ return _.some(this._children, function (child) {
+ return child.compute(values);
+ });
+ case "!":
+ return !this._children[0].compute(values);
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes the tree representation of the domain according to its given
+ * JS prefix-array representation. Note: the given array is considered
+ * already normalized.
+ *
+ * @private
+ * @param {Array} domain - normalized JS prefix-array representation of
+ * the domain
+ */
+ _parse: function (domain) {
+ this._data = (domain.length === 0 ? true : domain[0]);
+ if (domain.length <= 1) return;
+
+ var expected = 1;
+ for (var i = 1 ; i < domain.length ; i++) {
+ if (domain[i] === "&" || domain[i] === "|") {
+ expected++;
+ } else if (domain[i] !== "!") {
+ expected--;
+ }
+
+ if (!expected) {
+ i++;
+ this._addSubdomain(domain.slice(1, i));
+ this._addSubdomain(domain.slice(i));
+ break;
+ }
+ }
+ },
+
+ /**
+ * Adds a domain as a child (e.g. if the current domain is ["|", A, B],
+ * using this method with a ["&", C, D] domain will result in a
+ * ["|", "|", A, B, "&", C, D]).
+ * Note: the internal tree representation is automatically simplified.
+ *
+ * @param {Array} domain - normalized JS prefix-array representation of a
+ * domain to add
+ */
+ _addSubdomain: function (domain) {
+ if (!domain.length) return;
+ var subdomain = new Domain(domain);
+
+ if (!subdomain._children.length || subdomain._data !== this._data) {
+ this._children.push(subdomain);
+ } else {
+ var self = this;
+ _.each(subdomain._children, function (childDomain) {
+ self._children.push(childDomain);
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Static
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts JS prefix-array representation of a domain to a string
+ * representation of the Python prefix-array representation of this domain.
+ *
+ * @static
+ * @param {Array|string} domain
+ * @returns {string}
+ */
+ arrayToString: function (domain) {
+ if (_.isString(domain)) return domain;
+ return JSON.stringify(domain || [])
+ .replace(/false/g, "False")
+ .replace(/true/g, "True");
+ },
+ /**
+ * Converts a string representation of the Python prefix-array
+ * representation of a domain to a JS prefix-array representation of this
+ * domain.
+ *
+ * @static
+ * @param {string|Array} domain
+ * @returns {Array}
+ */
+ stringToArray: function (domain) {
+ if (!_.isString(domain)) return domain;
+ return pyeval.eval("domain", domain || "[]");
+ },
+ /**
+ * Makes implicit "&" operators explicit in the given JS prefix-array
+ * representation of domain (e.g [A, B] -> ["&", A, B])
+ *
+ * @static
+ * @param {Array} domain - the JS prefix-array representation of the domain
+ * to normalize (! will be normalized in-place)
+ * @returns {Array} the normalized JS prefix-array representation of the
+ * given domain
+ */
+ normalizeArray: function (domain) {
+ var expected = 1;
+ _.each(domain, function (item) {
+ if (item === "&" || item === "|") {
+ expected++;
+ } else if (item !== "!") {
+ expected--;
+ }
+ });
+ if (expected < 0) {
+ domain.unshift.apply(domain, _.times(Math.abs(expected), _.constant("&")));
+ }
+ return domain;
+ },
+});
+
+return Domain;
+});
diff --git a/addons/web/static/src/js/framework/local_storage.js b/addons/web/static/src/js/core/local_storage.js
index 6cf82e87752..6cf82e87752 100644
--- a/addons/web/static/src/js/framework/local_storage.js
+++ b/addons/web/static/src/js/core/local_storage.js
diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js
new file mode 100644
index 00000000000..f65276551cf
--- /dev/null
+++ b/addons/web/static/src/js/core/misc.js
@@ -0,0 +1,182 @@
+odoo.define('web.framework', function (require) {
+"use strict";
+
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var ajax = require('web.ajax');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var messages_by_seconds = function() {
+ return [
+ [0, _t("Loading...")],
+ [20, _t("Still loading...")],
+ [60, _t("Still loading...<br />Please be patient.")],
+ [120, _t("Don't leave yet,<br />it's still loading...")],
+ [300, _t("You may not believe it,<br />but the application is actually loading...")],
+ [420, _t("Take a minute to get a coffee,<br />because it's loading...")],
+ [3600, _t("Maybe you should consider reloading the application by pressing F5...")]
+ ];
+};
+
+var Throbber = Widget.extend({
+ template: "Throbber",
+ start: function() {
+ this.start_time = new Date().getTime();
+ this.act_message();
+ },
+ act_message: function() {
+ var self = this;
+ setTimeout(function() {
+ if (self.isDestroyed())
+ return;
+ var seconds = (new Date().getTime() - self.start_time) / 1000;
+ var mes;
+ _.each(messages_by_seconds(), function(el) {
+ if (seconds >= el[0])
+ mes = el[1];
+ });
+ self.$(".oe_throbber_message").html(mes);
+ self.act_message();
+ }, 1000);
+ },
+});
+
+/** Setup blockui */
+if ($.blockUI) {
+ $.blockUI.defaults.baseZ = 1100;
+ $.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
+ $.blockUI.defaults.css.border = '0';
+ $.blockUI.defaults.css["background-color"] = '';
+}
+
+
+var throbbers = [];
+
+function blockUI () {
+ var tmp = $.blockUI.apply($, arguments);
+ var throbber = new Throbber();
+ throbbers.push(throbber);
+ throbber.appendTo($(".oe_blockui_spin_container"));
+ $('body').addClass('o_ui_blocked');
+ return tmp;
+}
+
+function unblockUI () {
+ _.invoke(throbbers, 'destroy');
+ throbbers = [];
+ $('body').removeClass('o_ui_blocked');
+ return $.unblockUI.apply($, arguments);
+}
+
+/**
+ * Redirect to url by replacing window.location
+ * If wait is true, sleep 1s and wait for the server i.e. after a restart.
+ */
+function redirect (url, wait) {
+ // Dont display a dialog if some xmlhttprequest are in progress
+ crash_manager.disable();
+
+ var load = function() {
+ var old = "" + window.location;
+ var old_no_hash = old.split("#")[0];
+ var url_no_hash = url.split("#")[0];
+ location.assign(url);
+ if (old_no_hash === url_no_hash) {
+ location.reload(true);
+ }
+ };
+
+ var wait_server = function() {
+ ajax.rpc("/web/webclient/version_info", {}).done(load).fail(function() {
+ setTimeout(wait_server, 250);
+ });
+ };
+
+ if (wait) {
+ setTimeout(wait_server, 1000);
+ } else {
+ load();
+ }
+}
+
+// * Client action to reload the whole interface.
+// * If params.menu_id, it opens the given menu entry.
+// * If params.wait, reload will wait the openerp server to be reachable before reloading
+
+function Reload(parent, action) {
+ var params = action.params || {};
+ var menu_id = params.menu_id || false;
+ var l = window.location;
+
+ var sobj = $.deparam(l.search.substr(1));
+ if (params.url_search) {
+ sobj = _.extend(sobj, params.url_search);
+ }
+ var search = '?' + $.param(sobj);
+
+ var hash = l.hash;
+ if (menu_id) {
+ hash = "#menu_id=" + menu_id;
+ }
+ var url = l.protocol + "//" + l.host + l.pathname + search + hash;
+
+ redirect(url, params.wait);
+}
+
+core.action_registry.add("reload", Reload);
+
+
+/**
+ * Client action to go back home.
+ */
+function Home (parent, action) {
+ var url = '/' + (window.location.search || '');
+ redirect(url, action && action.params && action.params.wait);
+}
+core.action_registry.add("home", Home);
+
+/**
+ * Client action to go back in breadcrumb history.
+ * If can't go back in history stack, will go back to home.
+ */
+function HistoryBack (parent) {
+ parent.history_back().fail(function () {
+ Home(parent);
+ });
+}
+core.action_registry.add("history_back", HistoryBack);
+
+function login() {
+ redirect('/web/login');
+}
+core.action_registry.add("login", login);
+
+function logout() {
+ redirect('/web/session/logout');
+ return $.Deferred();
+}
+core.action_registry.add("logout", logout);
+
+/**
+ * Client action to refresh the session context (making sure
+ * HTTP requests will have the right one) then reload the
+ * whole interface.
+ */
+function ReloadContext (parent, action) {
+ // side-effect of get_session_info is to refresh the session context
+ ajax.rpc("/web/session/get_session_info", {}).then(function() {
+ Reload(parent, action);
+ });
+}
+core.action_registry.add("reload_context", ReloadContext);
+
+
+return {
+ blockUI: blockUI,
+ unblockUI: unblockUI,
+ redirect: redirect,
+};
+
+});
diff --git a/addons/web/static/src/js/framework/mixins.js b/addons/web/static/src/js/core/mixins.js
index 05d4105e466..5caac9a2c8c 100644
--- a/addons/web/static/src/js/framework/mixins.js
+++ b/addons/web/static/src/js/core/mixins.js
@@ -3,6 +3,7 @@ odoo.define('web.mixins', function (require) {
var Class = require('web.Class');
var utils = require('web.utils');
+var AbstractService = require('web.AbstractService');
/**
* Mixin to structure objects' life-cycles folowing a parent-children
@@ -122,7 +123,7 @@ function OdooEvent (target, name, data) {
this.stopped = false;
}
-OdooEvent.prototype.stop_propagation = function () {
+OdooEvent.prototype.stopPropagation = function () {
this.stopped = true;
};
@@ -270,7 +271,7 @@ var EventDispatcherMixin = _.extend({}, ParentedMixin, {
},
once: function(events, dest, func) {
// similar to this.on(), but func is executed only once
- var self = this
+ var self = this;
if (typeof func !== "function") {
throw new Error("Event handler must be a function.");
}
@@ -358,10 +359,114 @@ var PropertiesMixin = _.extend({}, EventDispatcherMixin, {
}
});
+var ServicesMixin = {
+ call: function(service, method) {
+ var args = Array.prototype.slice.call(arguments, 2);
+ var result;
+ this.trigger_up('call_service', {
+ service: service,
+ method: method,
+ args: args,
+ callback: function(r) {
+ result = r;
+ },
+ });
+ return result;
+ },
+ // AJAX calls
+ performRPC: function(route, args) {
+ return this.call('ajax', 'rpc', route, args);
+ },
+ performModelRPC: function(model, method, args, kwargs) {
+ return this.performRPC('/web/dataset/call_kw/' + model + '/' + method, {
+ model: model,
+ method: method,
+ args: args || [],
+ kwargs: kwargs || {},
+ });
+ },
+ loadFieldView: function(dataset, view_id, view_type, options) {
+ return this.loadViews(dataset.model, dataset.get_context(), [[view_id, view_type]], options).then(function (result) {
+ return result[view_type];
+ });
+ },
+ loadViews: function(modelName, context, views, options) {
+ var def = $.Deferred();
+ this.trigger_up('load_views', {
+ modelName: modelName,
+ context: context,
+ views: views,
+ options: options,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ loadFilters: function(dataset, action_id) {
+ var def = $.Deferred();
+ this.trigger_up('load_filters', {
+ dataset: dataset,
+ action_id: action_id,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ // Session stuff
+ getSession: function() {
+ var session;
+ this.trigger_up('get_session', {
+ callback: function(result) {
+ session = result;
+ }
+ });
+ return session;
+ },
+ /**
+ * Informs the action manager to do an action. This supposes that
+ * the action manager can be found amongst the ancestors of the current widget.
+ * If that's not the case this method will simply return `false`.
+ */
+ do_action: function (action, options) {
+ var def = $.Deferred();
+ options = options || {};
+ var oldOnSuccess = options.on_success;
+ options.on_success = function(result) {
+ oldOnSuccess(result);
+ def.resolve(result);
+ };
+ this.trigger_up('do_action', {'action': action, options: options});
+ return def;
+ },
+ do_notify: function(title, message, sticky) {
+ this.trigger_up('notification', {title: title, message: message, sticky: sticky});
+ },
+ do_warn: function(title, message, sticky) {
+ this.trigger_up('warning', {title: title, message: message, sticky: sticky});
+ },
+};
+
+var ServiceProvider = {
+ services: {},
+ init: function() {
+ var self = this;
+ _.each(AbstractService.prototype.Services, function(Service) {
+ var service = new Service();
+ self.services[service.name] = service;
+ });
+ this.custom_events.call_service = this._call_service.bind(this);
+ },
+ _call_service: function(event) {
+ var service = this.services[event.data.service];
+ var result = service[event.data.method].apply(service, event.data.args);
+ event.data.callback(result);
+ },
+};
+
return {
ParentedMixin: ParentedMixin,
EventDispatcherMixin: EventDispatcherMixin,
PropertiesMixin: PropertiesMixin,
+ ServicesMixin: ServicesMixin,
+ ServiceProvider: ServiceProvider,
};
});
diff --git a/addons/web/static/src/js/framework/pyeval.js b/addons/web/static/src/js/core/pyeval.js
index bcab0b0717f..df56f60074a 100644
--- a/addons/web/static/src/js/framework/pyeval.js
+++ b/addons/web/static/src/js/core/pyeval.js
@@ -2,7 +2,6 @@ odoo.define('web.pyeval', function (require) {
"use strict";
var core = require('web.core');
-var session = require('web.session');
var utils = require('web.utils');
var _t = core._t;
@@ -944,14 +943,14 @@ function eval_groupbys (contexts, evaluation_context) {
function pycontext () {
- return _.extend({
+ return {
datetime: datetime,
context_today: context_today,
time: time,
relativedelta: relativedelta,
current_date: py.PY_call(
time.strftime, [py.str.fromJSON('%Y-%m-%d')]),
- }, session.user_context);
+ };
}
/**
@@ -959,17 +958,17 @@ function pycontext () {
* @param {Array} object domains or contexts to evaluate
* @param {Object} [context] evaluation context
*/
-function pyeval (type, object, context, options) {
- options = options || {};
+function pyeval (type, object, context) {
context = _.extend(pycontext(), context || {});
//noinspection FallthroughInSwitchStatementJS
switch(type) {
case 'context':
case 'contexts':
- if (type === 'context')
+ if (type === 'context') {
object = [object];
- return eval_contexts((options.no_user_context ? [] : [session.user_context]).concat(object), context);
+ }
+ return eval_contexts(object, context);
case 'domain':
case 'domains':
if (type === 'domain')
@@ -1013,34 +1012,11 @@ function ensure_evaluated (args, kwargs) {
}
function eval_domains_and_contexts (source) {
- return new $.Deferred(function (d) {setTimeout(function () {
- var result;
- try {
- result = sync_eval_domains_and_contexts(source);
- }
- catch (e) {
- result = { error: {
- code: 400,
- message: _t("Evaluation Error"),
- data: {
- type: 'local_exception',
- debug: _.str.sprintf(
- _t("Local evaluation failure\n%s\n\n%s"),
- e.message, JSON.stringify(source))
- }
- }};
- }
- d.resolve(result);
- }, 0); });
-}
-
-function sync_eval_domains_and_contexts (source) {
- var contexts = ([session.user_context] || []).concat(source.contexts);
// see Session.eval_context in Python
return {
- context: pyeval('contexts', contexts),
- domain: pyeval('domains', source.domains),
- group_by: pyeval('groupbys', source.group_by_seq || [])
+ 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),
};
}
@@ -1054,7 +1030,6 @@ return {
ensure_evaluated: ensure_evaluated,
eval: pyeval,
eval_domains_and_contexts: eval_domains_and_contexts,
- sync_eval_domains_and_contexts: sync_eval_domains_and_contexts,
py_eval: py_eval,
};
diff --git a/addons/web/static/src/js/framework/qweb.js b/addons/web/static/src/js/core/qweb.js
index 50742075e05..50742075e05 100644
--- a/addons/web/static/src/js/framework/qweb.js
+++ b/addons/web/static/src/js/core/qweb.js
diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js
new file mode 100644
index 00000000000..d70c09e4ae0
--- /dev/null
+++ b/addons/web/static/src/js/core/registry.js
@@ -0,0 +1,97 @@
+odoo.define('web.Registry', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+/**
+ * The registry is really pretty much only a mapping from some keys to some
+ * values. The Registry class only add a few simple methods around that to make
+ * it nicer and slightly safer.
+ *
+ * Note that registries have a fundamental problem: the value that you try to
+ * get in a registry might not have been added yet, so of course, you need to
+ * make sure that your dependencies are solid. For this reason, it is a good
+ * practice to avoid using the registry if you can simply import what you need
+ * with the 'require' statement.
+ *
+ * However, on the flip side, sometimes you cannot just simply import something
+ * because we would have a dependency cycle. In that case, registries might
+ * help.
+ */
+var Registry = Class.extend({
+ /**
+ * @constructor
+ * @param {Object} [mapping] the initial data in the registry
+ */
+ init: function (mapping) {
+ this.map = Object.create(mapping || null);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a key (and a value) to the registry.
+ *
+ * @param {string} key
+ * @param {any} value
+ * @returns {Registry} can be used to chain add calls.
+ */
+ add: function (key, value) {
+ this.map[key] = value;
+ return this;
+ },
+ /**
+ * Check if the registry contains the value
+ *
+ * @param {string} key
+ * @returns {boolean}
+ */
+ contains: function (key) {
+ return (key in this.map);
+ },
+ /**
+ * Creates and returns a copy of the current mapping, with the provided
+ * mapping argument added in (replacing existing keys if needed)
+ *
+ * Parent and child remain linked, a new key in the parent (which is not
+ * overwritten by the child) will appear in the child.
+ *
+ * @param {Object} [mapping={}] a mapping of keys to object-paths
+ */
+ extend: function (mapping) {
+ var child = new Registry(this.map);
+ _.extend(child.map, mapping);
+ return child;
+ },
+ /**
+ * Returns the value associated to the given key.
+ *
+ * @param {string} key
+ * @returns {any}
+ */
+ get: function (key) {
+ return this.map[key];
+ },
+ /**
+ * Tries a number of keys, and returns the first object matching one of
+ * the keys.
+ *
+ * @param {string[]} keys a sequence of keys to fetch the object for
+ * @returns {any} the first result found matching an object
+ */
+ getAny: function (keys) {
+ for (var i=0; i<keys.length; i++) {
+ if (keys[i] in this.map) {
+ return this.map[keys[i]];
+ }
+ }
+ return null;
+ },
+});
+
+return Registry;
+
+});
+
diff --git a/addons/web/static/src/js/framework/session.js b/addons/web/static/src/js/core/session.js
index 516436af93c..ca56eb62439 100644
--- a/addons/web/static/src/js/framework/session.js
+++ b/addons/web/static/src/js/core/session.js
@@ -2,6 +2,7 @@ odoo.define('web.Session', function (require) {
"use strict";
var ajax = require('web.ajax');
+var concurrency = require('web.concurrency');
var core = require('web.core');
var utils = require('web.utils');
@@ -44,7 +45,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
// 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 utils.Mutex();
+ this.qweb_mutex = new concurrency.Mutex();
this.currencies = {};
this._groups_def = {};
},
diff --git a/addons/web/static/src/js/framework/time.js b/addons/web/static/src/js/core/time.js
index af13aa5a3b1..af13aa5a3b1 100644
--- a/addons/web/static/src/js/framework/time.js
+++ b/addons/web/static/src/js/core/time.js
diff --git a/addons/web/static/src/js/framework/translation.js b/addons/web/static/src/js/core/translation.js
index f2bad0db4ee..f2bad0db4ee 100644
--- a/addons/web/static/src/js/framework/translation.js
+++ b/addons/web/static/src/js/core/translation.js
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
new file mode 100644
index 00000000000..578eb3478f1
--- /dev/null
+++ b/addons/web/static/src/js/core/utils.js
@@ -0,0 +1,436 @@
+odoo.define('web.utils', function (require) {
+"use strict";
+
+/**
+ * Utils
+ *
+ * Various generic utility functions
+ */
+
+var translation = require('web.translation');
+
+var _t = translation._t;
+var id = -1;
+
+var utils = {
+ /**
+ * Throws an error if the given condition is not true
+ *
+ * @param {any} bool
+ */
+ assert: function (bool) {
+ if (!bool) {
+ throw new Error("AssertionError");
+ }
+ },
+ /**
+ * Confines a value inside an interval
+ *
+ * @param {number} [val] the value to confine
+ * @param {number} [min] the minimum of the interval
+ * @param {number} [max] the maximum of the interval
+ * @return {number} val if val is in [min, max], min if val < min and max
+ * otherwise
+ */
+ confine: function (val, min, max) {
+ return Math.max(min, Math.min(max, val));
+ },
+ /**
+ * computes (Math.floor(a/b), a%b and passes that to the callback.
+ *
+ * returns the callback's result
+ */
+ divmod: function (a, b, fn) {
+ var mod = a%b;
+ // in python, sign(a % b) === sign(b). Not in JS. If wrong side, add a
+ // round of b
+ if (mod > 0 && b < 0 || mod < 0 && b > 0) {
+ mod += b;
+ }
+ return fn(Math.floor(a/b), mod);
+ },
+ /**
+ * @param {number} value
+ * @param {integer} decimals
+ * @returns {boolean}
+ */
+ float_is_zero: function (value, decimals) {
+ var epsilon = Math.pow(10, -decimals);
+ return Math.abs(utils.round_precision(value, epsilon)) < epsilon;
+ },
+ /**
+ * Generate a unique numerical ID
+ *
+ * @returns {integer}
+ */
+ generateID: function () {
+ return ++id;
+ },
+ /**
+ * Read the cookie described by c_name
+ *
+ * @param {string} c_name
+ * @returns {string}
+ */
+ get_cookie: function (c_name) {
+ var cookies = document.cookie ? document.cookie.split('; ') : [];
+ for (var i = 0, l = cookies.length; i < l; i++) {
+ var parts = cookies[i].split('=');
+ var name = parts.shift();
+ var cookie = parts.join('=');
+
+ if (c_name && c_name === name) {
+ return cookie;
+ }
+ }
+ return "";
+ },
+ /**
+ * Returns a human readable number
+ *
+ * @param {Number} number
+ */
+ human_number: function (number, decimals) {
+ decimals = decimals | 0;
+ number = Math.round(number);
+ var d2 = Math.pow(10, decimals);
+ var val = _t("kMGTPE");
+ var i = val.length-1, s;
+ while( i ) {
+ s = Math.pow(10,i--*3);
+ if( s <= number ) {
+ number = Math.round(number*d2/s)/d2 + val[i];
+ }
+ }
+ return number;
+ },
+ /**
+ * Returns a human readable size
+ *
+ * @param {Number} number of bytes
+ */
+ human_size: function (size) {
+ var units = _t("Bytes,Kb,Mb,Gb,Tb,Pb,Eb,Zb,Yb").split(',');
+ var i = 0;
+ while (size >= 1024) {
+ size /= 1024;
+ ++i;
+ }
+ return size.toFixed(2) + ' ' + units[i];
+ },
+ /**
+ * Insert "thousands" separators in the provided number (which is actually
+ * a string)
+ *
+ * @param {String} num
+ * @returns {String}
+ */
+ insert_thousand_seps: function (num) {
+ var negative = num[0] === '-';
+ num = (negative ? num.slice(1) : num);
+ return (negative ? '-' : '') + utils.intersperse(
+ num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep);
+ },
+ /**
+ * Intersperses ``separator`` in ``str`` at the positions indicated by
+ * ``indices``.
+ *
+ * ``indices`` is an array of relative offsets (from the previous insertion
+ * position, starting from the end of the string) at which to insert
+ * ``separator``.
+ *
+ * There are two special values:
+ *
+ * ``-1``
+ * indicates the insertion should end now
+ * ``0``
+ * indicates that the previous section pattern should be repeated (until all
+ * of ``str`` is consumed)
+ *
+ * @param {String} str
+ * @param {Array<Number>} indices
+ * @param {String} separator
+ * @returns {String}
+ */
+ intersperse: function (str, indices, separator) {
+ separator = separator || '';
+ var result = [], last = str.length;
+
+ for(var i=0; i<indices.length; ++i) {
+ var section = indices[i];
+ if (section === -1 || last <= 0) {
+ // Done with string, or -1 (stops formatting string)
+ break;
+ } else if(section === 0 && i === 0) {
+ // repeats previous section, which there is none => stop
+ break;
+ } else if (section === 0) {
+ // repeat previous section forever
+ //noinspection AssignmentToForLoopParameterJS
+ section = indices[--i];
+ }
+ result.push(str.substring(last-section, last));
+ last -= section;
+ }
+
+ var s = str.substring(0, last);
+ if (s) { result.push(s); }
+ return result.reverse().join(separator);
+ },
+ /**
+ * @param {any} object
+ * @param {any} path
+ * @returns
+ */
+ into: function (object, path) {
+ if (!_(path).isArray()) {
+ path = path.split('.');
+ }
+ for (var i = 0; i < path.length; i++) {
+ object = object[path[i]];
+ }
+ return object;
+ },
+ /**
+ * @param {string} v
+ * @returns {boolean}
+ */
+ is_bin_size: function (v) {
+ return (/^\d+(\.\d*)? \w+$/).test(v);
+ },
+ /**
+ * @param {any} node
+ * @param {any} human_readable
+ * @param {any} indent
+ * @returns {string}
+ */
+ json_node_to_xml: function (node, human_readable, indent) {
+ // For debugging purpose, this function will convert a json node back to xml
+ indent = indent || 0;
+ var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''),
+ r = sindent + '<' + node.tag,
+ cr = human_readable ? '\n' : '';
+
+ if (typeof(node) === 'string') {
+ return sindent + node.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) {
+ throw new Error(
+ _.str.sprintf(_t("Node [%s] is not a JSONified XML node"),
+ JSON.stringify(node)));
+ }
+ for (var attr in node.attrs) {
+ var vattr = node.attrs[attr];
+ if (typeof(vattr) !== 'string') {
+ // domains, ...
+ vattr = JSON.stringify(vattr);
+ }
+ vattr = vattr.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ if (human_readable) {
+ vattr = vattr.replace(/&quot;/g, "'");
+ }
+ r += ' ' + attr + '="' + vattr + '"';
+ }
+ if (node.children && node.children.length) {
+ r += '>' + cr;
+ var childs = [];
+ for (var i = 0, ii = node.children.length; i < ii; i++) {
+ childs.push(utils.json_node_to_xml(node.children[i], human_readable, indent + 1));
+ }
+ r += childs.join(cr);
+ r += cr + sindent + '</' + node.tag + '>';
+ return r;
+ } else {
+ return r + '/>';
+ }
+ },
+ /**
+ * Left-pad provided arg 1 with zeroes until reaching size provided by second
+ * argument.
+ *
+ * @see rpad
+ *
+ * @param {number|string} str value to pad
+ * @param {number} size size to reach on the final padded value
+ * @returns {string} padded string
+ */
+ lpad: function (str, size) {
+ str = "" + str;
+ return new Array(size - str.length + 1).join('0') + str;
+ },
+ /**
+ * Passes the fractional and integer parts of x to the callback, returns
+ * the callback's result
+ */
+ modf: function (x, fn) {
+ var mod = x%1;
+ if (mod < 0) {
+ mod += 1;
+ }
+ return fn(mod, Math.floor(x));
+ },
+ /**
+ * performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision
+ * See the corresponding float_round() in server/tools/float_utils.py for more info
+ * @param {Number} the value to be rounded
+ * @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
+ */
+ round_decimals: function (value, decimals) {
+ return utils.round_precision(value, Math.pow(10,-decimals));
+ },
+ /**
+ * performs a half up rounding with arbitrary precision, correcting for float loss of precision
+ * See the corresponding float_round() in server/tools/float_utils.py for more info
+ *
+ * @param {number} value the value to be rounded
+ * @param {number} precision a precision parameter. eg: 0.01 rounds to two digits.
+ */
+ round_precision: function (value, precision) {
+ if (!value) {
+ return 0;
+ } else if (!precision || precision < 0) {
+ precision = 1;
+ }
+ var normalized_value = value / precision;
+ var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2);
+ var epsilon = Math.pow(2, epsilon_magnitude - 53);
+ normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
+
+ /**
+ * Javascript performs strictly the round half up method, which is asymmetric. However, in
+ * Python, the method is symmetric. For example:
+ * - In JS, Math.round(-0.5) is equal to -0.
+ * - In Python, round(-0.5) is equal to -1.
+ * We want to keep the Python behavior for consistency.
+ */
+ var sign = normalized_value < 0 ? -1.0 : 1.0;
+ var rounded_value = sign * Math.round(Math.abs(normalized_value));
+ return rounded_value * precision;
+ },
+ /**
+ * @see lpad
+ *
+ * @param {string} str
+ * @param {number} size
+ * @returns {string}
+ */
+ rpad: function (str, size) {
+ str = "" + str;
+ return str + new Array(size - str.length + 1).join('0');
+ },
+ /**
+ * Create a cookie
+ * @param {String} name : the name of the cookie
+ * @param {String} value : the value stored in the cookie
+ * @param {Integer} ttl : time to live of the cookie in millis. -1 to erase the cookie.
+ */
+ set_cookie: function (name, value, ttl) {
+ ttl = ttl || 24*60*60*365;
+ document.cookie = [
+ name + '=' + value,
+ 'path=/',
+ 'max-age=' + ttl,
+ 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
+ ].join(';');
+ },
+ /**
+ * @param {any} array
+ * @param {any} elem1
+ * @param {any} elem2
+ */
+ swap: function (array, elem1, elem2) {
+ var i1 = array.indexOf(elem1);
+ var i2 = array.indexOf(elem2);
+ array[i2] = elem1;
+ array[i1] = elem2;
+ },
+ /**
+ * @param {any} str
+ * @param {any} elseValues
+ * @param {any} trueValues
+ * @param {any} falseValues
+ * @returns
+ */
+ toBoolElse: function (str, elseValues, trueValues, falseValues) {
+ var ret = _.str.toBool(str, trueValues, falseValues);
+ if (_.isUndefined(ret)) {
+ return elseValues;
+ }
+ return ret;
+ },
+ /**
+ * @todo: is this really the correct place?
+ *
+ * @param {any} data
+ * @param {any} f
+ */
+ traverse_records: function (data, f) {
+ if (data.type === 'record') {
+ f(data);
+ } else if (data.data) {
+ for (var i = 0; i < data.data.length; i++) {
+ utils.traverse_records(data.data[i], f);
+ }
+ }
+ },
+ /**
+ * @param {any} node
+ * @param {any} strip_whitespace
+ * @returns
+ */
+ xml_to_json: function (node, strip_whitespace) {
+ switch (node.nodeType) {
+ case 9:
+ return utils.xml_to_json(node.documentElement, strip_whitespace);
+ case 3:
+ case 4:
+ return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
+ case 1:
+ var attrs = $(node).getAttributes();
+ _.each(['domain', 'filter_domain', 'context', 'default_get'], function (key) {
+ if (attrs[key]) {
+ try {
+ attrs[key] = JSON.parse(attrs[key]);
+ } catch(e) { }
+ }
+ });
+ return {
+ tag: node.tagName.toLowerCase(),
+ attrs: attrs,
+ children: _.compact(_.map(node.childNodes, function (node) {
+ return utils.xml_to_json(node, strip_whitespace);
+ })),
+ };
+ }
+ },
+ /**
+ * @param {any} node
+ * @returns {string}
+ */
+ xml_to_str: function (node) {
+ var str = "";
+ if (window.XMLSerializer) {
+ str = (new XMLSerializer()).serializeToString(node);
+ } else if (window.ActiveXObject) {
+ str = node.xml;
+ } else {
+ throw new Error(_t("Could not serialize XML"));
+ }
+ // Browsers won't deal with self closing tags except void elements:
+ // http://www.w3.org/TR/html-markup/syntax.html
+ var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' ');
+
+ // The following regex is a bit naive but it's ok for the xmlserializer output
+ str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function (match, tag, attrs) {
+ if (void_elements.indexOf(tag) < 0) {
+ return "<" + tag + attrs + "></" + tag + ">";
+ } else {
+ return match;
+ }
+ });
+ return str;
+ },
+};
+
+return utils;
+
+});
diff --git a/addons/web/static/src/js/framework/widget.js b/addons/web/static/src/js/core/widget.js
index 25a30ae6511..221a127c9dd 100644
--- a/addons/web/static/src/js/framework/widget.js
+++ b/addons/web/static/src/js/core/widget.js
@@ -2,9 +2,7 @@ odoo.define('web.Widget', function (require) {
"use strict";
var core = require('web.core');
-var session = require('web.session');
-var qweb = core.qweb;
var mixins = core.mixins;
/**
@@ -57,7 +55,7 @@ var mixins = core.mixins;
*/
-var Widget = core.Class.extend(mixins.PropertiesMixin, {
+var Widget = core.Class.extend(mixins.PropertiesMixin, mixins.ServicesMixin, {
// Backbone-ish API
tagName: 'div',
id: null,
@@ -81,7 +79,7 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
* When that widget is destroyed by calling destroy(), the current instance will be
* destroyed too. Can be null.
*/
- init: function(parent) {
+ init: function (parent) {
mixins.PropertiesMixin.init.call(this);
this.setParent(parent);
// Bind on_/do_* methods to this
@@ -104,14 +102,14 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @return {jQuery.Deferred}
*/
- willStart: function() {
+ willStart: function () {
return $.when();
},
/**
* Destroys the current widget, also destroys all its children before destroying itself.
*/
- destroy: function() {
- _.each(this.getChildren(), function(el) {
+ destroy: function () {
+ _.each(this.getChildren(), function (el) {
el.destroy();
});
if(this.$el) {
@@ -124,9 +122,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @param target A jQuery object or a Widget instance.
*/
- appendTo: function(target) {
+ appendTo: function (target) {
var self = this;
- return this.__widgetRenderAndInsert(function(t) {
+ return this.__widgetRenderAndInsert(function (t) {
self.$el.appendTo(t);
}, target);
},
@@ -135,9 +133,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @param target A jQuery object or a Widget instance.
*/
- prependTo: function(target) {
+ prependTo: function (target) {
var self = this;
- return this.__widgetRenderAndInsert(function(t) {
+ return this.__widgetRenderAndInsert(function (t) {
self.$el.prependTo(t);
}, target);
},
@@ -146,9 +144,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @param target A jQuery object or a Widget instance.
*/
- insertAfter: function(target) {
+ insertAfter: function (target) {
var self = this;
- return this.__widgetRenderAndInsert(function(t) {
+ return this.__widgetRenderAndInsert(function (t) {
self.$el.insertAfter(t);
}, target);
},
@@ -157,9 +155,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @param target A jQuery object or a Widget instance.
*/
- insertBefore: function(target) {
+ insertBefore: function (target) {
var self = this;
- return this.__widgetRenderAndInsert(function(t) {
+ return this.__widgetRenderAndInsert(function (t) {
self.$el.insertBefore(t);
}, target);
},
@@ -168,10 +166,10 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @param target A jQuery object or a Widget instance.
*/
- attachTo: function(target) {
+ attachTo: function (target) {
var self = this;
this.setElement(target.$el || target);
- return this.willStart().then(function() {
+ return this.willStart().then(function () {
return self.start();
});
},
@@ -180,14 +178,14 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
*
* @param target A jQuery object or a Widget instance.
*/
- replace: function(target) {
- return this.__widgetRenderAndInsert(_.bind(function(t) {
+ replace: function (target) {
+ return this.__widgetRenderAndInsert(_.bind(function (t) {
this.$el.replaceAll(t);
}, this), target);
},
- __widgetRenderAndInsert: function(insertion, target) {
+ __widgetRenderAndInsert: function (insertion, target) {
var self = this;
- return this.willStart().then(function() {
+ return this.willStart().then(function () {
self.renderElement();
insertion(target);
return self.start();
@@ -197,12 +195,12 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
* Method called after rendering. Mostly used to bind actions, perform asynchronous
* calls, etc...
*
- * By convention, this method should return an object that can be passed to $.when()
+ * By convention, this method should return an object that can be passed to $.when()
* to inform the caller when this widget has been initialized.
*
* @returns {jQuery.Deferred or any}
*/
- start: function() {
+ start: function () {
return $.when();
},
/**
@@ -210,10 +208,10 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
* `this.template` must be defined. The context given to QWeb contains the "widget"
* key that references `this`.
*/
- renderElement: function() {
+ renderElement: function () {
var $el;
if (this.template) {
- $el = $(qweb.render(this.template, {widget: this}).trim());
+ $el = $(core.qweb.render(this.template, {widget: this}).trim());
} else {
$el = this._make_descriptive();
}
@@ -323,13 +321,7 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
if (!this.custom_events.hasOwnProperty(key)) { continue; }
var method = this.proxy(this.custom_events[key]);
- this.on(key, this, wrap_handler(method));
- }
- function wrap_handler(callback) {
- return function (event) {
- event.stop_propagation();
- callback(event);
- };
+ this.on(key, this, method);
}
},
undelegateEvents: function () {
@@ -341,7 +333,7 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
* @param {String} selector CSS selector, rooted in $el
* @returns {jQuery} selector match
*/
- $: function(selector) {
+ $: function (selector) {
if (selector === undefined)
return this.$el;
return this.$el.find(selector);
@@ -398,27 +390,6 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, {
return fn.apply(self, arguments);
};
},
- /**
- * Informs the action manager to do an action. This supposes that
- * the action manager can be found amongst the ancestors of the current widget.
- * If that's not the case this method will simply return `false`.
- */
- do_action: function() {
- var parent = this.getParent();
- if (parent) {
- return parent.do_action.apply(parent, arguments);
- }
- return false;
- },
- do_notify: function(title, message, sticky) {
- this.trigger_up('notification', {title: title, message: message, sticky: sticky});
- },
- do_warn: function(title, message, sticky) {
- this.trigger_up('warning', {title: title, message: message, sticky: sticky});
- },
- rpc: function(url, data, options) {
- return this.alive(session.rpc(url, data, options));
- },
});
return Widget;
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 00000000000..fbf27f07eee
--- /dev/null
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -0,0 +1,328 @@
+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 pyeval = require('web.pyeval');
+var Widget = require('web.Widget');
+
+var AbstractField = Widget.extend({
+ className: 'o_field_widget',
+ events: {
+ 'keydown': '_onKeydown',
+ },
+ /**
+ * if this flag is set to true, the rest of the web client will assume that
+ * it is not editable. For example, the list view in editable mode will
+ * skip the widget when pressing tab
+ */
+ readonly: false,
+
+ /**
+ * 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 true, the widget will replace a cell in an editable list view
+ */
+ replace_element: false,
+ /**
+ * to override to indicate which field types are supported by the widget
+ */
+ supportedFieldTypes: [],
+
+ /**
+ * 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'
+ * @param {string} [options.required=false]
+ * @param {string} [options.idForLabel]
+ */
+ init: function (parent, name, record, options) {
+ this._super(parent);
+ options = options || {};
+
+ // 'name' is the field name displayed by this widget
+ this.name = name;
+
+ // 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 'attrs' property contains the attributes of the xml 'field' tag
+ this.attrs = record.fieldAttrs[name] || {};
+
+ // 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 = pyeval.py_eval(this.attrs.options || '{}', this.recordData);
+
+ // 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;
+
+ // the 'required' flag is basically only needed to determine if the widget
+ // is in a valid state (not valid if empty and required)
+ this.required = options.required || false;
+
+ // the 'idForLabel' is the (html) id that should be set to the relevent
+ // dom entity. If done correctly, clicking on the corresponding label
+ // (in form view) will focus and select the value.
+ this.idForLabel = options.idForLabel;
+ },
+ /**
+ * 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 {Deferred}
+ */
+ start: function() {
+ var self = this;
+ return this._super.apply(this, arguments).then(function() {
+ self._render();
+ if (self.required) {
+ self.$el.addClass('o_form_required');
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Right now, this function is only used in editable list view when the user
+ * select a cell. The corresponding widget will be activated, which in general
+ * means that the input text will be focused and selected
+ */
+ activate: function () {
+ },
+ /**
+ * 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 its value is valid and if there is a value when
+ * it is required. This is checked before saving a record, by the form view.
+ *
+ * @returns {boolean}
+ */
+ isValid: function () {
+ return this._isValid && !(this.required && !this.isSet());
+ },
+ /**
+ * 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 {Deferred} A Deferred, which resolves when the widget rendering
+ * is complete
+ */
+ reset: function (record, event) {
+ this._reset(record, event);
+ return this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * convert the value from the field to a string representation
+ *
+ * @private
+ * @param {any} value (from the field type)
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ var options = _.extend({}, this.nodeOptions, { data: this.recordData });
+ return field_utils.format_field(value, this.field, options);
+ },
+ /**
+ * convert a string representation to a valid value, depending on the field
+ * type.
+ *
+ * @private
+ * @param {string} value
+ * @returns {any}
+ */
+ _parseValue: function (value) {
+ return field_utils.parse_field(value, this.field);
+ },
+ /**
+ * 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 {Deferred}
+ */
+ _render: function () {
+ 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 {Deferred}
+ */
+ _renderEdit: function () {
+ },
+ /**
+ * Render the widget in readonly mode. The actual implementation is left to
+ * the concrete widget.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _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.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
+ */
+ _setValue: function (value) {
+ try {
+ value = this._parseValue(value);
+ this._isValid = true;
+ } catch(e) {
+ this._isValid = false;
+ }
+ var changes = {};
+ changes[this.name] = value;
+ this.trigger_up('field_changed', {
+ dataPointID: this.dataPointID,
+ changes: changes,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * might be controversial: intercept the tab key, to allow the editable list
+ * view to control where the focus is.
+ *
+ * @private
+ * @param {KeyEvent} event
+ */
+ _onKeydown: function (event) {
+ if (event.which === $.ui.keyCode.TAB) {
+ this.trigger_up('move_next');
+ event.preventDefault();
+ }
+ },
+});
+
+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 00000000000..d07bcb5dd95
--- /dev/null
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -0,0 +1,1300 @@
+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 ajax = require('web.ajax');
+var config = require('web.config');
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var datepicker = require('web.datepicker');
+var dom = require('web.dom');
+var field_utils = require('web.field_utils');
+var framework = require('web.framework');
+var session = require('web.session');
+var utils = require('web.utils');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+
+var InputField = AbstractField.extend({
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': '_onInput',
+ }),
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.tagName = 'input';
+ }
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.$el.focus();
+ setTimeout(this.$el.select.bind(this.$el), 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderEdit: function () {
+ this.$el.addClass('o_form_input');
+ this.$el.attr('type', 'text');
+ if (this.attrs.placeholder) {
+ this.$el.attr('placeholder', this.attrs.placeholder);
+ }
+ this.$el.attr('id', this.idForLabel);
+ // save cursor position to restore it after updating value
+ var selectionStart = this.el.selectionStart;
+ var selectionEnd = this.el.selectionEnd;
+ this.$el.val(this._formatValue(this.value));
+ this.el.selectionStart = selectionStart;
+ this.el.selectionEnd = selectionEnd;
+ },
+ _renderReadonly: function () {
+ this.$el.empty().html(this._formatValue(this.value));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Implement keyboard movements. Mostly useful for its environment, such
+ * as a list view.
+ *
+ * @override
+ * @param {any} event
+ */
+ _onKeydown: function (event) {
+ var input = this.$el[0];
+ var is_not_selecting;
+ switch (event.which) {
+ case $.ui.keyCode.DOWN:
+ this.trigger_up('move_down');
+ break;
+ case $.ui.keyCode.UP:
+ this.trigger_up('move_up');
+ break;
+ case $.ui.keyCode.LEFT:
+ is_not_selecting = input.selectionEnd === input.selectionStart;
+ if (is_not_selecting && input.selectionStart === 0) {
+ this.trigger_up('move_left');
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ is_not_selecting = input.selectionEnd === input.selectionStart;
+ if (is_not_selecting && input.selectionEnd === input.value.length) {
+ this.trigger_up('move_right');
+ }
+ break;
+ case $.ui.keyCode.ENTER:
+ this.trigger_up('move_next_line');
+ break;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * We notify the outside world for each change in this input value. It does
+ * not necessarily mean that onchanges will be triggered instantly.
+ */
+ _onInput: function () {
+ this._setValue(this.$el.val());
+ },
+});
+
+var FieldChar = InputField.extend({
+ supportedFieldTypes: ['char', 'text'],
+ tagName: 'span',
+});
+
+var FieldDate = InputField.extend({
+ className: "o_form_field_date",
+ tagName: "span",
+ replace_element: true,
+ supportedFieldTypes: ['date', 'datetime'],
+
+ start: function () {
+ var self = this;
+ var def;
+ if (this.mode === 'edit') {
+ this.datewidget = this._makeDatePicker();
+ this.datewidget.on('datetime_changed', this, function () {
+ if (this.datewidget.get_value() !== this._parseValue(this.value)) {
+ this._setValue(this.datewidget.get_value());
+ }
+ });
+ def = this.datewidget.appendTo('<div>').done(function () {
+ self.datewidget.$el.addClass(self.$el.attr('class'));
+ self.datewidget.$input.addClass('o_form_input');
+ self.datewidget.$input.attr('id', self.idForLabel);
+ self.replaceElement(self.datewidget.$el);
+ });
+ }
+ return $.when(def, this._super.apply(this, arguments));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ var $input = this.datewidget.$input;
+ $input.focus();
+ setTimeout($input.select.bind($input), 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _makeDatePicker: function () {
+ return new datepicker.DateWidget(this);
+ },
+ _renderEdit: function () {
+ this.datewidget.set_value(this.value);
+ },
+});
+
+var FieldDateTime = FieldDate.extend({
+ supportedFieldTypes: ['datetime'],
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _makeDatePicker: function () {
+ return new datepicker.DateTimeWidget(this);
+ },
+});
+
+var FieldMonetary = InputField.extend({
+ className: 'o_list_number',
+ supportedFieldTypes: ['float', 'monetary'],
+ // FieldMonetary overrides _formatValue to ensure that the format_monetary
+ // method is used. This allows this widget to be used with other field
+ // types, such as float.
+ _formatValue: function (value) {
+ var options = _.extend({}, this.nodeOptions, { data: this.recordData });
+ return field_utils.format_monetary(value, this.field, options);
+ },
+ isSet: function () {
+ return this.value !== false;
+ }
+});
+
+var FieldBoolean = AbstractField.extend({
+ className: 'o_field_boolean',
+ events: _.extend({}, AbstractField.prototype.events, {
+ change: '_onChange',
+ keydown: '_onKeyDown',
+ }),
+ replace_element: true,
+ supportedFieldTypes: ['boolean'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.$input.focus();
+ setTimeout(this.$input.select.bind(this.$input), 0);
+ },
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderReadonly: function () {
+ var $checkbox = this._formatValue(this.value);
+ this.$input = $checkbox.find('input');
+ this.$el.empty().append($checkbox);
+ },
+ _renderEdit: function () {
+ this._renderReadonly();
+ this.$input.prop('disabled', false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onChange: function () {
+ this._setValue(this.$input[0].checked);
+ },
+ /**
+ * @param {KeyEvent} event
+ */
+ _onKeydown: function (event) {
+ switch (event.which) {
+ case $.ui.keyCode.DOWN:
+ this.trigger_up('move_down');
+ break;
+ case $.ui.keyCode.UP:
+ this.trigger_up('move_up');
+ break;
+ case $.ui.keyCode.LEFT:
+ this.trigger_up('move_left');
+ break;
+ case $.ui.keyCode.RIGHT:
+ this.trigger_up('move_right');
+ break;
+ case $.ui.keyCode.TAB:
+ this.trigger_up('move_next');
+ event.preventDefault();
+ break;
+ case $.ui.keyCode.ENTER:
+ this.$input.prop('checked', !this.value);
+ this._setValue(!this.value);
+ break;
+ }
+ },
+});
+
+var FieldInteger = InputField.extend({
+ supportedFieldTypes: ['integer'],
+ isSet: function () {
+ return this.value !== false;
+ },
+});
+
+var FieldFloat = InputField.extend({
+ supportedFieldTypes: ['float'],
+ _renderReadonly: function () {
+ var precision;
+ var value = this.value;
+ if (this.attrs.digits) {
+ precision = this.node.attrs.digits;
+ } else {
+ precision = this.field.digits;
+ }
+ if (precision && precision.length === 2) {
+ value = utils.round_decimals(value, precision[1]);
+ }
+ var $span = $('<span>').addClass('o_form_field o_form_field_number').text(this._formatValue(value));
+ this.$el.html($span);
+ },
+ isSet: function () {
+ return this.value !== '';
+ },
+});
+
+var FieldFloatTime = FieldFloat.extend({
+ supportedFieldTypes: ['float'],
+ _formatValue: function (value) {
+ var pattern = '%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);
+ },
+ _parseValue: function (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 * field_utils.parse_float(value, this.field);
+ }
+ var hours = field_utils.parse_integer(float_time_pair[0]);
+ var minutes = field_utils.parse_integer(float_time_pair[1]);
+ return factor * (hours + (minutes / 60));
+ },
+});
+
+var FieldText = AbstractField.extend({
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': function () {
+ this._setValue(this.$textarea.val());
+ },
+ }),
+ supportedFieldTypes: ['text'],
+ start: function () {
+ this.$el.addClass('o_list_text o_form_textarea');
+ this.$el.attr('id', this.idForLabel);
+
+ if (this.mode === 'edit') {
+ // in edit mode, the widget contains a textarea. We append it in
+ // start() instead of _renderEdit() to keep the same textarea even
+ // if several _renderEdit are done. This allows to keep the cursor
+ // position and to autoresize only once
+ this.$textarea = $('<textarea>').appendTo(this.$el);
+ if (this.attrs.placeholder) {
+ this.$textarea.attr('placeholder', this.attrs.placeholder);
+ }
+ dom.autoresize(this.$textarea, {parent: this});
+ }
+
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ var $textarea = this.$('textarea');
+ $textarea.focus();
+ setTimeout($textarea.select.bind($textarea), 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderEdit: function () {
+ this.$textarea.val(this._formatValue(this.value));
+ },
+ _renderReadonly: function () {
+ this.$el.empty().text(this._formatValue(this.value));
+ },
+});
+
+var FieldHtml = InputField.extend({
+ supportedFieldTypes: ['html'],
+ text_to_html: function (text) {
+ var value = text || "";
+ if (value.match(/^\s*$/)) {
+ value = '<p><br/></p>';
+ } else {
+ value = "<p>"+value.split(/<br\/?>/).join("<br/></p><p>")+"</p>";
+ value = value.replace(/<p><\/p>/g, '').replace('<p><p>', '<p>').replace('<p><p ', '<p ').replace('</p></p>', '</p>');
+ }
+ return value;
+ },
+ _render: function () {
+ this.$el.html(this.text_to_html(this.value));
+ },
+});
+
+/**
+ * displays a handle and allows the resequencing
+ * This widget works for integer fields
+ */
+var HandleWidget = AbstractField.extend({
+ tagName: 'span',
+ className: 'o_row_handle fa fa-arrows ui-sortable-handle',
+ description: "",
+ readonly: true,
+ replace_element: true,
+ supportedFieldTypes: ['integer'],
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * content is only "set" with pure css...
+ * @override
+ */
+ _render: function () {
+ this.$el.empty();
+ },
+});
+
+var EmailWidget = InputField.extend({
+ prefix: 'mailto',
+ supportedFieldTypes: ['char'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.tagName = this.mode === 'readonly' ? 'a' : 'input';
+ },
+ _renderReadonly: function () {
+ this.$el.text(this.value)
+ .addClass('o_form_uri o_text_overflow')
+ .attr('href', this.prefix + ':' + this.value);
+ }
+});
+
+var FieldPhone = EmailWidget.extend({
+ prefix: 'tel',
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'readonly' && !this._can_call()) {
+ this.tagName = 'span';
+ }
+ },
+ _renderReadonly: function () {
+ this._super();
+ if(this._can_call()) {
+ var text = this.$el.text();
+ this.$el.html(text.substr(0, text.length/2) + "&shy;" + text.substr(text.length/2)); // To prevent Skype app to find the phone number
+ } else {
+ this.$el.removeClass('o_form_uri');
+ }
+ },
+ _can_call: function () {
+ // Phone fields are clickable in readonly on small screens ~= on phones
+ // This can be overriden by call-capable modules to display a clickable
+ // link in different situations, like always regardless of screen size,
+ // or only allow national calls for example.
+ return config.device.size_class <= config.device.SIZES.XS;
+ }
+});
+
+var UrlWidget = InputField.extend({
+ supportedFieldTypes: ['char'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.tagName = this.mode === 'readonly' ? 'a' : 'input';
+ },
+ _renderReadonly: function () {
+ this.$el.text(this.value)
+ .addClass('o_form_uri o_text_overflow')
+ .attr('href', this.value);
+ }
+});
+
+var AbstractFieldBinary = AbstractField.extend({
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'change .o_form_input_file': 'on_file_change',
+ 'click .o_select_file_button': function () {
+ this.$('.o_form_input_file').click();
+ },
+ 'click .o_clear_file_button': 'on_clear',
+ }),
+ init: function (parent, name, record) {
+ this._super.apply(this, arguments);
+ this.fields = record.fields;
+ this.useFileAPI = !!window.FileReader;
+ this.max_upload_size = 25 * 1024 * 1024; // 25Mo
+ 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;
+ }
+ var filereader = new FileReader();
+ filereader.readAsDataURL(file);
+ filereader.onloadend = function (upload) {
+ var data = upload.target.result;
+ data = data.split(',')[1];
+ self.on_file_uploaded(file.size, file.name, file.type, data);
+ };
+ } else {
+ this.$('form.o_form_binary_form input[name=session_id]').val(this.session.session_id);
+ 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(_t("File Upload"), _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) {
+ this.trigger_up('update_field', { name: filename, value: value });
+ }
+ },
+ on_clear: function () {
+ this.set_filename('');
+ this._setValue(false);
+ this._render();
+ },
+});
+
+var FieldBinaryImage = AbstractFieldBinary.extend({
+ 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'],
+ _render: function () {
+ var self = this;
+ var attrs = this.attrs;
+ var url = this.placeholder;
+ if (this.value) {
+ if (!utils.is_bin_size(this.value)) {
+ url = 'data:image/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: (this.recordData.__last_update || '').replace(/[^0-9]/g, ''),
+ });
+ }
+ }
+ var $img = $('<img>').attr('src', url);
+ $img.css({
+ width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width,
+ height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height,
+ });
+ this.$('> img').remove();
+ this.$el.prepend($img);
+ $img.on('error', function () {
+ self.on_clear();
+ $img.attr('src', self.placeholder);
+ self.do_warn(_t("Image"), _t("Could not display the selected image."));
+ });
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var FieldBinaryFile = AbstractFieldBinary.extend({
+ template: 'FieldBinaryFile',
+ events: _.extend({}, AbstractFieldBinary.prototype.events, {
+ 'click': function (event) {
+ if (this.mode === 'readonly' && this.value) {
+ this.on_save_as(event);
+ }
+ },
+ 'click .o_form_input': function () { // eq[0]
+ this.$('.o_form_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.filename_value) {
+ this.$el.append(" " + this.filename_value);
+ }
+ }
+ },
+ _renderEdit: function () {
+ if(this.value) {
+ this.$el.children().removeClass('o_hidden');
+ this.$('.o_select_file_button').first().addClass('o_hidden');
+ this.$('.o_form_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(_t("Save As..."), _t("The field is empty, there's nothing to save !"));
+ ev.stopPropagation();
+ } else {
+ framework.blockUI();
+ var c = crash_manager;
+ var filename_fieldname = this.attrs.filename;
+ this.session.get_file({
+ 'url': '/web/content',
+ 'data': {
+ 'model': this.model,
+ 'id': this.res_id,
+ 'field': this.name,
+ 'filename_field': filename_fieldname,
+ 'filename': this.recordData[filename_fieldname] || null,
+ 'download': true,
+ 'data': utils.is_bin_size(this.value) ? null : this.value,
+ },
+ 'complete': framework.unblockUI,
+ 'error': c.rpc_error.bind(c)
+ });
+ ev.stopPropagation();
+ }
+ },
+});
+
+var PriorityWidget = AbstractField.extend({
+ // the current implementation of this widget makes it
+ // only usable for fields of type selection
+ className: "o_priority",
+ events: {
+ 'mouseover > a': function (e) {
+ clearTimeout(this.hover_timer);
+ this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star');
+ $(e.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o');
+ },
+ 'mouseout > a': function () {
+ clearTimeout(this.hover_timer);
+
+ var self = this;
+ this.hover_timer = setTimeout(function () {
+ self._render();
+ }, 200);
+ },
+ 'click > a': function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ var new_value = String($(e.currentTarget).data('value'));
+ if(new_value === this.value) {
+ new_value = this.empty_value;
+ }
+ this._setValue(new_value);
+ },
+ },
+ supportedFieldTypes: ['integer'],
+ render_star: function (tag, is_full, value, tip) {
+ return $(tag)
+ .attr('title', tip)
+ .attr('data-value', value)
+ .addClass('o_priority_star fa')
+ .toggleClass('fa-star', is_full)
+ .toggleClass('fa-star-o', !is_full);
+ },
+ _render: function () {
+ var self = this;
+ var value = parseInt(this.value, 10);
+ this.$el.empty();
+ this.empty_value = this.field.selection[0][0];
+ _.each(this.field.selection.slice(1), function (choice) {
+ self.$el.append(self.render_star('<a href="#">', value >= parseInt(choice[0]), choice[0], choice[1]));
+ });
+ },
+});
+
+var AttachmentImage = AbstractField.extend({
+ template: 'AttachmentImage',
+});
+
+var StateSelectionWidget = AbstractField.extend({
+ template: 'FormSelection',
+ events: {
+ 'click a': function (e) {
+ e.preventDefault();
+ },
+ 'click li': 'set_selection'
+ },
+ prepare_dropdown_values: 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;
+ },
+ _render: function () {
+ var self = this;
+ var states = this.prepare_dropdown_values();
+ // Adapt "FormSelection"
+ var current_state = _.find(states, function (state) {
+ return state.name === self.value;
+ });
+ this.$('.o_status')
+ .removeClass('o_status_red o_status_green')
+ .addClass(current_state.state_class);
+
+ // Render "FormSelection.Items" and move it into "FormSelection"
+ var $items = $(qweb.render('FormSelection.items', {
+ states: _.without(states, current_state)
+ }));
+ var $dropdown = this.$('.dropdown-menu');
+ $dropdown.children().remove(); // remove old items
+ $items.appendTo($dropdown);
+ },
+ set_selection: function (ev) {
+ var li = $(ev.target).closest('li');
+ if (li.length) {
+ var value = String(li.data('value'));
+ this._setValue(value);
+ if (this.mode === 'edit') {
+ this._render();
+ }
+ }
+ },
+});
+
+var LabelSelection = AbstractField.extend({
+ _render: function () {
+ this.classes = this.nodeOptions && this.nodeOptions.classes || {};
+ var lbl_class = this.classes[this.value] || 'primary';
+ this.$el.addClass('label label-' + lbl_class).text(this._formatValue(this.value));
+ },
+});
+
+var FieldBooleanButton = AbstractField.extend({
+ className: 'o_stat_info',
+ supportedFieldTypes: ['boolean'],
+ _render: function () {
+ this.$el.empty();
+ var text, hover;
+ switch (this.nodeOptions.terminology) {
+ case "active":
+ text = this.value ? _t("Active") : _t("Inactive");
+ hover = this.value ? _t("Deactivate") : _t("Activate");
+ break;
+ case "archive":
+ text = this.value ? _t("Active") : _t("Archived");
+ hover = this.value ? _t("Archive") : _t("Restore");
+ break;
+ case "close":
+ text = this.value ? _t("Active") : _t("Closed");
+ hover = this.value ? _t("Close") : _t("Open");
+ break;
+ default:
+ var opt_terms = this.nodeOptions.terminology || {};
+ if (typeof opt_terms === 'string') {
+ opt_terms = {}; //unsupported terminology
+ }
+ text = this.value ? _t(opt_terms.string_true) || _t("On")
+ : _t(opt_terms.string_false) || _t("Off");
+ hover = this.value ? _t(opt_terms.hover_true) || _t("Switch Off")
+ : _t(opt_terms.hover_false) || _t("Switch On");
+ }
+ var val_color = this.value ? 'text-success' : 'text-danger';
+ var hover_color = this.value ? 'text-danger' : 'text-success';
+ var $val = $('<span>').addClass('o_stat_text o_not_hover ' + val_color).text(text);
+ var $hover = $('<span>').addClass('o_stat_text o_hover ' + hover_color).text(hover);
+ this.$el.append($val).append($hover);
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var FieldID = InputField.extend({
+ supportedFieldTypes: ['integer'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.mode = 'readonly';
+ },
+});
+
+var StatInfo = AbstractField.extend({
+ supportedFieldTypes: ['integer', 'float'],
+ _render: function () {
+ var options = {
+ value: this.value || 0,
+ };
+ if (! this.nodeOptions.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');
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var FieldPercentPie = AbstractField.extend({
+ template: 'FieldPercentPie',
+ supportedFieldTypes: ['integer'],
+ start: function () {
+ this.$left_mask = this.$('.o_mask').first();
+ this.$right_mask = this.$('.o_mask').last();
+ this.$pie_value = this.$('.o_pie_value');
+ return this._super();
+ },
+ _render: function () {
+ var value = this.value || 0;
+ var degValue = 360*value/100;
+
+ this.$right_mask.toggleClass('o_full', degValue >= 180);
+
+ var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)';
+ var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)';
+ this.$left_mask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg});
+ this.$right_mask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg});
+
+ this.$pie_value.html(Math.round(value) + '%');
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+/**
+ * FieldProgressBar
+ * parameters
+ * - 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({
+ 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];
+ }
+ this.max_value = this.recordData[this.nodeOptions.max_value] || 100;
+ this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable;
+ this.edit_max_value = this.nodeOptions.edit_max_value || false;
+ this.title = _t(this.attrs.title || this.nodeOptions.title) || '';
+ this.edit_on_click = !this.nodeOptions.edit_max_value || false;
+
+ this.write_mode = false;
+ },
+ _render: function () {
+ var self = this;
+ this._render_value();
+
+ if(!this.readonly) {
+ if(this.edit_on_click) {
+ this.$el.on('click', '.o_progress', function (e) {
+ var $target = $(e.currentTarget);
+ self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value);
+ self._render_value();
+ self.on_update(self.value);
+ });
+ } else {
+ this.$el.on('click', function () {
+ if (!self.write_mode) {
+ var $input = $('<input>', {type: 'text', class: 'o_progressbar_value'});
+ $input.on('blur', _.bind(self.on_change_input, self));
+ self.$('.o_progressbar_value').replaceWith($input);
+ self.write_mode = true;
+ self._render_value();
+ }
+ });
+ }
+ }
+ return this._super();
+ },
+ on_update: function (value) {
+ if(!isNaN(value)) {
+ if (this.edit_max_value) {
+ try {
+ this.max_value = this._parseValue(value);
+ this._isValid = true;
+ } catch(e) {
+ this._isValid = false;
+ }
+ var changes = {};
+ changes[this.nodeOptions.max_value] = this.max_value;
+ this.trigger_up('field_changed', {
+ dataPointID: this.dataPointID,
+ changes: changes,
+ });
+ } else {
+ this._setValue(value);
+ }
+ }
+ },
+ on_change_input: function (e) {
+ var $input = $(e.target);
+ if(e.type === 'change' && !$input.is(':focus')) {
+ return;
+ }
+ if(isNaN($input.val())) {
+ this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid."));
+ } else {
+ if(e.type === 'input') {
+ this._render_value($input.val());
+ if(parseFloat($input.val()) === 0) {
+ $input.select();
+ }
+ } else {
+ if(this.edit_max_value) {
+ this.max_value = $(e.target).val();
+ } else {
+ this.value = $(e.target).val() || 0;
+ }
+ var $div = $('<div>', {class: 'o_progressbar_value'});
+ this.$('.o_progressbar_value').replaceWith($div);
+ this.write_mode = false;
+
+ this._render_value();
+ this.on_update(this.edit_max_value ? this.max_value : this.value);
+ }
+ }
+ },
+ _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);
+ this.$('.o_progressbar_complete').css('width', widthComplete + '%');
+
+ if(!this.write_mode) {
+ if(max_value !== 100) {
+ this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value));
+ } else {
+ this.$('.o_progressbar_value').html(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();
+ }
+ },
+ 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({
+ template: "toggle_button",
+ events: {
+ 'click': 'set_toggle_button'
+ },
+ supportedFieldTypes: ['boolean'],
+ _render: function () {
+ var class_name = this.value ? 'o_toggle_button_success' : 'text-muted';
+ this.$('i').attr('class', ('fa fa-circle ' + class_name));
+ },
+ set_toggle_button: function () {
+ var toggle_value = !this.value;
+ this._setValue(toggle_value);
+ if (this.mode === 'edit') {
+ this._render();
+ }
+ },
+ isSet: function () {
+ return true;
+ },
+});
+
+var JournalDashboardGraph = AbstractField.extend({
+ className: "o_dashboard_graph",
+ init: function () {
+ this._super.apply(this, arguments);
+ this.graph_type = this.attrs.graph_type;
+ this.data = JSON.parse(this.value);
+ },
+ start: function () {
+ nv.utils.windowResize(this._onResize.bind(this));
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ nv.utils.offWindowResize(this._onResize);
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _customizeChart: function () {
+ if (this.graph_type === 'bar') {
+ // Add classes related to time on each bar of the bar chart
+ var bar_classes = _.map(this.data[0].values, function (v) {return v.type; });
+
+ _.each(this.$('.nv-bar'), function (v, k){
+ // classList doesn't work with phantomJS & addClass doesn't work with a SVG element
+ $(v).attr('class', $(v).attr('class') + ' ' + bar_classes[k]);
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ // note: the rendering of this widget is aynchronous as nvd3 does a
+ // setTimeout(0) before executing the callback given to addGraph
+ var self = this;
+ this.chart = null;
+ nv.addGraph(function () {
+ self.$svg = self.$el.append('<svg>');
+ switch(self.graph_type) {
+ case "line":
+ self.$svg.addClass('o_graph_linechart');
+
+ self.chart = nv.models.lineChart();
+ self.chart.forceY([0]);
+ self.chart.options({
+ x: function (d, u) { return u; },
+ margin: {'left': 0, 'right': 0, 'top': 0, 'bottom': 0},
+ showYAxis: false,
+ showLegend: false,
+ });
+ self.chart.xAxis.tickFormat(function (d) {
+ var label = '';
+ _.each(self.data, function (v){
+ if (v.values[d] && v.values[d].x){
+ label = v.values[d].x;
+ }
+ });
+ return label;
+ });
+ self.chart.yAxis.tickFormat(d3.format(',.2f'));
+
+ break;
+
+ case "bar":
+ self.$svg.addClass('o_graph_barchart');
+
+ self.chart = nv.models.discreteBarChart()
+ .x(function (d) { return d.label; })
+ .y(function (d) { return d.value; })
+ .showValues(false)
+ .showYAxis(false)
+ .margin({'left': 0, 'right': 0, 'top': 0, 'bottom': 40});
+
+ self.chart.xAxis.axisLabel(self.data[0].title);
+ self.chart.yAxis.tickFormat(d3.format(',.2f'));
+
+ break;
+ }
+ d3.select(self.$('svg')[0])
+ .datum(self.data)
+ .transition().duration(1200)
+ .call(self.chart);
+
+ self._customizeChart();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onResize: function () {
+ if (this.chart) {
+ this.chart.update();
+ this._customizeChart();
+ }
+ },
+
+});
+
+/**
+ * This widget is intended to be used on Text fields. It will provide Ace Editor
+ * for editing XML and Python.
+ */
+var AceEditor = AbstractField.extend({
+ template: "AceEditor",
+ willStart: function () {
+ if (!window.ace && !this.loadJS_def) {
+ this.loadJS_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () {
+ return $.when(ajax.loadJS('/web/static/lib/ace/mode-python.js'),
+ ajax.loadJS('/web/static/lib/ace/mode-xml.js')
+ );
+ });
+ }
+ return $.when(this._super(), this.loadJS_def);
+ },
+ start: function () {
+ this._startAce(this.$('.ace-view-editor')[0]);
+ return this._super();
+ },
+ destroy: function () {
+ if (this.aceEditor) {
+ this.aceEditor.destroy();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _renderEdit: function () {
+ var value = this._formatValue(this.value);
+ if (value !== this.lastValue) {
+ this.aceSession.setValue(this._formatValue(this.value));
+ }
+ },
+ _renderReadonly: function () {
+ this.aceSession.setValue(this._formatValue(this.value));
+ },
+ _setValue: function (value) {
+ this.lastValue = value;
+ this._super(value);
+ },
+ _startAce: function (node) {
+ var self = this;
+ 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,
+ });
+ this.aceEditor.on("change", function () {
+ if (self.aceSession.getUndoManager().hasUndo()) {
+ self._setValue(self.aceSession.getValue());
+ }
+ });
+ },
+});
+
+return {
+ EmailWidget: EmailWidget,
+ FieldBinaryFile: FieldBinaryFile,
+ FieldBinaryImage: FieldBinaryImage,
+ FieldBoolean: FieldBoolean,
+ FieldBooleanButton: FieldBooleanButton,
+ FieldChar: FieldChar,
+ FieldDate: FieldDate,
+ FieldDateTime: FieldDateTime,
+ FieldFloat: FieldFloat,
+ FieldFloatTime: FieldFloatTime,
+ FieldHtml: FieldHtml,
+ FieldID: FieldID,
+ FieldInteger: FieldInteger,
+ FieldMonetary: FieldMonetary,
+ FieldPercentPie: FieldPercentPie,
+ FieldPhone: FieldPhone,
+ FieldProgressBar: FieldProgressBar,
+ FieldText: FieldText,
+ FieldToggleBoolean: FieldToggleBoolean,
+ HandleWidget: HandleWidget,
+ InputField: InputField,
+ AttachmentImage: AttachmentImage,
+ LabelSelection: LabelSelection,
+ StateSelectionWidget: StateSelectionWidget,
+ PriorityWidget: PriorityWidget,
+ StatInfo: StatInfo,
+ UrlWidget: UrlWidget,
+ JournalDashboardGraph: JournalDashboardGraph,
+ AceEditor: AceEditor,
+};
+
+});
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 00000000000..0bfddd03098
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_registry.js
@@ -0,0 +1,66 @@
+odoo.define('web.field_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+});
+
+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');
+
+
+// Basic fields
+registry
+ .add('abstract', AbstractField)
+ .add('input', basic_fields.InputField)
+ .add('integer', basic_fields.FieldInteger)
+ .add('html', basic_fields.FieldHtml)
+ .add('boolean', basic_fields.FieldBoolean)
+ .add('date', basic_fields.FieldDate)
+ .add('datetime', basic_fields.FieldDateTime)
+ .add('text', basic_fields.FieldText)
+ .add('float', basic_fields.FieldFloat)
+ .add('char', basic_fields.FieldChar)
+ .add('handle', basic_fields.HandleWidget)
+ .add('email', basic_fields.EmailWidget)
+ .add('phone', basic_fields.FieldPhone)
+ .add('url', basic_fields.UrlWidget)
+ .add('image', basic_fields.FieldBinaryImage)
+ .add('binary', basic_fields.FieldBinaryFile)
+ .add('monetary', basic_fields.FieldMonetary)
+ .add('priority', basic_fields.PriorityWidget)
+ .add('attachment_image', basic_fields.AttachmentImage)
+ .add('label_selection', basic_fields.LabelSelection)
+ .add('state_selection', basic_fields.StateSelectionWidget)
+ .add('boolean_button', basic_fields.FieldBooleanButton)
+ .add('id', basic_fields.FieldID)
+ .add('statinfo', basic_fields.StatInfo)
+ .add('percentpie', basic_fields.FieldPercentPie)
+ .add('float_time', basic_fields.FieldFloatTime)
+ .add('progressbar', basic_fields.FieldProgressBar)
+ .add('toggle_button', basic_fields.FieldToggleBoolean)
+ .add('dashboard_graph', basic_fields.JournalDashboardGraph)
+ .add('ace', basic_fields.AceEditor);
+
+// Relational fields
+registry
+ .add('selection', relational_fields.FieldSelection)
+ .add('radio', relational_fields.FieldRadio)
+ .add('many2one', relational_fields.FieldMany2One)
+ .add('list.many2one', relational_fields.ListFieldMany2One)
+ .add('kanban.many2one', relational_fields.KanbanFieldMany2One)
+ .add('many2many', relational_fields.FieldMany2Many)
+ .add('many2many_tags', relational_fields.FieldMany2ManyTags)
+ .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes)
+ .add('form.many2many_tags', relational_fields.FormFieldMany2ManyTags)
+ .add('one2many', relational_fields.FieldOne2Many)
+ .add('statusbar', relational_fields.FieldStatus)
+ .add('one2many_list', relational_fields.FieldOne2Many);
+
+}); \ No newline at end of file
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 00000000000..3b06af9efc4
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -0,0 +1,240 @@
+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".
+ */
+
+var core = require('web.core');
+var session = require('web.session');
+var time = require('web.time');
+var utils = require('web.utils');
+
+function format_boolean(value) {
+ var $input = $('<input type="checkbox">')
+ .prop('checked', value)
+ .prop('disabled', true);
+ return $('<div>')
+ .addClass('o_checkbox')
+ .append($input)
+ .append($('<span>'));
+}
+
+function format_char(value) {
+ return typeof value === 'string' ? value : '';
+}
+
+function format_date(value) {
+ var l10n = core._t.database.parameters;
+ var date_format = time.strftime_to_moment_format(l10n.date_format);
+ return value && moment(time.str_to_date(value)).format(date_format);
+}
+
+function format_datetime(value) {
+ var l10n = core._t.database.parameters;
+ var date_format = time.strftime_to_moment_format(l10n.date_format);
+ var time_format = time.strftime_to_moment_format(l10n.time_format);
+ var datetime_format = date_format + ' ' + time_format;
+ return value && moment(time.str_to_datetime(value)).format(datetime_format);
+}
+
+// Format a float, according to the local settings
+// Params:
+// * value: a number describing the raw value of the float
+// * field [optional]: a description of a field, that may contains extra
+// information, such as a given precision
+function format_float(value, field) {
+ var l10n = core._t.database.parameters;
+ var precision = (field && field.digits) ? field.digits[1] : 2;
+ var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ return formatted.join(l10n.decimal_point);
+}
+
+function format_float_time(value) {
+ var pattern = '%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);
+}
+
+
+function format_id(value) {
+ return value ? value.toString() : false;
+}
+
+function format_integer(value) {
+ 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 "";
+ }
+ return utils.insert_thousand_seps(_.str.sprintf('%d', value));
+}
+
+function format_many2one(value) {
+ return value && (_.isArray(value) ? value[1] : value.data.display_name) || '';
+}
+
+function format_many2many(value) {
+ var names = _.map(value.data, function(p) {
+ return p.data.display_name;
+ });
+ return names.join(', ');
+}
+
+function format_monetary(value, field, options) {
+ options = options || {};
+ 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;
+ }
+ var currency = session.get_currency(currency_id);
+
+ var digits_precision = (currency && currency.digits) || [69,2];
+ var precision = digits_precision[1];
+ var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ var l10n = core._t.database.parameters;
+ var formatted_value = formatted.join(l10n.decimal_point);
+
+ if (!currency) {
+ return formatted_value;
+ }
+ if (currency.position === "after") {
+ return formatted_value += '&nbsp;' + currency.symbol;
+ } else {
+ return currency.symbol + '&nbsp;' + formatted_value;
+ }
+}
+
+function format_field(value, field, options) {
+ var formatter = result['format_' + field.type];
+ return formatter(value, field, options);
+}
+
+function format_selection(value, field) {
+ if (!value) {
+ return '';
+ }
+ var val = _.find(field.selection, function(option) {
+ return option[0] === value;
+ });
+ return val[1];
+}
+
+function parse_field(value, field) {
+ var parser = result['parse_' + field.type];
+ return parser(value, field);
+}
+
+
+function parse_float(value) {
+ value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), '');
+ value = value.replace(core._t.database.parameters.decimal_point, '.');
+ var parsed = Number(value);
+ if (isNaN(parsed)) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct float"), value));
+ }
+ return parsed;
+}
+
+function parse_float_time(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 * parse_float(value);
+ var hours = parse_integer(float_time_pair[0]);
+ var minutes = parse_integer(float_time_pair[1]);
+ return factor * (hours + (minutes / 60));
+}
+
+function parse_integer(value) {
+ value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), '');
+ var parsed = Number(value);
+ // do not accept not numbers or float values
+ if (isNaN(parsed) || parsed % 1) {
+ throw new Error(_.str.sprintf(core._t("'%s' is not a correct integer"), value));
+ }
+ return parsed;
+}
+
+function parse_monetary(formatted_value) {
+ var l10n = core._t.database.parameters;
+ var value = formatted_value.replace(l10n.thousands_sep, '')
+ .replace(l10n.decimal_point, '.')
+ .match(/([0-9]+(\.[0-9]*)?)/)[1];
+ return parseFloat(value);
+}
+
+function identity(value) {
+ return value;
+}
+
+var result = {
+ format_binary: identity, // todo
+ format_boolean: format_boolean,
+ format_char: format_char,
+ format_date: format_date,
+ format_datetime: format_datetime,
+ format_float: format_float,
+ format_float_time: format_float_time,
+ format_html: identity, // todo
+ format_id: format_id,
+ format_integer: format_integer,
+ format_many2many: format_many2many,
+ format_many2one: format_many2one,
+ format_monetary: format_monetary,
+ format_one2many: identity, // todo
+ format_reference: identity, // todo
+ format_selection: format_selection,
+ format_text: format_char,
+
+ format_field: format_field,
+
+ parse_binary: identity, // todo
+ parse_boolean: identity, // todo
+ parse_char: identity, // todo
+ parse_date: identity, // todo
+ parse_datetime: identity, // todo
+ parse_float: parse_float,
+ parse_float_time: parse_float_time,
+ parse_html: identity, // todo
+ parse_id: identity,
+ parse_integer: parse_integer,
+ parse_many2many: identity, // todo
+ parse_many2one: identity,
+ parse_monetary: parse_monetary,
+ parse_one2many: identity,
+ parse_reference: identity, // todo
+ parse_selection: identity, // todo
+ parse_text: identity, // todo
+
+ parse_field: parse_field,
+
+};
+
+return result;
+
+});
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 00000000000..4282d78faf7
--- /dev/null
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -0,0 +1,1493 @@
+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 concurrency = require('web.concurrency');
+var config = require('web.config');
+var ControlPanel = require('web.ControlPanel');
+var dialogs = require('web.view_dialogs');
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+var KanbanRenderer = require('web.KanbanRenderer');
+var ListRenderer = require('web.ListRenderer');
+var mixins = require('web.mixins');
+var Pager = require('web.Pager');
+var pyeval = require('web.pyeval');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+/**
+ * Abstract widget to handle relational fields
+ */
+var AbstractRelationalField = AbstractField.extend({
+ init: function (parent, name, record) {
+ this._super.apply(this, arguments);
+ // 'domain' and 'context' that should be used by the field widget. It
+ // can come from the attrs, and is mostly useful for rpcs
+ this.domain = this.attrs.domain || this.field.domain || [];
+ this.context = this.attrs.context || this.field.context || {};
+
+ // todo: remove this
+ this.record = record;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reset: function (record) {
+ this.record = record;
+ this._super.apply(this, arguments);
+ },
+});
+
+//------------------------------------------------------------------------------
+// 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("Create a %s"), this.name),
+ size: 'medium',
+ buttons: [{
+ text: _t('Create'),
+ classes: 'btn-primary',
+ click: function () {
+ if (this.$("input").val() !== ''){
+ this.trigger_up('quick_create', { value: this.$('input').val() });
+ this.close();
+ } else {
+ this.$("input").focus();
+ }
+ },
+ }, {
+ text: _t('Create and edit'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ this.trigger_up('search_create_popup', {
+ view_type: 'form',
+ value: this.$('input').val(),
+ });
+ },
+ }, {
+ text: _t('Cancel'),
+ close: true,
+ }],
+ });
+ },
+ start: function () {
+ this.$("p").text(_.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name));
+ this.$("input").val(this.value);
+ },
+});
+
+var FieldMany2One = AbstractRelationalField.extend(mixins.ServicesMixin, {
+ template: 'FieldMany2One',
+ custom_events: {
+ 'quick_create': '_onQuickCreate',
+ 'search_create_popup': '_onSearchCreatePopup',
+ },
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_form_input': '_onInputClick',
+ 'focusout .o_form_input': '_onInputFocusout',
+ 'keyup .o_form_input': '_onInputKeyup',
+ 'click .o_external_button': '_onExternalButtonClick',
+ 'click': '_onClick',
+ }),
+
+ init: function () {
+ this._super.apply(this, arguments);
+ this.limit = 7;
+ this.orderer = new concurrency.DropMisordered();
+ this.can_create = 'can_create' in this.attrs ? this.attrs.can_create : true;
+ this.can_write = 'can_write' in this.attrs ? this.attrs.can_write : true;
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ quick_create: true,
+ });
+ this.m2o_value = field_utils.format_many2one(this.value);
+ },
+ start: function () {
+ // what is 'this.floating' for? add a comment!
+ this.floating = false;
+
+ this.$input = this.$('.o_form_input');
+ this.$external_button = this.$('.o_external_button');
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.$input.focus();
+ setTimeout(this.$input.select.bind(this.$input), 0);
+ },
+ reinitialize: function (value) {
+ this.floating = false;
+ this._setValue(value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _bindAutoComplete: function () {
+ var self = this;
+ this.$input.autocomplete({
+ source: function (req, resp) {
+ self._search(req.term).then(function (result) {
+ resp(result);
+ });
+ },
+ select: function (event, ui) {
+ 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
+ },
+ autoFocus: true,
+ html: true,
+ minLength: 0,
+ delay: 200,
+ });
+ this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
+ this.autocomplete_bound = true;
+ },
+ /**
+ * @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
+ */
+ _focus: function () {
+ if (this.mode === "edit") {
+ this.$input.focus();
+ }
+ },
+ /**
+ * @private
+ * @returns {Array}
+ */
+ _getSearchBlacklist: function () {
+ return [];
+ },
+ /**
+ * @private
+ * @param {string} name
+ */
+ _quickCreate: function (name) {
+ var self = this;
+ var slow_create = this._searchCreatePopup.bind(this, "form", false, this._createContext(name));
+ if (this.nodeOptions.quick_create) {
+ this.trigger_up('name_create', {
+ model: this.field.relation,
+ name: name,
+ context: data.build_context(this.record, this.context),
+ on_success: function (result) {
+ if (self.mode === "edit") {
+ self.reinitialize({id: result[0], display_name: result[1]});
+ }
+ },
+ on_fail: slow_create,
+ });
+ } else {
+ slow_create();
+ }
+ },
+ /**
+ * @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 patner, 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 noone tries to uses this mechanism to do
+ // something else.
+ if (this.field.__always_reload) {
+ value = value.split('\n')[0];
+ }
+ this.$input.val(value);
+ if (!this.autocomplete_bound) {
+ this._bindAutoComplete();
+ }
+ this._updateExternalButton();
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ var value = _.escape((this.m2o_value || "").trim()).split("\n").join("<br/>");
+ this.$el.html(value);
+ if (!this.nodeOptions.no_open) {
+ this.$el.attr('href', '#');
+ this.$el.addClass('o_form_uri');
+ }
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this.m2o_value = field_utils.format_many2one(this.value);
+ },
+ /**
+ * @private
+ * @param {string} search_val
+ * @returns {Deferred}
+ */
+ _search: function (search_val) {
+ var self = this;
+ var def = $.Deferred();
+ this.orderer.add(def);
+
+ var exclusion_domain = [];
+ var blacklisted_ids = this._getSearchBlacklist();
+ if (blacklisted_ids.length > 0) {
+ exclusion_domain.push(['id', 'not in', blacklisted_ids]);
+ }
+ var domain = new data.CompoundDomain(data.build_domain(this.record, this.domain), exclusion_domain);
+ this.trigger_up('name_search', {
+ model: this.field.relation,
+ search_val: search_val,
+ domain: pyeval.eval('domain', domain),
+ operator: 'ilike',
+ limit: this.limit + 1,
+ on_success: function (result) {
+ // possible selections for the m2o
+ var values = _.map(result, function (x) {
+ x[1] = x[1].split("\n")[0];
+ return {
+ label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent,
+ value: x[1],
+ name: x[1],
+ id: x[0],
+ };
+ });
+
+ // search more... if more results than limit
+ if (values.length > self.limit) {
+ values = values.slice(0, self.limit);
+ values.push({
+ label: _t("Search More..."),
+ action: function () {
+ self.trigger_up('name_search', {
+ model: self.field.relation,
+ search_val: search_val,
+ domain: pyeval.eval('domain', domain),
+ operator: 'ilike',
+ limit: 160,
+ on_success: self._searchCreatePopup.bind(self, "search"),
+ });
+ },
+ classname: 'o_m2o_dropdown_option',
+ });
+ }
+ var create_enabled = self.can_create && !self.nodeOptions.no_create;
+ // quick create
+ var raw_result = _.map(result, function (x) { return x[1]; });
+ if (create_enabled && !self.nodeOptions.no_quick_create &&
+ search_val.length > 0 && !_.contains(raw_result, search_val)) {
+ values.push({
+ label: _.str.sprintf(_t('Create "<strong>%s</strong>"'),
+ $('<span />').text(search_val).html()),
+ action: self._quickCreate.bind(self, search_val),
+ classname: 'o_m2o_dropdown_option'
+ });
+ }
+ // create and edit ...
+ if (create_enabled && !self.nodeOptions.no_create_edit) {
+ values.push({
+ label: _t("Create and Edit..."),
+ action: self._searchCreatePopup.bind(self, "form", false, self._createContext(search_val)),
+ classname: 'o_m2o_dropdown_option',
+ });
+ } else if (values.length === 0) {
+ values.push({
+ label: _t("No results to show..."),
+ });
+ }
+
+ def.resolve(values);
+ },
+ });
+
+ return def;
+ },
+ /**
+ * all search/create popup handling
+ *
+ * @private
+ * @param {any} view
+ * @param {any} ids
+ * @param {any} context
+ */
+ _searchCreatePopup: function (view, ids, context) {
+ var self = this;
+ new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, {
+ res_model: this.field.relation,
+ domain: data.build_domain(this.record, this.domain),
+ context: new data.CompoundContext(data.build_context(this.record, this.context), context || {}),
+ title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
+ initial_ids: ids ? _.map(ids, function (x) { return x[0]; }) : undefined,
+ initial_view: view,
+ disable_multiple_selection: true,
+ on_selected: function (element_ids) {
+ self.reinitialize({id: element_ids[0]});
+ self._focus();
+ }
+ })).open();
+ },
+ /**
+ * @private
+ */
+ _updateExternalButton: function () {
+ var has_external_button = !this.nodeOptions.no_open && !this.floating && this.isSet();
+ this.$external_button.toggle(has_external_button);
+ this.$el.toggleClass('o_with_button', has_external_button);
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClick: function (event) {
+ var self = this;
+ if (this.mode === 'readonly' && !this.nodeOptions.no_open) {
+ event.preventDefault();
+ event.stopPropagation();
+ var context = data.build_context(this.record, this.context);
+ var args = [[this.value.res_id], context.eval()];
+ this.performModelRPC(this.field.relation, 'get_formview_action', args)
+ .then(function (action) {
+ self.trigger_up('do_action', {action: action});
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _onExternalButtonClick: function () {
+ if (!this.value) {
+ this.focus();
+ return;
+ }
+ var self = this;
+ var context = data.build_context(this.record, this.context);
+ var model = this.field.relation;
+ var method = 'get_formview_id';
+ var args = [[this.value.res_id], context.eval()];
+ this.performModelRPC(model, method, args).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 () {
+ self.trigger_up('reload', {db_id: self.value.id});
+ },
+ }).open();
+ });
+ },
+ /**
+ * @private
+ */
+ _onInputClick: function () {
+ if (this.$input.autocomplete("widget").is(":visible")) {
+ this.$input.autocomplete("close");
+ } else {
+ this.$input.autocomplete("search", "");
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputFocusout: function () {
+ if (this.can_create && this.floating) {
+ new M2ODialog(this, this.string, this.$input.val()).open();
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputKeyup: function () {
+ if (this.$input.val() === "") {
+ this.reinitialize(false);
+ } else if (this.m2o_value !== this.$input.val()) {
+ this.floating = true;
+ this._updateExternalButton();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onSearchCreatePopup: function (event) {
+ var data = event.data;
+ this._searchCreatePopup(data.view_type, false, this._createContext(data.value));
+ },
+});
+
+var ListFieldMany2One = FieldMany2One.extend({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.text(this.m2o_value);
+ },
+});
+
+var KanbanFieldMany2One = AbstractRelationalField.extend({
+ tagName: 'span',
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2o_value = field_utils.format_many2one(this.value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.text(this.m2o_value);
+ },
+});
+
+//------------------------------------------------------------------------------
+// X2Many widgets
+//------------------------------------------------------------------------------
+
+var FieldX2Many = AbstractRelationalField.extend({
+ tagName: 'div',
+ custom_events: _.extend({}, AbstractRelationalField.prototype.custom_events, {
+ field_changed: '_onFieldChanged',
+ kanban_record_delete: '_onDeleteRecord',
+ list_record_delete: '_onDeleteRecord',
+ add_record: '_onAddRecord',
+ open_record: '_onOpenRecord',
+ toggle_column_order: '_onToggleColumnOrder',
+ }),
+
+ /**
+ * @override
+ */
+ init: function (parent, name, record, options) {
+ this._super.apply(this, arguments);
+ this.operations = [];
+ this.isReadonly = this.mode === 'readonly';
+ this.view = this.field.views.tree || this.field.views.kanban;
+ this.activeActions = {};
+ var arch = this.view && this.view.arch;
+ if (arch) {
+ this.activeActions.create = arch.attrs.create ?
+ JSON.parse(arch.attrs.create) :
+ true;
+ this.editable = arch.attrs.editable;
+ }
+ this.widgets_registry = options && options.widgets_registry;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ return this._renderControlPanel().then(this._super.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This function is meant to be overriden by the One2ManyField.
+ *
+ * @private
+ * @returns {Boolean} true if the 'Add an item' row should be available in
+ * the list renderer, false otherwise
+ */
+ _hasCreateLine: function () {
+ return !this.isReadonly;
+ },
+ /**
+ * Instanciates or updates the adequate renderer.
+ *
+ * @override
+ * @private
+ * @returns {Deferred}
+ */
+ _render: function () {
+ if (!this.view) {
+ return this._super();
+ }
+ if (this.renderer) {
+ this.renderer.updateState(this.value);
+ this.pager.updateState({ size: this.value.count });
+ return $.when();
+ }
+ var arch = this.view.arch;
+ if (arch.tag === 'tree') {
+ this.renderer = new ListRenderer(this, this.value, {
+ arch: arch,
+ mode: this.mode,
+ addCreateLine: this._hasCreateLine(),
+ addTrashIcon: !this.isReadonly,
+ });
+ }
+ if (arch.tag === 'kanban') {
+ var record_options = {
+ editable: false,
+ deletable: false,
+ model: this.value.model, // required by includes of KanbanRecord
+ read_only_mode: this.isReadonly,
+ };
+ this.renderer = new KanbanRenderer(this, this.value, {
+ arch: arch,
+ record_options: record_options
+ });
+ }
+ return this.renderer ? this.renderer.appendTo(this.$el) : this._super();
+ },
+ /**
+ * Instanciates a control panel with the appropriate buttons and a pager.
+ * Prepends the control panel's $el to this widget's $el.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _renderControlPanel: function () {
+ if (!this.view) {
+ return $.when();
+ }
+ var self = this;
+ var defs = [];
+ this.control_panel = new ControlPanel(this, "X2ManyControlPanel");
+ this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
+ single_page_hidden: true,
+ withAccessKey: false,
+ });
+ this.pager.on('pager_changed', this, function (new_state) {
+ this.trigger_up('load', {
+ id: this.value.id,
+ limit: new_state.limit,
+ offset: new_state.current_min - 1,
+ on_success: function (value) {
+ self.value = value;
+ self._render();
+ },
+ });
+ });
+ this._renderButtons();
+ defs.push(this.pager.appendTo($('<div>'))); // start the pager
+ defs.push(this.control_panel.prependTo(this.$el));
+ return $.when.apply($, defs).then(function () {
+ self.control_panel.update({
+ cp_content: {
+ $buttons: self.$buttons,
+ $pager: self.pager.$el,
+ }
+ });
+ });
+ },
+ /**
+ * Renders the buttons and sets this.$buttons.
+ *
+ * @private
+ */
+ _renderButtons: function () {
+ if (!this.isReadonly && this.view.arch.tag === 'kanban') {
+ var options = { create_text: this.nodeOptions.create_text };
+ this.$buttons = $(qweb.render('KanbanView.buttons', options));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks on the 'Add an item' 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}
+ */
+ _onDeleteRecord: function (event) {
+ event.stopPropagation();
+ this._setValue({
+ operation: 'REMOVE',
+ id: event.data.id
+ });
+ },
+ /**
+ * Updates the given record with the changes.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onFieldChanged: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ event.stopPropagation();
+ // changes occured in an editable list
+ var changes = event.data.changes;
+ if (Object.keys(changes).length) {
+ this._setValue({
+ operation: 'UPDATE',
+ id: event.data.dataPointID,
+ data: changes
+ });
+ }
+ },
+ /**
+ * Intercepts the 'open_record' event to edit its data and lets it bubble up
+ * to the form view.
+ *
+ * @private
+ */
+ _onOpenRecord: function (event) {
+ _.extend(event.data, {
+ context: data.build_context(this.record, this.context),
+ domain: data.build_context(this.record, this.domain),
+ form_view: this.field.views && this.field.views.form,
+ readonly: this.mode === 'readonly',
+ string: this.string,
+ });
+ },
+ /**
+ * Adds field name information to the event, so that the view upstream is
+ * aware of which widgets it has to redraw.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onToggleColumnOrder: function (event) {
+ event.data.field = this.name;
+ },
+});
+
+var FieldOne2Many = FieldX2Many.extend({
+ className: 'o_form_field_one2many',
+ supportedFieldTypes: ['one2many'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [event] an event that triggered the reset action
+ * @returns {Deferred}
+ */
+ reset: function (record, event) {
+ var self = this;
+ if (event && event.target === this && self.view.arch.tag === 'tree' && this.editable) {
+ var command = event.data.changes[this.name];
+ if (command.operation === 'UPDATE') {
+ var fieldsChanged = _.keys(command.data);
+ this.renderer.confirmChange(record.data[this.name], command.id, fieldsChanged);
+ return $.when();
+ }
+ }
+ return this._super.apply(this, arguments).then(function () {
+ if (event && event.target === self && self.view.arch.tag === 'tree') {
+ if (event.data.changes[self.name].operation === 'CREATE') {
+ // todo: get last record if editable = bottom
+ var newID = self.value.data[0].id;
+ self.renderer.editRecord(newID);
+ }
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Overrides enable the 'Add an item' feature if the 'create' action is
+ * available.
+ *
+ * @override
+ * @private
+ */
+ _hasCreateLine: function () {
+ return this._super.apply(this, arguments) && this.activeActions.create;
+ },
+ /**
+ * Overrides to only render the buttons if the 'create' action is available.
+ *
+ * @override
+ * @private
+ */
+ _renderButtons: function () {
+ if (this.activeActions.create) {
+ this._super.apply(this, arguments);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a FormViewDialog to allow creating a new record for a one2many.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} event 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 (event) {
+ // we don't want interference with the components upstream.
+ event.stopPropagation();
+
+ if (this.editable) {
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable,
+ });
+ } else {
+ this.trigger_up('add_one2many_record', {
+ domain: data.build_domain(this.record, this.domain),
+ context: data.build_context(this.record, this.context),
+ field: this.field,
+ on_save: function (record) {
+ this._setValue({
+ operation: 'ADD',
+ id: record.id
+ });
+ }.bind(this),
+ });
+ }
+ },
+ /**
+ * 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.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onOpenRecord: function (event) {
+ var self = this;
+ this._super.apply(this, arguments);
+ _.extend(event.data, {
+ on_save: function (record) {
+ // update in a form view opened from the o2m (kanban or non editable list)
+ var relRecord = _.find(self.value.data, function (d) {
+ return d.res_id === record.res_id;
+ });
+ self._setValue({
+ operation: 'UPDATE',
+ id: relRecord.id,
+ data: record.data,
+ });
+ },
+ });
+ },
+});
+
+var FieldMany2Many = FieldX2Many.extend({
+ className: 'o_form_field_many2many',
+ supportedFieldTypes: ['many2many'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ create_text: _t('Add'),
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a SelectCreateDialog.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onAddRecord: function () {
+ var self = this;
+ var domain = data.build_domain(this.record, this.domain);
+ new dialogs.SelectCreateDialog(this, {
+ res_model: this.field.relation,
+ domain: new data.CompoundDomain(domain, ["!", ["id", "in", this.value.res_ids]]),
+ context: data.build_context(this.record, this.context),
+ title: _t("Add: ") + this.string,
+ no_create: this.nodeOptions.no_create || !this.activeActions.create,
+ on_selected: function (res_ids) {
+ var new_ids = _.difference(res_ids, self.value.res_ids);
+ if (new_ids.length) {
+ var values = _.map(new_ids, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values
+ });
+ }
+ }
+ }).open();
+ },
+ /**
+ * Overrides the handler to reload the field when the subrecord is saved.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onOpenRecord: function (event) {
+ this._super.apply(this, arguments);
+ _.extend(event.data, {
+ on_saved: this.trigger_up.bind(this, 'reload', {db_id: event.data.id}),
+ });
+ },
+});
+
+var FieldMany2ManyTags = AbstractRelationalField.extend({
+ tag_template: "FieldMany2ManyTag",
+ className: "o_form_field o_form_field_many2manytags",
+ replace_element: true,
+ supportedFieldTypes: ['many2many'],
+ custom_events: {
+ field_changed: '_onFieldChanged',
+ move_next: '_onMoveNext',
+ },
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_delete': '_onDeleteTag',
+ 'keydown .o_form_field_many2one input': '_onKeyDown',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.many2one.focus();
+ },
+ isSet: function () {
+ return !!this.value && this.value.count;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} data
+ */
+ _addTag: function (data) {
+ if (!_.contains(this.value.res_ids, data.id)) {
+ this._setValue({
+ operation: 'ADD_M2M',
+ ids: data
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {any} id
+ */
+ _removeTag: function (id) {
+ var record = _.findWhere(this.value.data, {res_id: id});
+ this._setValue({
+ operation: 'REMOVE',
+ id: record.id
+ });
+ },
+ /**
+ * @private
+ */
+ _renderEdit: function () {
+ var self = this;
+ this._renderTags();
+ this.many2one = new FieldMany2One(this, this.name, this.record, {
+ idForLabel: this.idForLabel,
+ mode: 'edit',
+ });
+ // 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.nodeOptions.no_open = true;
+ this.many2one._getSearchBlacklist = function () {
+ return self.value.res_ids;
+ };
+ this.many2one.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderTags();
+ },
+ /**
+ * @private
+ */
+ _renderTags: function () {
+ var elements = this.value ? _.pluck(this.value.data, 'data') : [];
+ this.$el.html(qweb.render(this.tag_template, {
+ elements: elements,
+ readonly: this.mode === "readonly",
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteTag: function (event) {
+ this._removeTag($(event.target).parent().data('id'));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ event.stopPropagation();
+ var new_value = event.data.changes[this.name];
+ if (new_value) {
+ this._addTag(new_value);
+ this.many2one.reinitialize(false);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onKeyDown: function (event) {
+ if($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) {
+ var $badges = this.$('.badge');
+ if($badges.length) {
+ this._removeTag($badges.last().data('id'));
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onMoveNext: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ // Intercept event triggered up by the many2one, to prevent triggering it twice
+ event.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+});
+
+var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ events: _.extend({}, FieldMany2ManyTags.prototype.events, {
+ 'click .badge': '_onOpenColorPicker',
+ 'mousedown .o_colorpicker span': '_onUpdateColor',
+ 'focusout .o_colorpicker': '_onCloseColorPicker',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCloseColorPicker: function (){
+ this.$color_picker.remove();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onOpenColorPicker: function (event) {
+ var tag_id = $(event.currentTarget).data('id');
+ var tag = _.findWhere(this.value.data, { res_id: tag_id });
+ if (tag && 'color' in tag.data) { // if there is a color field on the related model
+ this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', {
+ 'widget': this,
+ 'tag_id': tag_id,
+ }));
+
+ $(event.currentTarget).append(this.$color_picker);
+ this.$color_picker.dropdown('toggle');
+ this.$color_picker.attr("tabindex", 1).focus();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onUpdateColor: function (event) {
+ event.preventDefault();
+ var self = this;
+ var color = $(event.currentTarget).data('color');
+ var id = $(event.currentTarget).data('id');
+ var tag = self.$("span.badge[data-id='" + id + "']");
+ var current_color = tag.data('color');
+
+ if (color === current_color) { return; }
+
+ this.trigger_up('field_changed', {
+ dataPointID: _.findWhere(this.value.data, {res_id: id}).id,
+ changes: {
+ color: color,
+ },
+ force_save: true,
+ });
+ },
+});
+
+var FieldMany2ManyCheckBoxes = AbstractRelationalField.extend({
+ template: 'FieldMany2ManyCheckBoxes',
+ events: _.extend({}, AbstractRelationalField.prototype.events, {
+ change: '_onChange',
+ }),
+ supportedFieldTypes: ['many2many'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2mValues = this.field.__many2many_information;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ this._super.apply(this, arguments);
+ _.each(this.value, function (id) {
+ self.$('input[data-record-id="' + id + '"]').prop('checked', true);
+ });
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$("input").prop("disabled", true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChange: function () {
+ var ids = _.map(this.$('input:checked'), function (input) {
+ return $(input).data("record-id");
+ });
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: ids,
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Widgets handling both basic and relational fields (selection and Many2one)
+//------------------------------------------------------------------------------
+
+var FieldStatus = AbstractField.extend({
+ template: "FieldStatus",
+ className: 'o_statusbar_status',
+ events: {
+ 'click .o_arrow_button': '_onClickStage',
+ },
+ supportedFieldTypes: ['selection', 'many2one'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+ /**
+ * @private
+ */
+ _setState: function () {
+ if (this.field.type === 'many2one') {
+ this.status_information = this.field.__status_information;
+ var status = _.findWhere(this.status_information, {id: this.value.res_id});
+ if (status) {
+ status.selected = true;
+ }
+ } else {
+ var self = this;
+ this.status_information = _.map(this.field.selection, function (val) {
+ return { id: val[0], display_name: val[1], selected: val[0] === self.value };
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ var visible = this.attrs.statusbar_visible;
+ var selection_folded = [];
+ var selection_unfolded = [];
+ _.each(this.status_information, function (info) {
+ if (info.selected && (info.fold || visible && visible.indexOf(info.display_name) === -1)) {
+ selection_folded.push(info);
+ } else {
+ selection_unfolded.push(info);
+ }
+ });
+ var mobile = config.device.size_class <= config.device.SIZES.XS;
+ // grep: FieldStatus.content.mobile, FieldStatus.content.desktop
+ var content = qweb.render("FieldStatus.content." + (mobile ? 'mobile' : 'desktop'), {
+ selection_folded: selection_folded.reverse(),
+ selection_unfolded: selection_unfolded.reverse(),
+ clickable: !!this.attrs.clickable,
+ });
+ this.$el.html(content);
+ this.$('.disabled').prop('disabled', true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Note that this function is debounced...
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClickStage: _.debounce(function (event) {
+ var $stage = $(event.currentTarget);
+ var val;
+ if (this.field.type === "many2one") {
+ val = parseInt($stage.data("value"), 10);
+ } else {
+ val = $stage.data("value");
+ }
+ if (val) {
+ this._setValue(val);
+ }
+ }, 300, true),
+});
+
+/**
+ * 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({
+ template: 'FieldSelection',
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': '_onInput',
+ }),
+ replace_element: true,
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.values = [];
+ if (this.field.type === 'many2one') {
+ this.values = this.field.__selection_information;
+ } else {
+ this.values = _.reject(this.field.selection, function (v) {
+ return v[0] === false && v[1] === '';
+ });
+ }
+ this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Format the value to a valid string
+ *
+ * @override
+ * @private
+ * @param {any} value
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ if (this.field.type === 'many2one') {
+ var options = _.extend({}, this.nodeOptions);
+ return field_utils.format_many2one(value, this.field, this.recordData, options);
+ } else {
+ return this._super(value);
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this.$el.empty();
+ for (var i = 0 ; i < this.values.length ; i++) {
+ this.$el.append($('<option/>', {
+ value: JSON.stringify(this.values[i][0]),
+ html: this.values[i][1]
+ }));
+ }
+ this.$el.val(JSON.stringify(this._parseValue(this.value)));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty().html(this._formatValue(this.value));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ if (this.field.type === 'many2one') {
+ this.value = this.value.data.id;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * The small slight difficulty is that we have to set the value differently
+ * depending on the field type.
+ * @private
+ */
+ _onInput: 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({
+ template: 'FieldRadio',
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ }),
+ /**
+ * @constructs FieldRadio
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.field.__status_information, function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ this.unique_id = _.uniqueId("radio");
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {boolean} always true
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this.$("input").prop("checked", false);
+ var key;
+ if (this.field.type === 'many2one') {
+ key = this._parseValue(this.value);
+ } else {
+ key = this.value;
+ }
+ this.$('input[data-value="' + key + '"]').prop('checked', true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onInputClick: function (event) {
+ var res_id = $(event.target).data('value');
+ 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);
+ }
+ },
+});
+
+return {
+ FieldMany2One: FieldMany2One,
+ KanbanFieldMany2One: KanbanFieldMany2One,
+ ListFieldMany2One: ListFieldMany2One,
+
+ FieldOne2Many: FieldOne2Many,
+
+ FieldMany2Many: FieldMany2Many,
+ FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes,
+ FieldMany2ManyTags: FieldMany2ManyTags,
+ FormFieldMany2ManyTags: FormFieldMany2ManyTags,
+
+ FieldRadio: FieldRadio,
+ FieldSelection: FieldSelection,
+ FieldStatus: FieldStatus,
+};
+
+});
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 00000000000..8d72cecf391
--- /dev/null
+++ b/addons/web/static/src/js/fields/upgrade_fields.js
@@ -0,0 +1,177 @@
+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 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 {Deferred}
+ */
+ _confirmUpgrade: function () {
+ var args = [[["share", "=", false]]];
+ return this.performModelRPC('res.users', 'search_count', args).then(function (data) {
+ framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data);
+ });
+ },
+ /**
+ * This function is meant to be overriden to insert the 'Enterprise' label
+ * JQuery node at the right place.
+ *
+ * @abstract
+ * @private
+ * @param {JQuery} the 'Enterprise' label to insert
+ */
+ _insertEnterpriseLabel: function ($enterprise_label) {},
+ /**
+ * Opens the Upgrade dialog.
+ *
+ * @private
+ */
+ _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,
+ },
+ ];
+
+ 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': "label label-primary oe_inline"
+ }));
+ },
+ /**
+ * This function is meant to be overriden to reset the $el to its initial
+ * state.
+ *
+ * @abstract
+ * @private
+ */
+ _resetValue: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {MouseEvent}
+ */
+ _onInputClicked: function (event) {
+ if ($(event.currentTarget).prop("checked")) {
+ this._openDialog().on('closed', this, this._resetValue.bind(this));
+ }
+ },
+
+};
+
+var UpgradeBoolean = FieldBoolean.extend(AbstractFieldUpgrade, {
+ events: _.extend({}, FieldBoolean.prototype.events, {
+ 'click input': '_onInputClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _insertEnterpriseLabel: function ($enterprise_label) {
+ this.$el.append('&nbsp;').append($enterprise_label);
+ },
+ /**
+ * @override
+ */
+ _resetValue: function () {
+ this.$input.prop("checked", false).change();
+ },
+});
+
+var UpgradeRadio = FieldRadio.extend(AbstractFieldUpgrade, {
+ events: _.extend({}, FieldRadio.prototype.events, {
+ 'click input:last': '_onInputClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _insertEnterpriseLabel: function ($enterprise_label) {
+ this.$('label').last().append('&nbsp;').append($enterprise_label);
+ },
+ /**
+ * @override
+ */
+ _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/framework/crash_manager.js b/addons/web/static/src/js/framework/crash_manager.js
deleted file mode 100644
index af982ecd15d..00000000000
--- a/addons/web/static/src/js/framework/crash_manager.js
+++ /dev/null
@@ -1,211 +0,0 @@
-odoo.define('web.CrashManager', function (require) {
-"use strict";
-
-var ajax = require('web.ajax');
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-
-var QWeb = core.qweb;
-var _t = core._t;
-var _lt = core._lt;
-
-var map_title ={
- user_error: _lt('Warning'),
- warning: _lt('Warning'),
- access_error: _lt('Access Error'),
- missing_error: _lt('Missing Record'),
- validation_error: _lt('Validation Error'),
- except_orm: _lt('Global Business Error'),
- access_denied: _lt('Access Denied'),
-};
-
-var CrashManager = core.Class.extend({
- init: function() {
- this.active = true;
- },
- enable: function () {
- this.active = true;
- },
- disable: function () {
- this.active = false;
- },
- rpc_error: function(error) {
- var self = this;
- if (!this.active) {
- return;
- }
- if (this.connection_lost) {
- return;
- }
- if (error.code === -32098) {
- core.bus.trigger('connection_lost');
- this.connection_lost = true;
- var timeinterval = setInterval(function() {
- ajax.jsonRpc('/web/webclient/version_info').then(function() {
- clearInterval(timeinterval);
- core.bus.trigger('connection_restored');
- self.connection_lost = false;
- });
- }, 2000);
- return;
- }
- var handler = core.crash_registry.get(error.data.name, true);
- if (handler) {
- new (handler)(this, error).display();
- return;
- }
- if (error.data.name === "openerp.http.SessionExpiredException" || error.data.name === "werkzeug.exceptions.Forbidden") {
- this.show_warning({type: _t("Odoo Session Expired"), data: {message: _t("Your Odoo session expired. Please refresh the current web page.")}});
- return;
- }
- if (_.has(map_title, error.data.exception_type)) {
- if(error.data.exception_type === 'except_orm'){
- if(error.data.arguments[1]) {
- error = _.extend({}, error,
- {
- data: _.extend({}, error.data,
- {
- message: error.data.arguments[1],
- title: error.data.arguments[0] !== 'Warning' ? (" - " + error.data.arguments[0]) : '',
- })
- });
- }
- else {
- error = _.extend({}, error,
- {
- data: _.extend({}, error.data,
- {
- message: error.data.arguments[0],
- title: '',
- })
- });
- }
- }
- else {
- error = _.extend({}, error,
- {
- data: _.extend({}, error.data,
- {
- message: error.data.arguments[0],
- title: map_title[error.data.exception_type] !== 'Warning' ? (" - " + map_title[error.data.exception_type]) : '',
- })
- });
- }
-
- this.show_warning(error);
- //InternalError
-
- } else {
- this.show_error(error);
- }
- },
- show_warning: function(error) {
- if (!this.active) {
- return;
- }
- new Dialog(this, {
- size: 'medium',
- title: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"),
- subtitle: error.data.title,
- $content: $(QWeb.render('CrashManager.warning', {error: error}))
- }).open();
- },
- show_error: function(error) {
- if (!this.active) {
- return;
- }
- var dialog = new Dialog(this, {
- title: _.str.capitalize(error.type || error.message) || _t("Odoo Error"),
- $content: $(QWeb.render('CrashManager.error', {error: error}))
- }).open();
-
- // When the dialog opens, initialize the copy feature and destroy it when the dialog is closed
- var $clipboardBtn;
- var clipboard;
- dialog.opened(function () {
- $clipboardBtn = dialog.$(".o_clipboard_button");
- $clipboardBtn.tooltip({title: _t("Copied !"), trigger: "manual", placement: "left"});
- clipboard = new window.Clipboard($clipboardBtn[0], {
- text: function () {
- return (_t("Error") + ":\n" + error.message + "\n\n" + error.data.debug).trim();
- }
- });
- clipboard.on("success", function (e) {
- _.defer(function () {
- $clipboardBtn.tooltip("show");
- _.delay(function () {
- $clipboardBtn.tooltip("hide");
- }, 800);
- });
- });
- });
- dialog.on("closed", this, function () {
- $clipboardBtn.tooltip("destroy");
- clipboard.destroy();
- });
-
- // 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;
- });
- },
- show_message: function(exception) {
- this.show_error({
- type: _t("Odoo Client Error"),
- message: exception,
- data: {debug: ""}
- });
- },
-});
-
-/**
- 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 = Dialog.extend(ExceptionHandler, {
- init: function(parent, error) {
- this._super(parent);
- this.error = error;
- },
- display: function() {
- var self = this;
- var error = this.error;
- error.data.message = error.data.arguments[0];
-
- new Dialog(this, {
- size: 'medium',
- title: _.str.capitalize(error.type) || _t("Odoo Warning"),
- buttons: [
- {text: error.data.arguments[2], classes : "btn-primary", click: function() {
- window.location.href = '#action='+error.data.arguments[1];
- self.destroy();
- }},
- {text: _t("Cancel"), click: function() { self.destroy(); }, close: true}
- ],
- $content: QWeb.render('CrashManager.warning', {error: error}),
- }).open();
- }
-});
-
-core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler);
-
-return CrashManager;
-});
diff --git a/addons/web/static/src/js/framework/data_manager.js b/addons/web/static/src/js/framework/data_manager.js
deleted file mode 100644
index 55a4b4da15b..00000000000
--- a/addons/web/static/src/js/framework/data_manager.js
+++ /dev/null
@@ -1,280 +0,0 @@
-odoo.define('web.DataManager', function (require) {
-"use strict";
-
-var core = require('web.core');
-var Model = require('web.DataModel');
-var session = require('web.session');
-var utils = require('web.utils');
-
-function traverse(tree, f) {
- if (f(tree)) {
- _.each(tree.children, function(c) { traverse(c, f); });
- }
-}
-
-return core.Class.extend({
- init: function () {
- this.Filters = new Model('ir.filters');
- this._init_cache();
- },
-
- _init_cache: function () {
- this._cache = {
- actions: {},
- fields_views: {},
- fields: {},
- filters: {},
- views: {},
- };
- },
-
- /**
- * Invalidates the whole cache
- * Suggestion: could be refined to invalidate some part of the cache
- */
- invalidate: function () {
- 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 {Deferred} 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 (!this._cache.actions[key]) {
- this._cache.actions[key] = session.rpc("/web/action/load", {
- 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;
- }, this._invalidate.bind(this, this._cache.actions, key));
- }
-
- return this._cache.actions[key].then(function (action) {
- return $.extend(true, {}, action);
- });
- },
-
- /**
- * Loads various information concerning views: fields_view for each view,
- * and optionally filters and fields.
- *
- * @param {Object} [dataset] the dataset for which the views are loaded
- * @param {Array} [views_descr] array of [view_id, view_type]
- * @param {Object} [options] dictionnary of various options:
- * - options.load_filters: whether or not to load the filters,
- * - options.action_id: the action_id (required to load filters),
- * - options.load_fields: whether or not to load the fields,
- * - options.toolbar: whether or not a toolbar will be displayed,
- * @return {Deferred} resolved with the requested views information
- */
- load_views: function (dataset, views_descr, options) {
- var self = this;
- var model = dataset.model;
- var context = dataset.get_context();
- var key = this._gen_key(model, views_descr, options || {}, context);
-
- if (!this._cache.views[key]) {
- // Don't load fields or filters if already in cache
- options.load_fields = options.load_fields && !this._cache.fields[model];
- var filters_key;
- if (options.load_filters) {
- filters_key = this._gen_key(model, options.action_id);
- options.load_filters = !this._cache.filters[filters_key];
- }
-
- this._cache.views[key] = dataset.call('load_views', {
- views: views_descr,
- options: options,
- context: context,
- }).then(function (result) {
- // Postprocess fields_views and insert them into the fields_views cache
- result.fields_views = _.mapObject(result.fields_views, self._postprocess_fvg.bind(self)); // FIXME: lazy postprocess?
- _.each(views_descr, function (view_descr) {
- var toolbar = options.toolbar && view_descr[1] !== 'search';
- var fv_key = self._gen_key(model, view_descr[0], view_descr[1], toolbar, context);
- self._cache.fields_views[fv_key] = $.when(result.fields_views[view_descr[1]]);
- });
-
- // Insert filters, if any, into the filters cache
- if (result.filters) {
- self._cache.filters[filters_key] = $.when(result.filters);
- }
-
- // Insert fields, if any, into the fields cache
- if (result.fields) {
- self._cache.fields[model] = $.when(result.fields);
- }
-
- return result.fields_views;
- }, this._invalidate.bind(this, this._cache.views, key));
- }
-
- return this._cache.views[key];
- },
-
- /**
- * Loads the fields_view of a given model, view_type and optional view_id.
- *
- * @param {Object} [dataset] the dataset for which the fields_view is loaded
- * @param {int} [view_id] the id of the view (optional)
- * @param {string} [view_type] the type of the view
- * @param {Boolean} [toolbar] whether or not a toolbar will be displayed
- * @return {Deferred} resolved with the requested fields_view
- */
- load_fields_view: function (dataset, view_id, view_type, toolbar) {
- var key = this._gen_key(dataset.model, view_id, view_type, toolbar, dataset.get_context());
- if (!this._cache.fields_views[key]) {
- this._cache.fields_views[key] = dataset.call('fields_view_get', {
- view_id: view_id,
- view_type: view_type,
- toolbar: toolbar,
- context: dataset.get_context(),
- }).then(this._postprocess_fvg.bind(this), this._invalidate.bind(this, this._cache.fields_views, key));
- }
- return this._cache.fields_views[key];
- },
-
- /**
- * Loads the fields of a given model.
- *
- * @param {Object} [dataset] the dataset for which the fields are loaded
- * @return {Deferred} resolved with the requested fields
- */
- load_fields: function (dataset) {
- if (!this._cache.fields[dataset.model]) {
- this._cache.fields[dataset.model] = dataset.call('fields_get', {
- context: dataset.get_context(),
- }).fail(this._invalidate.bind(this, this._cache.fields, dataset.model));
- }
- return this._cache.fields[dataset.model];
- },
-
- /**
- * Loads the filters of a given model and optional action id.
- *
- * @param {Object} [dataset] the dataset for which the filters are loaded
- * @param {int} [action_id] the id of the action (optional)
- * @return {Deferred} resolved with the requested filters
- */
- load_filters: function (dataset, action_id) {
- var key = this._gen_key(dataset.model, action_id);
- if (!this._cache.filters[key]) {
- this._cache.filters[key] = this.Filters.call('get_filters', [dataset.model, action_id], {
- context: dataset.get_context(),
- }).fail(this._invalidate.bind(this, this._cache.filters, key));
- }
- return this._cache.filters[key];
- },
-
- /**
- * Calls 'create_or_replace' on 'ir_filters'.
- *
- * @param {Object} [filter] the filter description
- * @return {Deferred} resolved with the id of the created or replaced filter
- */
- create_filter: function (filter) {
- var self = this;
- return this.Filters
- .call('create_or_replace', [filter])
- .then(function (filter_id) {
- var key = [
- filter.model_id,
- filter.action_id || false,
- ].join(',');
- self._invalidate(self._cache.filters, key);
- return filter_id;
- });
- },
-
- /**
- * Calls 'unlink' on 'ir_filters'.
- *
- * @param {Object} [filter] the description of the filter to remove
- * @return {Deferred}
- */
- delete_filter: function (filter) {
- var self = this;
- return this.Filters
- .call('unlink', [filter.id])
- .then(function () {
- self._cache.filters = {}; // invalidate cache
- });
- },
-
- /**
- * Private function that postprocesses fields_view (mainly parses the arch attribute)
- */
- _postprocess_fvg: function (fields_view) {
- var self = this;
-
- // Parse and process arch
- var doc = $.parseXML(fields_view.arch).documentElement;
- var fields = fields_view.fields;
- fields_view.arch = utils.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban'));
- traverse(fields_view.arch, function(node) {
- if (typeof node === 'string') {
- return false;
- }
- if (node.tag === 'field') {
- fields[node.attrs.name].__attrs = node.attrs;
- if (fields[node.attrs.name].type === 'many2one') { // FIXME: this shouldn't be done here
- if (node.attrs.widget === 'statusbar' || node.attrs.widget === 'radio') {
- fields[node.attrs.name].__fetch_status = true;
- } else if (node.attrs.widget === 'selection') {
- fields[node.attrs.name].__fetch_selection = true;
- }
- }
- if (node.attrs.widget === 'many2many_checkboxes') {
- fields[node.attrs.name].__fetch_many2manys = true;
- }
- return false;
- }
- return node.tag !== 'arch';
- });
-
- // Special case for id's
- if ('id' in fields_view.fields) {
- var id_field = fields_view.fields.id;
- id_field.original_type = id_field.type;
- id_field.type = 'id';
- }
-
- // Process inner views (one2manys)
- _.each(fields_view.fields, function(field) {
- _.each(field.views || {}, function(view) {
- self._postprocess_fvg(view);
- });
- });
-
- return fields_view;
- },
-
- /**
- * 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(',');
- },
-
- /**
- * Private function that invalidates a cache entry
- */
- _invalidate: function (cache, key) {
- delete cache[key];
- },
-});
-
-});
diff --git a/addons/web/static/src/js/framework/dom_utils.js b/addons/web/static/src/js/framework/dom_utils.js
deleted file mode 100644
index d0beff2988b..00000000000
--- a/addons/web/static/src/js/framework/dom_utils.js
+++ /dev/null
@@ -1,68 +0,0 @@
-odoo.define('web.dom_utils', function (require) {
-"use strict";
-
-var core = require('web.core');
-
-/**
- * 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.
- */
-function autoresize ($textarea, options) {
- if ($textarea.data("auto_resize")) {
- return;
- }
- options = options || {};
-
- var $fixed_text_area;
- var min_height = (options && options.min_height) || 50;
- if (!$fixed_text_area) {
- $fixed_text_area = $('<textarea disabled>').css({
- position: 'absolute',
- opacity: 0,
- height: 10,
- top: -10000,
- left: -10000,
- });
- $fixed_text_area.addClass($textarea[0].className);
- $fixed_text_area.insertAfter($textarea);
- $fixed_text_area.data("auto_resize", true);
- }
-
- var style = window.getComputedStyle($textarea[0], null);
- if (style.resize === 'vertical') {
- $textarea[0].style.resize = 'none';
- } else if (style.resize === 'both') {
- $textarea[0].style.resize = 'horizontal';
- }
- resize();
- $textarea.data("auto_resize", true);
-
- $textarea.on('input focus', resize);
- if (options.parent) {
- core.bus.on('DOM_updated', options.parent, resize);
- core.bus.on('view_shown', options.parent, resize);
- }
-
- function resize () {
- var heightOffset;
- var style = window.getComputedStyle($textarea[0], null);
- if (style.boxSizing === 'content-box') {
- heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom));
- } else {
- heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
- }
- $fixed_text_area.width($textarea.width());
- $fixed_text_area.val($textarea.val());
- var height = $fixed_text_area[0].scrollHeight;
- $textarea.css({height: Math.max(height + heightOffset, min_height)});
- }
-}
-
-return {
- autoresize: autoresize,
-};
-
-});
diff --git a/addons/web/static/src/js/framework/domain_utils.js b/addons/web/static/src/js/framework/domain_utils.js
deleted file mode 100644
index 8cefb7cabbe..00000000000
--- a/addons/web/static/src/js/framework/domain_utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-odoo.define("web.domainUtils", function (require) {
-"use strict";
-
-var pyeval = require("web.pyeval");
-
-function domainToString(domain) {
- if (_.isString(domain)) return domain;
- return JSON.stringify(domain || []).replace(/false/g, "False").replace(/true/g, "True");
-}
-function stringToDomain(domain) {
- if (!_.isString(domain)) return domain;
- return pyeval.eval("domain", domain || "[]");
-}
-
-return {
- domainToString: domainToString,
- stringToDomain: stringToDomain,
-};
-});
diff --git a/addons/web/static/src/js/framework/misc.js b/addons/web/static/src/js/framework/misc.js
deleted file mode 100644
index e083ee25b22..00000000000
--- a/addons/web/static/src/js/framework/misc.js
+++ /dev/null
@@ -1,438 +0,0 @@
-odoo.define('web.framework', function (require) {
-"use strict";
-
-var core = require('web.core');
-var crash_manager = require('web.crash_manager');
-var ajax = require('web.ajax');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-
-var messages_by_seconds = function() {
- return [
- [0, _t("Loading...")],
- [20, _t("Still loading...")],
- [60, _t("Still loading...<br />Please be patient.")],
- [120, _t("Don't leave yet,<br />it's still loading...")],
- [300, _t("You may not believe it,<br />but the application is actually loading...")],
- [420, _t("Take a minute to get a coffee,<br />because it's loading...")],
- [3600, _t("Maybe you should consider reloading the application by pressing F5...")]
- ];
-};
-
-var Throbber = Widget.extend({
- template: "Throbber",
- start: function() {
- this.start_time = new Date().getTime();
- this.act_message();
- },
- act_message: function() {
- var self = this;
- setTimeout(function() {
- if (self.isDestroyed())
- return;
- var seconds = (new Date().getTime() - self.start_time) / 1000;
- var mes;
- _.each(messages_by_seconds(), function(el) {
- if (seconds >= el[0])
- mes = el[1];
- });
- self.$(".oe_throbber_message").html(mes);
- self.act_message();
- }, 1000);
- },
-});
-
-/** Setup blockui */
-if ($.blockUI) {
- $.blockUI.defaults.baseZ = 1100;
- $.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
- $.blockUI.defaults.css.border = '0';
- $.blockUI.defaults.css["background-color"] = '';
-}
-
-
-var throbbers = [];
-
-function blockUI () {
- var tmp = $.blockUI.apply($, arguments);
- var throbber = new Throbber();
- throbbers.push(throbber);
- throbber.appendTo($(".oe_blockui_spin_container"));
- $('body').addClass('o_ui_blocked');
- return tmp;
-}
-
-function unblockUI () {
- _.invoke(throbbers, 'destroy');
- throbbers = [];
- $('body').removeClass('o_ui_blocked');
- return $.unblockUI.apply($, arguments);
-}
-
-/**
- * Redirect to url by replacing window.location
- * If wait is true, sleep 1s and wait for the server i.e. after a restart.
- */
-function redirect (url, wait) {
- // Dont display a dialog if some xmlhttprequest are in progress
- crash_manager.disable();
-
- var load = function() {
- var old = "" + window.location;
- var old_no_hash = old.split("#")[0];
- var url_no_hash = url.split("#")[0];
- location.assign(url);
- if (old_no_hash === url_no_hash) {
- location.reload(true);
- }
- };
-
- var wait_server = function() {
- ajax.rpc("/web/webclient/version_info", {}).done(load).fail(function() {
- setTimeout(wait_server, 250);
- });
- };
-
- if (wait) {
- setTimeout(wait_server, 1000);
- } else {
- load();
- }
-}
-
-// * Client action to reload the whole interface.
-// * If params.menu_id, it opens the given menu entry.
-// * If params.wait, reload will wait the openerp server to be reachable before reloading
-
-function Reload(parent, action) {
- var params = action.params || {};
- var menu_id = params.menu_id || false;
- var l = window.location;
-
- var sobj = $.deparam(l.search.substr(1));
- if (params.url_search) {
- sobj = _.extend(sobj, params.url_search);
- }
- var search = '?' + $.param(sobj);
-
- var hash = l.hash;
- if (menu_id) {
- hash = "#menu_id=" + menu_id;
- }
- var url = l.protocol + "//" + l.host + l.pathname + search + hash;
-
- redirect(url, params.wait);
-}
-
-core.action_registry.add("reload", Reload);
-
-
-/**
- * Client action to go back home.
- */
-function Home (parent, action) {
- var url = '/' + (window.location.search || '');
- redirect(url, action && action.params && action.params.wait);
-}
-core.action_registry.add("home", Home);
-
-/**
- * Client action to go back in breadcrumb history.
- * If can't go back in history stack, will go back to home.
- */
-function HistoryBack (parent) {
- parent.history_back().fail(function () {
- Home(parent);
- });
-}
-core.action_registry.add("history_back", HistoryBack);
-
-function login() {
- redirect('/web/login');
-}
-core.action_registry.add("login", login);
-
-function logout() {
- redirect('/web/session/logout');
- return $.Deferred();
-}
-core.action_registry.add("logout", logout);
-
-/**
- * Client action to refresh the session context (making sure
- * HTTP requests will have the right one) then reload the
- * whole interface.
- */
-function ReloadContext (parent, action) {
- // side-effect of get_session_info is to refresh the session context
- ajax.rpc("/web/session/get_session_info", {}).then(function() {
- Reload(parent, action);
- });
-}
-core.action_registry.add("reload_context", ReloadContext);
-
-
-// nvd3 customization
-//-------------------------------------------------------------------------
-if ('nv' in window) {
- nv.dev = false; // sets nvd3 library in production mode
-
- // monkey patch nvd3 to allow removing eventhandler on windowresize events
- // see https://github.com/novus/nvd3/pull/396 for more details
-
- // Adds a resize listener to the window.
- nv.utils.onWindowResize = function(fun) {
- if (fun === null) return;
- window.addEventListener('resize', fun);
- };
-
- // Backwards compatibility with current API.
- nv.utils.windowResize = nv.utils.onWindowResize;
-
- // Removes a resize listener from the window.
- nv.utils.offWindowResize = function(fun) {
- if (fun === null) return;
- window.removeEventListener('resize', fun);
- };
-
- // monkey patch nvd3 to prevent crashes when user changes view and nvd3 tries
- // to remove tooltips after 500 ms... seriously nvd3, what were you thinking?
- nv.tooltip.cleanup = function () {
- $('.nvtooltip').remove();
- };
-
- // monkey patch nvd3 to prevent it to display a tooltip (position: absolute) with
- // a negative `top`; with this patch the highest tooltip's position is still in the
- // graph
- var originalCalcTooltipPosition = nv.tooltip.calcTooltipPosition;
- nv.tooltip.calcTooltipPosition = function () {
- var container = originalCalcTooltipPosition.apply(this, arguments);
- container.style.top = container.style.top.split('px')[0] < 0 ? 0 + 'px' : container.style.top;
- return container;
- };
-}
-
-// Bootstrap customization
-//-------------------------------------------------------------------------
-/* Bootstrap defaults overwrite */
-$.fn.tooltip.Constructor.DEFAULTS.placement = 'auto top';
-$.fn.tooltip.Constructor.DEFAULTS.html = true;
-$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover focus click';
-$.fn.tooltip.Constructor.DEFAULTS.container = 'body';
-$.fn.tooltip.Constructor.DEFAULTS.delay = { show: 1000, hide: 0 };
-//overwrite bootstrap tooltip method to prevent showing 2 tooltip at the same time
-var bootstrap_show_function = $.fn.tooltip.Constructor.prototype.show;
-$.fn.modal.Constructor.prototype.enforceFocus = function () { };
-$.fn.tooltip.Constructor.prototype.show = function () {
- $('.tooltip').remove();
- //the following fix the bug when using placement
- //auto and the parent element does not exist anymore resulting in
- //an error. This should be remove once we updade bootstrap to a version that fix the bug
- //edit: bug has been fixed here : https://github.com/twbs/bootstrap/pull/13752
- var e = $.Event('show.bs.' + this.type);
- var inDom = $.contains(document.documentElement, this.$element[0]);
- if (e.isDefaultPrevented() || !inDom) return;
- return bootstrap_show_function.call(this);
-};
-
-// jquery customization
-//-------------------------------------------------------------------------
-jQuery.expr[":"].Contains = jQuery.expr.createPseudo(function(arg) {
- return function( elem ) {
- return jQuery(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
- };
-});
-
-/** Custom jQuery plugins */
-$.fn.getAttributes = function() {
- var o = {};
- if (this.length) {
- for (var attr, i = 0, attrs = this[0].attributes, l = attrs.length; i < l; i++) {
- attr = attrs.item(i);
- o[attr.nodeName] = attr.value;
- }
- }
- return o;
-};
-$.fn.openerpClass = function(additionalClass) {
- // This plugin should be applied on top level elements
- additionalClass = additionalClass || '';
- if (!!$.browser.msie) {
- additionalClass += ' openerp_ie';
- }
- return this.each(function() {
- $(this).addClass('openerp ' + additionalClass);
- });
-};
-$.fn.openerpBounce = function() {
- return this.each(function() {
- $(this).css('box-sizing', 'content-box').effect('bounce', {distance: 18, times: 5}, 250);
- });
-};
-
-// jquery autocomplete tweak to allow html and classnames
-var proto = $.ui.autocomplete.prototype,
- 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);
- }
-});
-
-/**
- * Private function to notify that something has been attached in the DOM
- * @param {htmlString or Element or Array or jQuery} [content] the content that
- * has been attached in the DOM
- * @params {Array} [callbacks] array of {widget: w, callback_args: args} such
- * that on_attach_callback() will be called on each w with arguments args
- */
-function _notify (content, callbacks) {
- _.each(callbacks, function(c) {
- if (c.widget && c.widget.on_attach_callback) {
- c.widget.on_attach_callback(c.callback_args);
- }
- });
- core.bus.trigger('DOM_updated', content);
-}
-/**
- * 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)
- */
-function append ($target, content, options) {
- $target.append(content);
- if (options && options.in_DOM) {
- _notify(content, options.callbacks);
- }
-}
-/**
- * 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)
- */
-function prepend ($target, content, options) {
- $target.prepend(content);
- if (options && options.in_DOM) {
- _notify(content, options.callbacks);
- }
-}
-
-/**
- * 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
- */
-function detach (to_detach, options) {
- _.each(to_detach, function(d) {
- if (d.widget.on_detach_callback) {
- d.widget.on_detach_callback(d.callback_args);
- }
- });
- var $to_detach = options && options.$to_detach;
- if (!$to_detach) {
- $to_detach = $(_.map(to_detach, function(d) {
- return d.widget.el;
- }));
- }
- return $to_detach.detach();
-}
-
-/**
- * Returns the distance between a DOM element and the top-left corner of the window
- * @param {element} [e] the DOM element
- * @return {Object} the left and top distances in pixels
- */
-function getPosition(e) {
- var position = {left: 0, top: 0};
- while (e) {
- position.left += e.offsetLeft;
- position.top += e.offsetTop;
- e = e.offsetParent;
- }
- return position;
-}
-
-return {
- blockUI: blockUI,
- unblockUI: unblockUI,
- redirect: redirect,
- append: append,
- prepend: prepend,
- detach: detach,
- getPosition: getPosition,
-};
-
-});
-
-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 overwritting the methods:
- * init: function(parent) {
- * this._super(parent, <url_of_iframe>
- * },
- * iframe_clicked: function(e){
- * filter the clicks you want to use and apply
- * an action on it
- * }
- */
-var IFrameWidget = Widget.extend({
- tagName: 'iframe',
- init: function(parent, url) {
- this._super(parent);
- this.url = url;
- },
- start: function() {
- this.$el.css({height: '100%', width: '100%', border: 0});
- this.$el.attr({src: this.url});
- this.$el.on("load", this.bind_events.bind(this));
- return this._super();
- },
- bind_events: function(){
- this.$el.contents().click(this.iframe_clicked.bind(this));
- },
- iframe_clicked: function(e){
- }
-});
-
-return IFrameWidget;
-
-});
diff --git a/addons/web/static/src/js/framework/registry.js b/addons/web/static/src/js/framework/registry.js
deleted file mode 100644
index b8bc1dbadfe..00000000000
--- a/addons/web/static/src/js/framework/registry.js
+++ /dev/null
@@ -1,54 +0,0 @@
-odoo.define('web.Registry', function (require) {
-"use strict";
-
-var Class = require('web.Class');
-
-var Registry = Class.extend({
- init: function (mapping) {
- this.map = Object.create(mapping || null);
- },
- get: function (key) {
- return this.map[key];
- },
- contains: function (key) {
- return (key in this.map);
- },
- /**
- * Tries a number of keys, and returns the first object matching one of
- * the keys.
- *
- * @param {Array} keys a sequence of keys to fetch the object for
- * @returns {Class} the first class found matching an object
- */
- get_any: function (keys) {
- for (var i=0; i<keys.length; i++) {
- if (keys[i] in this.map) {
- return this.map[keys[i]];
- }
- }
- return null;
- },
- add: function (key, value) {
- this.map[key] = value;
- return this;
- },
- /**
- * Creates and returns a copy of the current mapping, with the provided
- * mapping argument added in (replacing existing keys if needed)
- *
- * Parent and child remain linked, a new key in the parent (which is not
- * overwritten by the child) will appear in the child.
- *
- * @param {Object} [mapping={}] a mapping of keys to object-paths
- */
- extend: function (mapping) {
- var child = new Registry(this.map);
- _.extend(child.map, mapping);
- return child;
- },
-});
-
-return Registry;
-
-});
-
diff --git a/addons/web/static/src/js/framework/utils.js b/addons/web/static/src/js/framework/utils.js
deleted file mode 100644
index 6b0a2ddbcc0..00000000000
--- a/addons/web/static/src/js/framework/utils.js
+++ /dev/null
@@ -1,566 +0,0 @@
-odoo.define('web.utils', function (require) {
-"use strict";
-
-var Class = require('web.Class');
-var translation = require('web.translation');
-
-var _t = translation._t;
-
-/**
- * computes (Math.floor(a/b), a%b and passes that to the callback.
- *
- * returns the callback's result
- */
-function divmod (a, b, fn) {
- var mod = a%b;
- // in python, sign(a % b) === sign(b). Not in JS. If wrong side, add a
- // round of b
- if (mod > 0 && b < 0 || mod < 0 && b > 0) {
- mod += b;
- }
- return fn(Math.floor(a/b), mod);
-}
-
-/**
- * Passes the fractional and integer parts of x to the callback, returns
- * the callback's result
- */
-function modf (x, fn) {
- var mod = x%1;
- if (mod < 0) {
- mod += 1;
- }
- return fn(mod, Math.floor(x));
-}
-
-/*
- * Left-pad provided arg 1 with zeroes until reaching size provided by second
- * argument.
- *
- * @param {Number|String} str value to pad
- * @param {Number} size size to reach on the final padded value
- * @returns {String} padded string
- */
-function lpad (str, size) {
- str = "" + str;
- return new Array(size - str.length + 1).join('0') + str;
-}
-
-function rpad (str, size) {
- str = "" + str;
- return str + new Array(size - str.length + 1).join('0');
-}
-
-var id = -1;
-
-function generate_id () {
- return ++id;
-}
-
-/**
- * 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}
- */
-function intersperse (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);
-}
-
-function xml_to_json (node, strip_whitespace) {
- switch (node.nodeType) {
- case 9:
- return xml_to_json(node.documentElement, strip_whitespace);
- case 3:
- case 4:
- return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
- case 1:
- var attrs = $(node).getAttributes();
- _.each(['domain', 'filter_domain', 'context', 'default_get'], function(key) {
- if (attrs[key]) {
- try {
- attrs[key] = JSON.parse(attrs[key]);
- } catch(e) { }
- }
- });
- return {
- tag: node.tagName.toLowerCase(),
- attrs: attrs,
- children: _.compact(_.map(node.childNodes, function(node) {
- return xml_to_json(node, strip_whitespace);
- })),
- };
- }
-}
-
-function json_node_to_xml (node, human_readable, indent) {
- // For debugging purpose, this function will convert a json node back to xml
- indent = indent || 0;
- var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''),
- r = sindent + '<' + node.tag,
- cr = human_readable ? '\n' : '';
-
- if (typeof(node) === 'string') {
- return sindent + node.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
- } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) {
- throw new Error(
- _.str.sprintf(_t("Node [%s] is not a JSONified XML node"),
- JSON.stringify(node)));
- }
- for (var attr in node.attrs) {
- var vattr = node.attrs[attr];
- if (typeof(vattr) !== 'string') {
- // domains, ...
- vattr = JSON.stringify(vattr);
- }
- vattr = vattr.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
- if (human_readable) {
- vattr = vattr.replace(/&quot;/g, "'");
- }
- r += ' ' + attr + '="' + vattr + '"';
- }
- if (node.children && node.children.length) {
- r += '>' + cr;
- var childs = [];
- for (var i = 0, ii = node.children.length; i < ii; i++) {
- childs.push(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 + '/>';
- }
-}
-
-function xml_to_str (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;
-}
-
-function Mutex () {
- this.def = $.Deferred().resolve();
-}
-
-Mutex.prototype.exec = function (action) {
- var current = this.def;
- var next = this.def = $.Deferred();
- return current.then(function() {
- return $.when(action()).always(function() {
- next.resolve();
- });
- });
-};
-
-function get_cookie (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 "";
-}
-
-/**
- * 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.
- */
-function set_cookie(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(';');
-};
-
-/**
- * Insert "thousands" separators in the provided number (which is actually
- * a string)
- *
- * @param {String} num
- * @returns {String}
- */
-function insert_thousand_seps (num) {
- var negative = num[0] === '-';
- num = (negative ? num.slice(1) : num);
- return (negative ? '-' : '') + intersperse(
- num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep);
-}
-
-function is_bin_size (v) {
- return (/^\d+(\.\d*)? \w+$/).test(v);
-}
-
-/**
- * Check with a scary heuristic 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
- */
-function binary_to_binsize (value) {
- if (!value) {
- return human_size(0);
- }
- if (value.substr(0, 10).indexOf(' ') == -1) {
- // Computing approximate size out of base64 encoded string
- // http://en.wikipedia.org/wiki/Base64#MIME
- return human_size(value.length / 1.37);
- } else {
- // already bin_size
- return value;
- }
-}
-
-/**
- * Returns a human readable size
- *
- * @param {Number} number of bytes
- */
-function human_size (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];
-}
-
-/**
- * Naive and unlocalized human size parser.
- * Returns the numeric amount specified in the given human formatted string.
- *
- * Eg:
- *
- * parse_human_size('4 Kb') === 4096
- * parse_human_size('10m') === 10485760
- * parse_human_size('340.99') === 340.99
- *
- * @param {String} input human formatted string
- */
-function parse_human_size(size) {
- var units = "bkmgtpezy".split('');
- var parsed = size.toString().match(/^([0-9\.,]*)(\s*)?([a-z]{1,2})?$/i);
- if (parsed === null) {
- throw("Could not parse: " + size);
- }
- var amount = parseFloat(parsed[1].replace(',', '.'));
- if (isNaN(amount) || !isFinite(amount)) {
- throw("Invalid amount: " + size);
- }
- var unit = parsed[3] ? parsed[3][0].toLowerCase() : '';
- var index = units.indexOf(unit);
- if (unit && index === -1) {
- throw("Invalid unit: " + size);
- }
- if (index > 0) {
- amount = amount * Math.pow(1024, index);
- }
- return amount;
-}
-
-/**
- * Returns a human readable number
- *
- * @param {Number} number
- */
-function human_number (number, decimals) {
- var decimals = decimals | 0;
- var number = Math.round(number);
- var d2 = Math.pow(10, decimals);
- var val = _t("kMGTPE");
- var i = val.length-1, s;
- while( i ) {
- s = Math.pow(10,i--*3);
- if( s <= number ) {
- number = Math.round(number*d2/s)/d2 + val[i];
- }
- }
- return number;
-}
-
-/**
- * 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} the value to be rounded
- * @param {Number} a precision parameter. eg: 0.01 rounds to two digits.
- */
-function round_precision (value, precision) {
- if (!value) {
- return 0;
- } else if (!precision || precision < 0) {
- precision = 1;
- }
- var normalized_value = value / precision;
- var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2);
- var epsilon = Math.pow(2, epsilon_magnitude - 53);
- normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
-
- /**
- * Javascript performs strictly the round half up method, which is asymmetric. However, in
- * Python, the method is symmetric. For example:
- * - In JS, Math.round(-0.5) is equal to -0.
- * - In Python, round(-0.5) is equal to -1.
- * We want to keep the Python behavior for consistency.
- */
- var sign = normalized_value < 0 ? -1.0 : 1.0;
- var rounded_value = sign * Math.round(Math.abs(normalized_value));
- return rounded_value * precision;
-}
-
-/**
- * performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision
- * See the corresponding float_round() in server/tools/float_utils.py for more info
- * @param {Number} the value to be rounded
- * @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
- */
-function round_decimals (value, decimals) {
- return round_precision(value, Math.pow(10,-decimals));
-}
-
-function float_is_zero (value, decimals) {
- var epsilon = Math.pow(10, -decimals);
- return Math.abs(round_precision(value, epsilon)) < epsilon;
-};
-
-/**
- * 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 val if val is in [min, max], min if val < min and max otherwise
- */
-function confine (val, min, max) {
- return Math.max(min, Math.min(max, val));
-}
-
-function assert (bool) {
- if (!bool) {
- throw new Error("AssertionError");
- }
-}
-
-/* Logical XOR */
-function xor (a, b) {
- return (a && !b) || (!a && b);
-}
-
-var DropMisordered = Class.extend({
- /**
- * @constructs instance.web.DropMisordered
- * @extends instance.web.Class
- *
- * @param {Boolean} [failMisordered=false] whether mis-ordered responses should be failed or just ignored
- */
- init: function (failMisordered) {
- // local sequence number, for requests sent
- this.lsn = 0;
- // remote sequence number, seqnum of last received request
- this.rsn = -1;
- this.failMisordered = failMisordered || false;
- },
- /**
- * Adds a deferred (usually an async request) to the sequencer
- *
- * @param {$.Deferred} deferred to ensure add
- * @returns {$.Deferred}
- */
- add: function (deferred) {
- var res = $.Deferred();
-
- var self = this, seq = this.lsn++;
- deferred.done(function () {
- if (seq > self.rsn) {
- self.rsn = seq;
- res.resolve.apply(res, arguments);
- } else if (self.failMisordered) {
- res.reject();
- }
- }).fail(function () {
- res.reject.apply(res, arguments);
- });
-
- return res.promise();
- },
-});
-
-var DropPrevious = Class.extend({
- /**
- * Registers a new deferred and rejects the previous one
- * @param {$.Deferred} the new deferred
- * @returns {$.Promise}
- */
- add: function (deferred) {
- if (this.current_def) { this.current_def.reject(); }
- var res = $.Deferred();
- deferred.then(res.resolve, res.reject);
- this.current_def = res;
- return res.promise();
- }
-});
-
-/**
- * Rejects a deferred as soon as a reference deferred is either resolved or rejected
- * @param {$.Deferred} [target_def] the deferred to potentially reject
- * @param {$.Deferred} [reference_def] the reference target
- * @returns {$.Deferred}
- */
-function reject_after(target_def, reference_def) {
- var res = $.Deferred();
- target_def.then(res.resolve, res.reject);
- reference_def.always(res.reject);
- return res.promise();
-}
-
-/**
- * Returns a deferred resolved after 'wait' milliseconds
- * @param {int} [wait] the delay in ms
- * @return {$.Deferred}
- */
-function delay (wait) {
- var def = $.Deferred();
- setTimeout(def.resolve, wait);
- return def;
-}
-
-function swap(array, elem1, elem2) {
- var i1 = array.indexOf(elem1);
- var i2 = array.indexOf(elem2);
- array[i2] = elem1;
- array[i1] = elem2;
-}
-
-function toBoolElse (str, elseValues, trueValues, falseValues) {
- var ret = _.str.toBool(str, trueValues, falseValues);
- if (_.isUndefined(ret)) {
- return elseValues;
- }
- return ret;
-}
-
-function async_when() {
- var async = false;
- var def = $.Deferred();
- $.when.apply($, arguments).done(function() {
- var args = arguments;
- var action = function() {
- def.resolve.apply(def, args);
- };
- if (async)
- action();
- else
- setTimeout(action, 0);
- }).fail(function() {
- var args = arguments;
- var action = function() {
- def.reject.apply(def, args);
- };
- if (async)
- action();
- else
- setTimeout(action, 0);
- });
- async = true;
- return def;
-}
-
-return {
- divmod: divmod,
- modf: modf,
- lpad: lpad,
- rpad: rpad,
- generate_id: generate_id,
- intersperse: intersperse,
- xml_to_json: xml_to_json,
- json_node_to_xml: json_node_to_xml,
- xml_to_str: xml_to_str,
- Mutex: Mutex,
- get_cookie: get_cookie,
- set_cookie: set_cookie,
- insert_thousand_seps: insert_thousand_seps,
- is_bin_size: is_bin_size,
- binary_to_binsize: binary_to_binsize,
- human_size: human_size,
- parse_human_size: parse_human_size,
- human_number: human_number,
- round_precision: round_precision,
- round_decimals: round_decimals,
- float_is_zero: float_is_zero,
- confine: confine,
- assert: assert,
- xor: xor,
- DropMisordered: DropMisordered,
- DropPrevious: DropPrevious,
- reject_after: reject_after,
- delay: delay,
- swap: swap,
- toBoolElse: toBoolElse,
- async_when: async_when,
-};
-
-});
diff --git a/addons/web/static/src/js/framework/view.js b/addons/web/static/src/js/framework/view.js
deleted file mode 100644
index c5a017710e9..00000000000
--- a/addons/web/static/src/js/framework/view.js
+++ /dev/null
@@ -1,211 +0,0 @@
-odoo.define('web.View', function (require) {
-"use strict";
-
-var core = require('web.core');
-var data = require('web.data');
-var data_manager = require('web.data_manager');
-var pyeval = require('web.pyeval');
-var Widget = require('web.Widget');
-
-var View = Widget.extend({
- events: {
- 'click a[type=action]': function (ev) {
- ev.preventDefault();
- var action_data = $(ev.target).attr('name');
- this.do_action(action_data);
- }
- },
- defaults: {
- action: {},
- },
- // name displayed in view switchers
- display_name: '',
- // used by views that need a searchview.
- searchable: true,
- // used by views that need a searchview but don't want it to be displayed.
- searchview_hidden: false,
- // 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,
- // 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',
- // whether or not the view requires the model's fields to render itself
- require_fields: false,
- init: function(parent, dataset, fields_view, options) {
- this._super(parent);
- this.ViewManager = parent;
- this.dataset = dataset;
- this.model = dataset.model;
- this.fields_view = fields_view;
- this.options = _.defaults({}, options, this.defaults);
- },
- /**
- * Triggers event 'view_loaded'.
- * Views extending start() must call this.super() once they are ready.
- * @return {Deferred}
- */
- start: function() {
- // add classname that reflect the (absence of) access rights
- this.$el.toggleClass('o_cannot_create', !this.is_action_enabled('create'));
- return this._super().then(this.trigger.bind(this, 'view_loaded'));
- },
- /**
- * Fetches and executes the action identified by ``action_data``.
- *
- * @param {Object} action_data the action descriptor data
- * @param {String} action_data.name the action name, used to uniquely identify the action to find and execute it
- * @param {String} [action_data.special=null] special action handlers (currently: only ``'cancel'``)
- * @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
- * @param {Object} [action_data.context=null] additional action context, to add to the current context
- * @param {DataSet} dataset a dataset object used to communicate with the server
- * @param {Object} [record_id] the identifier of the object on which the action is to be applied
- * @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
- */
- do_execute_action: function (action_data, dataset, record_id, on_closed) {
- var self = this;
- var result_handler = function () {
- if (on_closed) { on_closed.apply(null, arguments); }
- if (self.getParent() && self.getParent().on_action_executed) {
- return self.getParent().on_action_executed.apply(null, arguments);
- }
- };
- var context = new data.CompoundContext(dataset.get_context(), action_data.context || {});
-
- // response handler
- var handler = function (action) {
- if (action && action.constructor === Object) {
- // filter out context keys that are specific to the current action.
- // Wrong default_* and search_default_* values will no give the expected result
- // Wrong group_by values will simply fail and forbid rendering of the destination view
- var ncontext = new data.CompoundContext(
- _.object(_.reject(_.pairs(dataset.get_context().eval()), function(pair) {
- return pair[0].match('^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null;
- }))
- );
- ncontext.add(action_data.context || {});
- ncontext.add({active_model: dataset.model});
- if (record_id) {
- ncontext.add({
- active_id: record_id,
- active_ids: [record_id],
- });
- }
- ncontext.add(action.context || {});
- action.context = ncontext;
- return self.do_action(action, {
- on_close: result_handler,
- });
- } else {
- self.do_action({"type":"ir.actions.act_window_close"});
- return result_handler();
- }
- };
-
- if (action_data.special === 'cancel') {
- return handler({"type":"ir.actions.act_window_close"});
- } else if (action_data.type === "object") {
- var args = record_id ? [[record_id]] : [dataset.ids];
- if (action_data.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 additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
- args = args.concat(additional_args);
- } catch(e) {
- console.error("Could not JSON.parse arguments", action_data.args);
- }
- }
- args.push(context);
- return dataset.call_button(action_data.name, args).then(handler);
- } else if (action_data.type === "action") {
- return data_manager.load_action(action_data.name, _.extend(pyeval.eval('context', context), {
- active_model: dataset.model,
- active_ids: dataset.ids,
- active_id: record_id
- })).then(handler);
- }
- },
- do_show: function () {
- this._super();
- core.bus.trigger('view_shown', this);
- },
- do_push_state: function(state) {
- if (this.getParent() && this.getParent().do_push_state) {
- this.getParent().do_push_state(state);
- }
- },
- do_load_state: function (state, warm) {
- },
- /**
- * This function should render the action buttons of the view.
- * It should be called after start().
- * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted
- * $node may be undefined, in which case the View can insert the buttons somewhere else
- */
- render_buttons: function($node) {
- },
- /**
- * This function should render the sidebar of the view.
- * It should be called after start().
- * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted
- * $node may be undefined, in which case the View can insert the sidebar somewhere else
- */
- render_sidebar: function($node) {
- },
- /**
- * This function should render the pager of the view.
- * It should be called after start().
- * @param {jQuery} [$node] a jQuery node where the pager should be inserted
- * $node may be undefined, in which case the View can insert the pager somewhere else
- */
- render_pager: function($node) {
- },
- /**
- * Switches to a specific view type
- */
- do_switch_view: function() {
- this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments)));
- },
- do_search: function(domain, context, group_by) {
- },
- sidebar_eval_context: function () {
- return $.when({});
- },
- /**
- * Asks the view to reload itself, if the reloading is asynchronous should
- * return a {$.Deferred} indicating when the reloading is done.
- */
- reload: function () {
- return $.when();
- },
- /**
- * Return whether the user can perform the action ('create', 'edit', 'delete') in this view.
- * An action is disabled by setting the corresponding attribute in the view's main element,
- * like: <form string="" create="false" edit="false" delete="false">
- */
- is_action_enabled: function(action) {
- var attrs = this.fields_view.arch.attrs;
- return (action in attrs) ? JSON.parse(attrs[action]) : true;
- },
- get_context: function () {
- return {};
- },
- destroy: function () {
- if (this.$buttons) {
- this.$buttons.off();
- }
- return this._super.apply(this, arguments);
- },
- set_scrollTop: function(scrollTop) {
- this.scrollTop = scrollTop;
- },
- get_scrollTop: function() {
- return this.scrollTop;
- }
-});
-
-return View;
-
-});
diff --git a/addons/web/static/src/js/libs.js b/addons/web/static/src/js/libs.js
new file mode 100644
index 00000000000..1a058aee761
--- /dev/null
+++ b/addons/web/static/src/js/libs.js
@@ -0,0 +1,138 @@
+odoo.define('web.libs', function () {
+"use strict";
+
+/**
+ * External Libraries Customization
+ *
+ * This is the place where we do some slightly crappy libraries customization.
+ * We are not proud of this, but at least, we try to avoid patching them in
+ * place.
+ *
+ * In an ideal world, this file would not be needed, because external libraries
+ * would not have bugs, their APIS would be done in a way that we could use them
+ * as is, and their distribution process would make sure that they are properly
+ * configured. However, we do not live in such a world, so this file is
+ * necessary.
+ */
+
+// nvd3 customization
+//-------------------------------------------------------------------------
+if ('nv' in window) {
+ nv.dev = false; // sets nvd3 library in production mode
+
+ // monkey patch nvd3 to allow removing eventhandler on windowresize events
+ // see https://github.com/novus/nvd3/pull/396 for more details
+
+ // Adds a resize listener to the window.
+ nv.utils.onWindowResize = function(fun) {
+ if (fun === null) return;
+ window.addEventListener('resize', fun);
+ };
+
+ // Backwards compatibility with current API.
+ nv.utils.windowResize = nv.utils.onWindowResize;
+
+ // Removes a resize listener from the window.
+ nv.utils.offWindowResize = function(fun) {
+ if (fun === null) return;
+ window.removeEventListener('resize', fun);
+ };
+
+ // monkey patch nvd3 to prevent crashes when user changes view and nvd3
+ // tries to remove tooltips after 500 ms... seriously nvd3, what were you
+ // thinking?
+ nv.tooltip.cleanup = function () {
+ $('.nvtooltip').remove();
+ };
+
+ // monkey patch nvd3 to prevent it to display a tooltip (position: absolute)
+ // with a negative `top`; with this patch the highest tooltip's position is
+ // still in the graph
+ var originalCalcTooltipPosition = nv.tooltip.calcTooltipPosition;
+ nv.tooltip.calcTooltipPosition = function () {
+ var container = originalCalcTooltipPosition.apply(this, arguments);
+ container.style.top = container.style.top.split('px')[0] < 0 ? 0 + 'px' : container.style.top;
+ return container;
+ };
+}
+
+// Bootstrap customization
+//-------------------------------------------------------------------------
+/* Bootstrap defaults overwrite */
+$.fn.tooltip.Constructor.DEFAULTS.placement = 'auto top';
+$.fn.tooltip.Constructor.DEFAULTS.html = true;
+$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover focus click';
+$.fn.tooltip.Constructor.DEFAULTS.container = 'body';
+$.fn.tooltip.Constructor.DEFAULTS.delay = { show: 1000, hide: 0 };
+//overwrite bootstrap tooltip method to prevent showing 2 tooltip at the same time
+var bootstrap_show_function = $.fn.tooltip.Constructor.prototype.show;
+$.fn.modal.Constructor.prototype.enforceFocus = function () { };
+$.fn.tooltip.Constructor.prototype.show = function () {
+ $('.tooltip').remove();
+ //the following fix the bug when using placement
+ //auto and the parent element does not exist anymore resulting in
+ //an error. This should be remove once we updade bootstrap to a version that fix the bug
+ //edit: bug has been fixed here : https://github.com/twbs/bootstrap/pull/13752
+ var e = $.Event('show.bs.' + this.type);
+ var inDom = $.contains(document.documentElement, this.$element[0]);
+ if (e.isDefaultPrevented() || !inDom) return;
+ return bootstrap_show_function.call(this);
+};
+
+// jquery customization
+//-------------------------------------------------------------------------
+jQuery.expr[":"].Contains = jQuery.expr.createPseudo(function(arg) {
+ return function( elem ) {
+ return jQuery(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
+ };
+});
+
+/** Custom jQuery plugins */
+$.fn.getAttributes = function() {
+ var o = {};
+ if (this.length) {
+ for (var attr, i = 0, attrs = this[0].attributes, l = attrs.length; i < l; i++) {
+ attr = attrs.item(i);
+ o[attr.nodeName] = attr.value;
+ }
+ }
+ return o;
+};
+$.fn.openerpBounce = function() {
+ return this.each(function() {
+ $(this).css('box-sizing', 'content-box').effect('bounce', {distance: 18, times: 5}, 250);
+ });
+};
+
+// 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/services/ajax_service.js b/addons/web/static/src/js/services/ajax_service.js
new file mode 100644
index 00000000000..b86585eb5d8
--- /dev/null
+++ b/addons/web/static/src/js/services/ajax_service.js
@@ -0,0 +1,16 @@
+odoo.define('web.AjaxService', function (require) {
+"use strict";
+
+var AbstractService = require('web.AbstractService');
+var session = require('web.session');
+
+var AjaxService = AbstractService.extend({
+ name: 'ajax',
+ rpc: function(route, args) {
+ return session.rpc(route, args);
+ },
+});
+
+return AjaxService;
+
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/config.js b/addons/web/static/src/js/services/config.js
index 3354c799655..3354c799655 100644
--- a/addons/web/static/src/js/config.js
+++ b/addons/web/static/src/js/services/config.js
diff --git a/addons/web/static/src/js/services/core.js b/addons/web/static/src/js/services/core.js
index 41e30c1bd52..acd22c09fa1 100644
--- a/addons/web/static/src/js/services/core.js
+++ b/addons/web/static/src/js/services/core.js
@@ -45,7 +45,6 @@ return {
one2many_view_registry: new Registry(),
search_filters_registry: new Registry(),
search_widgets_registry: new Registry(),
- view_registry: new Registry(),
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
index e01a3e75680..f09eec23daf 100644
--- a/addons/web/static/src/js/services/crash_manager.js
+++ b/addons/web/static/src/js/services/crash_manager.js
@@ -1,3 +1,215 @@
+odoo.define('web.CrashManager', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+
+var QWeb = core.qweb;
+var _t = core._t;
+var _lt = core._lt;
+
+var map_title ={
+ user_error: _lt('Warning'),
+ warning: _lt('Warning'),
+ access_error: _lt('Access Error'),
+ missing_error: _lt('Missing Record'),
+ validation_error: _lt('Validation Error'),
+ except_orm: _lt('Global Business Error'),
+ access_denied: _lt('Access Denied'),
+};
+
+var CrashManager = core.Class.extend({
+ init: function() {
+ this.active = true;
+ },
+ enable: function () {
+ this.active = true;
+ },
+ disable: function () {
+ this.active = false;
+ },
+ rpc_error: function(error) {
+ var self = this;
+ if (!this.active) {
+ return;
+ }
+ if (this.connection_lost) {
+ return;
+ }
+ if (error.code === -32098) {
+ core.bus.trigger('connection_lost');
+ this.connection_lost = true;
+ var timeinterval = setInterval(function() {
+ ajax.jsonRpc('/web/webclient/version_info').then(function() {
+ clearInterval(timeinterval);
+ core.bus.trigger('connection_restored');
+ self.connection_lost = false;
+ });
+ }, 2000);
+ return;
+ }
+ var handler = core.crash_registry.get(error.data.name, true);
+ if (handler) {
+ new (handler)(this, error).display();
+ return;
+ }
+ if (error.data.name === "openerp.http.SessionExpiredException" || error.data.name === "werkzeug.exceptions.Forbidden") {
+ this.show_warning({type: _t("Odoo Session Expired"), data: {message: _t("Your Odoo session expired. Please refresh the current web page.")}});
+ return;
+ }
+ if (_.has(map_title, error.data.exception_type)) {
+ if(error.data.exception_type === 'except_orm'){
+ if(error.data.arguments[1]) {
+ error = _.extend({}, error,
+ {
+ data: _.extend({}, error.data,
+ {
+ message: error.data.arguments[1],
+ title: error.data.arguments[0] !== 'Warning' ? (" - " + error.data.arguments[0]) : '',
+ })
+ });
+ }
+ else {
+ error = _.extend({}, error,
+ {
+ data: _.extend({}, error.data,
+ {
+ message: error.data.arguments[0],
+ title: '',
+ })
+ });
+ }
+ }
+ else {
+ error = _.extend({}, error,
+ {
+ data: _.extend({}, error.data,
+ {
+ message: error.data.arguments[0],
+ title: map_title[error.data.exception_type] !== 'Warning' ? (" - " + map_title[error.data.exception_type]) : '',
+ })
+ });
+ }
+
+ this.show_warning(error);
+ //InternalError
+
+ } else {
+ this.show_error(error);
+ }
+ },
+ show_warning: function(error) {
+ if (!this.active) {
+ return;
+ }
+ new Dialog(this, {
+ size: 'medium',
+ title: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"),
+ subtitle: error.data.title,
+ $content: $(QWeb.render('CrashManager.warning', {error: error}))
+ }).open();
+ },
+ show_error: function(error) {
+ if (!this.active) {
+ return;
+ }
+ var dialog = new Dialog(this, {
+ title: _.str.capitalize(error.type || error.message) || _t("Odoo Error"),
+ $content: $(QWeb.render('CrashManager.error', {error: error}))
+ }).open();
+
+ // When the dialog opens, initialize the copy feature and destroy it when the dialog is closed
+ var $clipboardBtn;
+ var clipboard;
+ dialog.opened(function () {
+ $clipboardBtn = dialog.$(".o_clipboard_button");
+ $clipboardBtn.tooltip({title: _t("Copied !"), trigger: "manual", placement: "left"});
+ clipboard = new window.Clipboard($clipboardBtn[0], {
+ text: function () {
+ return (_t("Error") + ":\n" + error.message + "\n\n" + error.data.debug).trim();
+ }
+ });
+ clipboard.on("success", function (e) {
+ _.defer(function () {
+ $clipboardBtn.tooltip("show");
+ _.delay(function () {
+ $clipboardBtn.tooltip("hide");
+ }, 800);
+ });
+ });
+ });
+ dialog.on("closed", this, function () {
+ $clipboardBtn.tooltip("destroy");
+ clipboard.destroy();
+ });
+
+ // 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;
+ });
+ },
+ show_message: function(exception) {
+ this.show_error({
+ type: _t("Odoo Client Error"),
+ message: exception,
+ data: {debug: ""}
+ });
+ },
+});
+
+/**
+ 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 = Dialog.extend(ExceptionHandler, {
+ init: function(parent, error) {
+ this._super(parent);
+ this.error = error;
+ },
+ display: function() {
+ var self = this;
+ var error = this.error;
+ error.data.message = error.data.arguments[0];
+
+ new Dialog(this, {
+ size: 'medium',
+ title: _.str.capitalize(error.type) || _t("Odoo Warning"),
+ buttons: [
+ {text: error.data.arguments[2], classes : "btn-primary", click: function() {
+ window.location.href = '#action='+error.data.arguments[1];
+ self.destroy();
+ }},
+ {text: _t("Cancel"), click: function() { self.destroy(); }, close: true}
+ ],
+ $content: QWeb.render('CrashManager.warning', {error: error}),
+ }).open();
+ }
+});
+
+core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler);
+
+return CrashManager;
+});
+
odoo.define('web.crash_manager', function (require) {
"use strict";
diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js
index 69fc68908f6..7188f7d9126 100644
--- a/addons/web/static/src/js/services/data_manager.js
+++ b/addons/web/static/src/js/services/data_manager.js
@@ -1,3 +1,224 @@
+odoo.define('web.DataManager', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Model = require('web.DataModel');
+var session = require('web.session');
+var utils = require('web.utils');
+
+return core.Class.extend({
+ init: function () {
+ this.Filters = new Model('ir.filters');
+ this._init_cache();
+ },
+
+ _init_cache: function () {
+ this._cache = {
+ actions: {},
+ fields_views: {},
+ filters: {},
+ views: {},
+ };
+ },
+
+ /**
+ * Invalidates the whole cache
+ * Suggestion: could be refined to invalidate some part of the cache
+ */
+ invalidate: function () {
+ 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 {Deferred} 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 (!this._cache.actions[key]) {
+ this._cache.actions[key] = session.rpc("/web/action/load", {
+ 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;
+ }, this._invalidate.bind(this, this._cache.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} [dataset] the dataset for which the views are loaded
+ * @param {Array} [views_descr] array of [view_id, view_type]
+ * @param {Object} [options] dictionnary 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 {Deferred} resolved with the requested views information
+ */
+ load_views: function (params, options) {
+ var self = this;
+
+ var model = params.model;
+ var context = params.context;
+ var views_descr = params.views_descr;
+ var key = this._gen_key(model, views_descr, options || {}, context);
+
+ if (!this._cache.views[key]) {
+ // Don't load filters if already in cache
+ var filters_key;
+ if (options.load_filters) {
+ filters_key = this._gen_key(model, options.action_id);
+ options.load_filters = !this._cache.filters[filters_key];
+ }
+
+ this._cache.views[key] = session.rpc('/web/dataset/call_kw/' + model + '/load_views', {
+ args: [],
+ kwargs: {
+ views: views_descr,
+ options: options,
+ context: context.eval(),
+ },
+ model: model,
+ method: 'load_views',
+ }).then(function (result) {
+ // Postprocess fields_views and insert them into the fields_views cache
+ result.fields_views = _.mapObject(result.fields_views, self._postprocess_fvg.bind(self));
+ _.each(result.fields_views, function (fields_view) {
+ _.defaults(fields_view.fields, result.fields);
+ });
+ _.each(views_descr, function (view_descr) {
+ var toolbar = options.toolbar && view_descr[1] !== 'search';
+ var fv_key = self._gen_key(model, view_descr[0], view_descr[1], toolbar, context);
+ self._cache.fields_views[fv_key] = $.when(result.fields_views[view_descr[1]]);
+ });
+
+ // Insert filters, if any, into the filters cache
+ if (result.filters) {
+ self._cache.filters[filters_key] = $.when(result.filters);
+ }
+
+ return result.fields_views;
+ }, this._invalidate.bind(this, this._cache.views, key));
+ }
+
+ return this._cache.views[key].then(function (views) {
+ return $.extend(true, {}, views);
+ });
+ },
+
+ /**
+ * Loads the filters of a given model and optional action id.
+ *
+ * @param {Object} [dataset] the dataset for which the filters are loaded
+ * @param {int} [action_id] the id of the action (optional)
+ * @return {Deferred} resolved with the requested filters
+ */
+ load_filters: function (dataset, action_id) {
+ var key = this._gen_key(dataset.model, action_id);
+ if (!this._cache.filters[key]) {
+ this._cache.filters[key] = this.Filters.call('get_filters', [dataset.model, action_id], {
+ context: dataset.get_context(),
+ }).fail(this._invalidate.bind(this, this._cache.filters, key));
+ }
+ return this._cache.filters[key];
+ },
+
+ /**
+ * Calls 'create_or_replace' on 'ir_filters'.
+ *
+ * @param {Object} [filter] the filter description
+ * @return {Deferred} resolved with the id of the created or replaced filter
+ */
+ create_filter: function (filter) {
+ var self = this;
+ return this.Filters
+ .call('create_or_replace', [filter])
+ .then(function (filter_id) {
+ var key = [
+ filter.model_id,
+ filter.action_id || false,
+ ].join(',');
+ self._invalidate(self._cache.filters, key);
+ return filter_id;
+ });
+ },
+
+ /**
+ * Calls 'unlink' on 'ir_filters'.
+ *
+ * @param {Object} [filter] the description of the filter to remove
+ * @return {Deferred}
+ */
+ delete_filter: function (filter) {
+ var self = this;
+ return this.Filters
+ .call('unlink', [filter.id])
+ .then(function () {
+ self._cache.filters = {}; // invalidate cache
+ });
+ },
+
+ /**
+ * Private function that postprocesses fields_view (mainly parses the arch attribute)
+ */
+ _postprocess_fvg: function (fields_view) {
+ var self = this;
+
+ // Parse arch
+ var doc = $.parseXML(fields_view.arch).documentElement;
+ fields_view.arch = utils.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban'));
+
+ // Special case for id's
+ if ('id' in fields_view.fields) {
+ var id_field = fields_view.fields.id;
+ id_field.original_type = id_field.type;
+ id_field.type = 'id';
+ }
+
+ // Process inner views (one2manys)
+ _.each(fields_view.fields, function(field) {
+ _.each(field.views || {}, function(view) {
+ self._postprocess_fvg(view);
+ });
+ });
+
+ return fields_view;
+ },
+
+ /**
+ * 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(',');
+ },
+
+ /**
+ * Private function that invalidates a cache entry
+ */
+ _invalidate: function (cache, key) {
+ delete cache[key];
+ },
+});
+
+});
+
odoo.define('web.data_manager', function (require) {
"use strict";
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 00000000000..a4527026238
--- /dev/null
+++ b/addons/web/static/src/js/services/local_storage_service.js
@@ -0,0 +1,25 @@
+odoo.define('web.LocalStorageService', function (require) {
+'use strict';
+
+var AbstractService = require('web.AbstractService');
+var localStorage = require('web.local_storage');
+
+var LocalStorageService = AbstractService.extend({
+ name: 'local_storage',
+ setItem: function(key, value) {
+ localStorage.setItem(key,value);
+ },
+ getItem: function(key) {
+ return localStorage.getItem(key);
+ },
+ removeItem: function(key) {
+ localStorage.removeItem(key);
+ },
+ clear: function() {
+ localStorage.clear();
+ }
+});
+
+return LocalStorageService;
+
+});
diff --git a/addons/web/static/src/js/view_manager.js b/addons/web/static/src/js/view_manager.js
deleted file mode 100644
index 2c792e393d7..00000000000
--- a/addons/web/static/src/js/view_manager.js
+++ /dev/null
@@ -1,479 +0,0 @@
-odoo.define('web.ViewManager', function (require) {
-"use strict";
-
-var ControlPanelMixin = require('web.ControlPanelMixin');
-var core = require('web.core');
-var data_manager = require('web.data_manager');
-var framework = require('web.framework');
-var Model = require('web.DataModel');
-var pyeval = require('web.pyeval');
-var SearchView = require('web.SearchView');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var ViewManager = Widget.extend(ControlPanelMixin, {
- className: "o_view_manager_content",
- /**
- * Called each time the view manager is attached into the DOM
- */
- on_attach_callback: function() {
- this.is_in_DOM = true;
- if (this.active_view && this.active_view.controller.on_attach_callback) {
- this.active_view.controller.on_attach_callback();
- }
- },
- /**
- * Called each time the view manager is detached from the DOM
- */
- on_detach_callback: function() {
- this.is_in_DOM = false;
- if (this.active_view && this.active_view.controller.on_detach_callback) {
- this.active_view.controller.on_detach_callback();
- }
- },
- /**
- * @param {Object} [dataset]
- * @param {Array} [views] List of [view_id, view_type[, fields_view]]
- * @param {Object} [flags] various boolean describing UI state
- */
- init: function(parent, dataset, views, flags, options) {
- var self = this;
- this._super(parent);
-
- this.action = options && options.action || {};
- this.action_manager = options && options.action_manager;
- this.flags = flags || {};
- this.dataset = dataset;
- this.view_order = [];
- this.views = {};
- this.view_stack = []; // used for breadcrumbs
- this.active_view = null;
- this.registry = core.view_registry;
- this.title = this.action.name;
- _.each(views, function (view) {
- var view_type = view[1] || view.view_type;
- var View = self.registry.get(view_type);
- if (!View) {
- console.error("View type", "'"+view_type+"'", "is not present in the view registry.");
- return;
- }
- var view_label = View.prototype.display_name;
- var view_descr = {
- accesskey: View.prototype.accesskey,
- button_label: _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}),
- controller: null,
- fields_view: view[2] || view.fields_view,
- icon: View.prototype.icon,
- label: view_label,
- mobile_friendly: View.prototype.mobile_friendly,
- multi_record: View.prototype.multi_record,
- options: view.options || {},
- require_fields: View.prototype.require_fields,
- title: self.title,
- type: view_type,
- view_id: view[0] || view.view_id,
- };
- self.view_order.push(view_descr);
- self.views[view_type] = view_descr;
- });
- this.first_view = this.views[options && options.view_type]; // view to open first
- this.default_view = this.get_default_view();
- },
- willStart: function () {
- var views_def;
- var first_view_to_display = this.first_view || this.default_view;
- if (!first_view_to_display.fields_view || (this.flags.search_view && !this.search_fields_view)) {
- views_def = this.load_views(first_view_to_display.require_fields);
- }
- return $.when(this._super(), views_def);
- },
- /**
- * @return {Deferred} initial view and search view (if any) loading promise
- */
- start: function() {
- var self = this;
- _.each(this.views, function (view) {
- view.options = _.extend({
- action: self.action,
- }, self.flags, self.flags[view.type], view.options);
- });
- if (this.flags.search_view) {
- this.search_view_loaded = this.setup_search_view();
- }
- if (this.flags.views_switcher) {
- this.render_switch_buttons();
- }
-
- // If a non multi-record first_view is given, switch to it but first push the default_view
- // to the view_stack to complete the breadcrumbs
- if (this.first_view && !this.first_view.multi_record && this.default_view.multi_record) {
- this.default_view.controller = this.create_view(this.default_view);
- this.view_stack.push(this.default_view);
- }
- var view_to_load = this.first_view || this.default_view;
- var options = this.flags[view_to_load] && this.flags[view_to_load].options;
- var main_view_loaded = this.switch_mode(view_to_load.type, options);
-
- return $.when(this._super(), main_view_loaded, this.search_view_loaded);
- },
- /**
- * Loads all missing field_views of views in this.views and the search view.
- *
- * @param {Boolean} [load_fields] whether or not to load the fields as well
- * @return {Deferred}
- */
- load_views: function (load_fields) {
- var self = this;
- var views = [];
- _.each(this.views, function (view) {
- if (!view.fields_view) {
- views.push([view.view_id, view.type]);
- }
- });
- var options = {
- action_id: this.action.id,
- load_fields: load_fields,
- toolbar: this.flags.sidebar,
- };
- if (this.flags.search_view && !this.search_fields_view) {
- options.load_filters = true;
- var searchview_id = this.action.search_view_id && this.action.search_view_id[0];
- views.push([searchview_id || false, 'search']);
- }
- return data_manager.load_views(this.dataset, views, options).then(function (fields_views) {
- _.each(fields_views, function (fields_view, view_type) {
- if (view_type === 'search') {
- self.search_fields_view = fields_view;
- } else {
- self.views[view_type].fields_view = fields_view;
- }
- });
- });
- },
- /**
- * Returns the default view with the following fallbacks:
- * - use the default_view defined in the flags, if any
- * - use the first view in the view_order
- *
- * @returns {Object} the default view
- */
- get_default_view: function() {
- return this.views[this.flags.default_view || this.view_order[0].type];
- },
- switch_mode: function(view_type, view_options) {
- var self = this;
- var view = this.views[view_type];
- var old_view = this.active_view;
-
- if (!view || this.currently_switching) {
- return $.Deferred().reject();
- } else {
- this.currently_switching = true; // prevent overlapping switches
- }
-
- // Ensure that the fields_view has been loaded
- var views_def;
- if (!view.fields_view) {
- views_def = this.load_views(view.require_fields);
- }
-
- return $.when(views_def).then(function () {
- if (view.multi_record) {
- self.view_stack = [];
- } else if (self.view_stack.length > 0 && !(_.last(self.view_stack).multi_record)) {
- // Replace the last view by the new one if both are mono_record
- self.view_stack.pop();
- }
- self.view_stack.push(view);
-
- self.active_view = view;
-
- if (!view.loaded) {
- if (!view.controller) {
- view.controller = self.create_view(view, view_options);
- }
- view.$fragment = $('<div>');
- view.loaded = view.controller.appendTo(view.$fragment).done(function () {
- // Remove the unnecessary outer div
- view.$fragment = view.$fragment.contents();
- self.trigger("controller_inited", view.type, view.controller);
- });
- }
-
- // Call do_search on the searchview to compute domains, contexts and groupbys
- if (self.search_view_loaded &&
- self.flags.auto_search &&
- view.controller.searchable !== false) {
- self.active_search = $.Deferred();
- $.when(self.search_view_loaded, view.loaded).done(function() {
- self.searchview.do_search();
- });
- }
-
- return $.when(view.loaded, self.active_search)
- .then(function() {
- return self._display_view(view_options, old_view).then(function() {
- self.trigger('switch_mode', view_type, view_options);
- });
- }).fail(function(e) {
- if (!(e && e.code === 200 && e.data.exception_type)) {
- self.do_warn(_t("Error"), view.controller.display_name + _t(" view couldn't be loaded"));
- }
- // Restore internal state
- self.active_view = old_view;
- self.view_stack.pop();
- });
- }).always(function () {
- self.currently_switching = false;
- });
- },
- _display_view: function (view_options, old_view) {
- var self = this;
- var view_controller = this.active_view.controller;
- var view_fragment = this.active_view.$fragment;
-
- // Show the view
- return $.when(view_controller.do_show(view_options)).done(function () {
- var view_control_elements = self.render_view_control_elements();
- // Prepare the ControlPanel content and update it
- var cp_status = {
- active_view_selector: '.o_cp_switch_' + self.active_view.type,
- breadcrumbs: self.action_manager && self.action_manager.get_breadcrumbs(),
- cp_content: _.extend({}, self.searchview_elements, view_control_elements),
- hidden: self.flags.headless,
- searchview: self.searchview,
- search_view_hidden: view_controller.searchable === false || view_controller.searchview_hidden,
- };
- self.update_control_panel(cp_status);
-
- // Detach the old view and store it
- if (old_view && old_view !== self.active_view) {
- // Store the scroll position
- if (self.action_manager && self.action_manager.webclient) {
- old_view.controller.set_scrollTop(self.action_manager.webclient.get_scrollTop());
- }
- // Do not detach ui-autocomplete elements to let jquery-ui garbage-collect them
- var $to_detach = self.$el.contents().not('.ui-autocomplete');
- old_view.$fragment = framework.detach([{widget: old_view.controller}], {$to_detach: $to_detach});
- }
-
- // If the user switches from a multi-record to a mono-record view,
- // the action manager should be scrolled to the top.
- if (old_view && old_view.controller.multi_record === true && view_controller.multi_record === false) {
- view_controller.set_scrollTop(0);
- }
-
- // Append the view fragment to self.$el
- framework.append(self.$el, view_fragment, {
- in_DOM: self.is_in_DOM,
- callbacks: [{widget: view_controller}],
- });
- });
- },
- create_view: function(view, view_options) {
- var self = this;
- var js_class = view.fields_view.arch.attrs && view.fields_view.arch.attrs.js_class;
- var View = this.registry.get(js_class || view.type);
- var options = _.clone(view.options);
- if (view.type === "form" && ((this.action.target === 'new' || this.action.target === 'inline') ||
- (view_options && view_options.mode === 'edit'))) {
- options.initial_mode = options.initial_mode || 'edit';
- }
-
- var controller = new View(this, this.dataset, view.fields_view, options);
-
- controller.on('switch_mode', this, this.switch_mode.bind(this));
- controller.on('history_back', this, function () {
- if (self.action_manager) self.action_manager.trigger('history_back');
- });
- controller.on("change:title", this, function() {
- if (self.action_manager && !self.flags.headless) {
- var breadcrumbs = self.action_manager.get_breadcrumbs();
- self.update_control_panel({breadcrumbs: breadcrumbs}, {clear: false});
- }
- });
-
- return controller;
- },
- select_view: function (index) {
- var view_type = this.view_stack[index].type;
- return this.switch_mode(view_type);
- },
- /**
- * Renders the switch buttons for multi- and mono-record views and adds
- * listeners on them, but does not append them to the DOM
- * Sets switch_buttons.$mono and switch_buttons.$multi to send to the ControlPanel
- */
- render_switch_buttons: function() {
- var self = this;
-
- // Partition the views according to their multi-/mono-record status
- var views = _.partition(this.view_order, function(view) {
- return view.multi_record === true;
- });
- var multi_record_views = views[0];
- var mono_record_views = views[1];
-
- // Inner function to render and prepare switch_buttons
- var _render_switch_buttons = function(views) {
- if (views.length > 1) {
- var $switch_buttons = $(QWeb.render('ViewManager.switch-buttons', {views: views}));
- // Create bootstrap tooltips
- _.each(views, function(view) {
- $switch_buttons.filter('.o_cp_switch_' + view.type).tooltip();
- });
- // Add onclick event listener
- $switch_buttons.filter('button').click(_.debounce(function(event) {
- var view_type = $(event.target).data('view-type');
- self.switch_mode(view_type);
- }, 200, true));
- return $switch_buttons;
- }
- };
-
- // Render switch buttons but do not append them to the DOM as this will
- // be done later, simultaneously to all other ControlPanel elements
- this.switch_buttons = {};
- this.switch_buttons.$multi = _render_switch_buttons(multi_record_views);
- this.switch_buttons.$mono = _render_switch_buttons(mono_record_views);
- },
- /**
- * Renders the control elements (buttons, sidebar, pager) of the current view.
- * Fills this.active_view.control_elements dictionnary with the rendered
- * elements and the adequate view switcher, to send to the ControlPanel.
- * Warning: it should be called before calling do_show on the view as the
- * sidebar is extended to listen on the load_record event triggered as soon
- * as do_show is done (the sidebar should thus be instantiated before).
- */
- render_view_control_elements: function() {
- if (!this.active_view.control_elements) {
- var view_controller = this.active_view.controller;
- var $buttons = this.flags.$buttons;
- var elements = {};
- if (!this.flags.headless) {
- elements = {
- $buttons: $("<div>"),
- $sidebar: $("<div>"),
- $pager: $("<div>"),
- };
- }
- view_controller.render_buttons($buttons ? $buttons.empty() : elements.$buttons);
- view_controller.render_sidebar(elements.$sidebar);
- view_controller.render_pager(elements.$pager);
- // Remove the unnecessary outer div
- elements = _.mapObject(elements, function($node) {
- return $node && $node.contents();
- });
- // Use the adequate view switcher (mono- or multi-record)
- if (this.switch_buttons) {
- if (this.active_view.multi_record) {
- elements.$switch_buttons = this.switch_buttons.$multi;
- } else {
- elements.$switch_buttons = this.switch_buttons.$mono;
- }
- }
-
- // Store the rendered elements in the active_view to allow restoring them later
- this.active_view.control_elements = elements;
- }
- return this.active_view.control_elements;
- },
- /**
- * Sets up the current viewmanager's search view.
- * Sets $searchview and $searchview_buttons in searchview_elements to send to the ControlPanel
- */
- setup_search_view: function() {
- var self = this;
- if (this.searchview) {
- this.searchview.destroy();
- }
-
- var search_defaults = {};
- var context = this.action.context || [];
- _.each(context, function (value, key) {
- var match = /^search_default_(.*)$/.exec(key);
- if (match) {
- search_defaults[match[1]] = value;
- }
- });
-
- var options = {
- hidden: this.flags.search_view === false,
- disable_custom_filters: this.flags.search_disable_custom_filters,
- $buttons: $("<div>"),
- action: this.action,
- search_defaults: search_defaults,
- };
- // Instantiate the SearchView, but do not append it nor its buttons to the DOM as this will
- // be done later, simultaneously to all other ControlPanel elements
- this.searchview = new SearchView(this, this.dataset, this.search_fields_view, options);
-
- this.searchview.on('search_data', this, this.search.bind(this));
- return $.when(this.searchview.appendTo($("<div>"))).done(function() {
- self.searchview_elements = {};
- self.searchview_elements.$searchview = self.searchview.$el;
- self.searchview_elements.$searchview_buttons = self.searchview.$buttons.contents();
- });
- },
- /**
- * Executed on event "search_data" thrown by the SearchView
- */
- search: function(domains, contexts, groupbys) {
- var self = this;
- var controller = this.active_view.controller; // the correct view must be loaded here
- var action_context = this.action.context || {};
- var view_context = controller.get_context();
- pyeval.eval_domains_and_contexts({
- domains: [this.action.domain || []].concat(domains || []),
- contexts: [action_context, view_context].concat(contexts || []),
- group_by_seq: groupbys || []
- }).done(function (results) {
- if (results.error) {
- self.active_search.resolve();
- throw new Error(
- _.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
- JSON.stringify(results.error)));
- }
- self.dataset._model = new Model(
- self.dataset.model, results.context, results.domain);
- var groupby = results.group_by.length ?
- results.group_by :
- action_context.group_by;
- if (_.isString(groupby)) {
- groupby = [groupby];
- }
- if (!controller.grouped && !_.isEmpty(groupby)){
- self.dataset.set_sort([]);
- }
- $.when(controller.do_search(results.domain, results.context, groupby || [])).then(function() {
- self.active_search.resolve();
- });
- });
- },
- do_push_state: function(state) {
- if (this.action_manager) {
- state.view_type = this.active_view.type;
- this.action_manager.do_push_state(state);
- }
- },
- do_load_state: function(state, warm) {
- if (state.view_type && state.view_type !== this.active_view.type) {
- this.switch_mode(state.view_type, true);
- }
- this.active_view.controller.do_load_state(state, warm);
- },
- destroy: function () {
- if (this.control_elements) {
- if (this.control_elements.$switch_buttons) {
- this.control_elements.$switch_buttons.off();
- }
- }
- return this._super.apply(this, arguments);
- },
-});
-
-return ViewManager;
-
-});
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 00000000000..b90b1448ce2
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -0,0 +1,498 @@
+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 Widget = require('web.Widget');
+
+
+var AbstractController = Widget.extend({
+ custom_events: {
+ open_record: '_onOpenRecord',
+ },
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {AbstractModel} model
+ * @param {AbstractRenderer} renderer
+ * @param {object} params
+ * @param {string} params.modelName
+ * @param {any} [params.handle] a handle that will be given to the model (some id)
+ * @param {any} params.initialState the initialState
+ * @param {string} [params.noContentHelp]
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.model = model;
+ this.renderer = renderer;
+ this.modelName = params.modelName;
+ this.handle = params.handle;
+ this.activeActions = params.activeActions;
+ this.initialState = params.initialState;
+ this.noContentHelp = params.noContentHelp;
+ },
+ /**
+ * Simply renders and updates the url.
+ *
+ * @returns {Deferred}
+ */
+ start: function () {
+ return $.when(
+ this._super.apply(this, arguments),
+ this.renderer.appendTo(this.$el)
+ ).then(this._update.bind(this, this.initialState));
+ },
+ destroy: function () {
+ if (this.$buttons) {
+ this.$buttons.off();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns any special keys that may be useful when reloading the view to
+ * get the same effect. This is necessary for saving the current view in
+ * the favorites. For example, a graph view might want to add a key to
+ * save the current graph type.
+ *
+ * @returns {Object}
+ */
+ getContext: function () {
+ return {};
+ },
+ /**
+ * @see setScrollTop
+ * @returns {number}
+ */
+ getScrollTop: function () {
+ return this.scrollTop;
+ },
+ /**
+ * Returns a title that may be displayed in the breadcrumb area. For
+ * example, the name of the record.
+ *
+ * Note: this seems wrong right now, it should not be implemented, we have
+ * no guarantee that there is a display_name variable in a controller.
+ *
+ * @returns {string}
+ */
+ getTitle: function () {
+ return this.display_name;
+ },
+ /**
+ * 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] This object will simply be given to the update
+ * @returns {Deferred}
+ */
+ reload: function (params) {
+ return this.update(params || {});
+ },
+ /**
+ * Most likely called by the view manager, this method is responsible for
+ * adding buttons in the control panel (buttons such as save/discard/...)
+ *
+ * Note that there is no guarantee that this method will be called. The
+ * controller is supposed to work even without a view manager, for example
+ * in the frontend (odoo frontend = public website)
+ *
+ * @param {jQuery Node} $node
+ */
+ renderButtons: function ($node) {
+ },
+ /**
+ * For views that require a pager, this method will be called to allow the
+ * controller to instantiate and render a pager. Note that in theory, the
+ * controller can actually render whatever he wants in the pager zone. If
+ * your view does not want a pager, just let this method empty.
+ *
+ * @param {Query Node} $node
+ */
+ renderPager: function ($node) {
+ },
+ /**
+ * Same as renderPager, but for the 'sidebar' zone (the zone with the menu
+ * dropdown in the control panel next to the buttons)
+ *
+ * @param {Query Node} $node
+ */
+ renderSidebar: function ($node) {
+ },
+ /**
+ * Not sure about this one, it probably needs to be reworked, maybe merged
+ * in get/set local state methods.
+ *
+ * @see getScrollTop
+ * @param {number} scrollTop
+ */
+ setScrollTop: function (scrollTop) {
+ this.scrollTop = scrollTop;
+ },
+ /**
+ * 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 {Deferred}
+ */
+ update: function (params, options) {
+ var self = this;
+ var shouldReload = (options && 'reload' in options) ? options.reload : true;
+ var def = shouldReload ? this.model.reload(this.handle, params) : $.when();
+ return def.then(function (handle) {
+ self.handle = handle || self.handle; // update handle if we reloaded
+ var state = self.model.get(self.handle);
+ var localState = self.renderer.getLocalState();
+ return self.renderer.updateState(state, params).then(function () {
+ self.renderer.setLocalState(localState);
+ self._update(state);
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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.
+ *
+ * @param {Object} [state] information that will be pushed to the outside
+ * world
+ */
+ _pushState: function (state) {
+ this.trigger_up('push_state', state || {});
+ },
+ /**
+ * Hide or show the nocontent helper. For this, it also remove the renderer
+ * from the dom, and reattach it when necessary.
+ *
+ * This method is a helper for controllers that want to display a help
+ * message when no content is available. It is suggested to override
+ * _update to call this method.
+ *
+ * @param {boolean} hasNoContent
+ */
+ _toggleNoContentHelper: function (hasNoContent) {
+ if (hasNoContent) {
+ this.renderer.$el.detach();
+ var $msg = $('<div>')
+ .addClass('oe_view_nocontent')
+ .html(this.noContentHelp);
+ this.$el.html($msg);
+ } else {
+ if (!document.contains(this.renderer.el)) {
+ this.$('div.oe_view_nocontent').remove();
+ this.$el.append(this.renderer.$el);
+ }
+ }
+ },
+ /**
+ * 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.
+ *
+ * @param {Object} state the state given by the model
+ * @returns {Deferred}
+ */
+ _update: function (state) {
+ this._pushState();
+ return $.when();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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? or view manager
+ *
+ * @param {OdooEvent} event
+ * @param {number} event.data.id The local model ID for the record to be
+ * opened
+ * @param {string} [event.data.mode='readonly']
+ */
+ _onOpenRecord: function (event) {
+ var record = this.model.get(event.data.id, {raw: true});
+ this.trigger_up('switch_view', {
+ view_type: 'form',
+ res_id: record.res_id,
+ mode: event.data.mode || 'readonly',
+ });
+ },
+
+});
+
+return AbstractController;
+
+});
+
+ // do_show: function () {
+ // this._super.apply(this, arguments);
+ // core.bus.trigger('view_shown', this);
+ // },
+ // do_push_state: function (state) {
+ // if (this.getParent() && this.getParent().do_push_state) {
+ // this.getParent().do_push_state(state);
+ // }
+ // },
+ // do_load_state: function (state, warm) {
+ // },
+ /**
+ * Switches to a specific view type
+ */
+ // do_switch_view: function () {
+ // this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments)));
+ // },
+ // hasNoContent: function (state) {
+ // return !state.count && this.no_content_help;
+ // },
+ // displayNoContentHelp: function () {
+ // var $msg = $('<div>')
+ // .addClass('oe_view_nocontent')
+ // .html(this.no_content_help);
+ // this.$el.append($msg);
+ // return;
+ // },
+ // open_record: function (id, options) {
+ // // TODO: move this to view manager at some point
+ // var state = this.model.get(this.db_id);
+ // var record = this.model.get(id);
+ // var res_ids;
+ // if (state.groupedBy.length) {
+ // res_ids = _.pluck(_.flatten(_.pluck(state.data, 'data')), 'res_id');
+ // } else {
+ // res_ids = _.pluck(state.data, 'res_id');
+ // }
+ // options = _.extend({}, options, {
+ // dataset: {
+ // res_ids: res_ids,
+ // current_id: record.res_id,
+ // },
+ // });
+ // this.trigger_up('switch_view', {
+ // view_type: 'form',
+ // options: options,
+ // });
+ // },
+ // open_dialog: function (event) {
+ // var data = event.data;
+ // data.flags = _.defaults(data.flags || {}, {
+ // mode: 'edit',
+ // footer_to_buttons: true,
+ // action_buttons: false,
+ // headless: true
+ // });
+ // this.do_action({
+ // type: 'ir.actions.act_window',
+ // res_model: data.model || this.model,
+ // res_id: data.id,
+ // view_id: data.view_id,
+ // view_mode: data.type || 'form',
+ // view_type: data.type || 'form',
+ // views: [[false, data.type || 'form']],
+ // target: 'new',
+ // context: data.context,
+ // flags: data.flags,
+ // }, {
+ // on_load: function (action) {
+ // action.viewManager.$('.o_form_statusbar').remove();
+ // data.on_load && data.on_load(action.viewManager);
+ // },
+ // });
+ // },
+ // _onDeletedRecords: function () {
+ // this.update_state(this.db_id);
+ // },
+ // update_state: function (db_id) {
+ // this.db_id = db_id;
+ // var state = this.model.get(db_id);
+ // if (state.type === 'record') {
+ // this.set({ title : state.data.id ? state.data.display_name : _t("New") });
+ // }
+ // if (this.pager && this.config.hasPager) {
+ // this.update_pager();
+ // }
+ // if (this.$buttons) {
+ // this.update_buttons();
+ // }
+ // this.$('.oe_view_nocontent').remove();
+ // if (this.hasNoContent(state)) {
+ // if (this.renderer) {
+ // this.renderer.do_hide();
+ // }
+ // this.displayNoContentHelp();
+ // return;
+ // }
+ // if (this.renderer) {
+ // this.update_renderer();
+ // this.renderer.do_show();
+ // }
+ // var params = state.res_id ? {id: state.res_id} : {};
+ // this.do_push_state(params);
+ // core.bus.trigger('view_shown');
+ // },
+ // reload: function (event) {
+ // var db_id = event && event.data && event.data.db_id;
+ // if (db_id) {
+ // // reload the relational field given its db_id
+ // this.model.reload(db_id).then(this._confirmSave.bind(this, db_id));
+ // } else {
+ // // no db_id given, so reload the main record
+ // return this.model.reload(this.db_id).then(this.update_state.bind(this));
+ // }
+ // },
+ // update_renderer: function () {
+ // var state = this.model.get(this.db_id);
+ // return this.renderer.update(state);
+ // },
+ // renderPager: function ($node, options) {
+ // var data = this.model.get(this.db_id, {raw: true});
+ // this.pager = new Pager(this, data.count, data.offset + 1, this.page_size, options);
+
+ // this.pager.on('pager_changed', this, function (new_state) {
+ // var self = this;
+ // var data = this.model.get(this.db_id);
+ // this.pager.disable();
+ // var limit_changed = (this.page_size !== new_state.limit);
+ // this.page_size = new_state.limit;
+ // this.model
+ // .setLimit(data.id, new_state.limit)
+ // .setOffset(data.id, new_state.current_min - 1)
+ // .reload(data.id)
+ // .then(function (state) {
+ // self.update_state(state);
+ // // Reset the scroll position to the top on page changed only
+ // if (!limit_changed) {
+ // self.trigger_up('scrollTo', {offset: 0});
+ // }
+ // })
+ // .then(this.pager.enable.bind(this.pager));
+ // });
+ // this.pager.appendTo($node = $node || this.options.$pager);
+ // this.update_pager(); // to force proper visibility
+ // },
+ // update_buttons: function () {
+ // },
+ // update_pager: function () {
+ // var data = this.model.get(this.db_id);
+ // this.pager.updateState({
+ // current_min: data.offset + 1,
+ // size: data.count,
+ // });
+ // var is_pager_visible = (data.type === 'record') || (!!data.count && (data.groupedBy && !data.groupedBy.length));
+ // this.pager.do_toggle(is_pager_visible);
+ // },
+ // do_search: function (domain, context, group_by) {
+ // var load = this.db_id ? this._reload_data : this._load_data;
+ // return load.call(this, domain, context, group_by).then(this.update_state.bind(this));
+ // },
+ // _load_data: function (domain, context, group_by) {
+ // return this.model
+ // .load({
+ // type: 'record',
+ // model: this.modelName,
+ // domain: domain,
+ // groupedBy: group_by,
+ // context: context,
+ // });
+ // },
+ // _reload_data: function (domain, context, group_by) {
+ // return this.model
+ // .setDomain(this.db_id, domain)
+ // .setContext(this.db_id, context)
+ // .setGroupBy(this.db_id, group_by)
+ // .reload(this.db_id);
+ // },
+ // sidebar_eval_context: function () {
+ // return $.when({});
+ // },
+ // /**
+ // * Return whether the user can perform a given action (e.g. 'create', 'edit') in this view.
+ // * An action is disabled by setting the corresponding attribute in the view's main element,
+ // * like: <form string="" create="false" edit="false" delete="false">
+ // */
+ // is_action_enabled: function (action) {
+ // var attrs = this.fields_view.arch.attrs;
+ // return (action in attrs) ? JSON.parse(attrs[action]) : true;
+ // },
+ // config: {
+ // openGroupByDefault: false,
+ // page_size: 40,
+ // hasPager: true, // TODO: change this into false
+ // js_libs: [], // the list of lazy-loaded js dependencies
+ // css_libs: [], // the list of lazy-loaded css dependencies
+ // },
+ // defaults: {
+ // action: {},
+ // },
+ // used by views that need a searchview.
+ // searchable: true,
+ // used by views that need a searchview but don't want it to be displayed.
+ // searchview_hidden: false,
+
+ // events: {
+ // 'click a[type=action]': function (ev) {
+ // ev.preventDefault();
+ // var action_data = $(ev.target).attr('name');
+ // this.do_action(action_data);
+ // }
+ // },
+
+ // custom_events: _.extend({}, FieldManagerMixin.custom_events, {
+ // open_record: function (event) {
+ // this.open_record(event.data.id);
+ // },
+ // reload: 'reload',
+ // open_dialog: 'open_dialog',
+ // close_dialog: function () {
+ // this.do_action({type: 'ir.actions.act_window_close'});
+ // },
+ // // TODO: add open_action, ...
+ // }),
+
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 00000000000..78aa595a398
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_model.js
@@ -0,0 +1,69 @@
+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.
+ */
+
+var Class = require('web.Class');
+var mixins = require('web.mixins');
+
+var AbstractModel = Class.extend(mixins.EventDispatcherMixin, mixins.ServicesMixin, {
+ /**
+ * @param {Widget} parent
+ */
+ init: function (parent) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method should return the complete state necessary for the renderer
+ * to display the currently viewed 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 deferred 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
+ * @param {string} params.modelName the name of the model
+ * @returns {Deferred} The deferred resolves to some kind of handle
+ */
+ load: function (params) {
+ return $.when();
+ },
+ /**
+ * 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.
+ *
+ * @param {Object} params
+ * @returns {Deferred}
+ */
+ reload: function (params) {
+ return $.when();
+ },
+});
+
+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 00000000000..c47f6f7e3f3
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_renderer.js
@@ -0,0 +1,119 @@
+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 Widget = require('web.Widget');
+
+return Widget.extend({
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {any} state
+ * @param {Object} params
+ */
+ init: function (parent, state, params) {
+ this._super(parent);
+ this.state = state;
+ this.arch = params.arch;
+ },
+ /**
+ * The rendering can be asynchronous (but it is not encouraged). The start
+ * method simply makes sure that we render the view.
+ *
+ * @returns {Deferred}
+ */
+ start: function () {
+ this.$el.addClass(this.arch.attrs.class);
+ return $.when(this._render(), this._super());
+ },
+
+ //--------------------------------------------------------------------------
+ // 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 () {
+ },
+ /**
+ * 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) {
+ },
+ /**
+ * update the state of the view. It always retrigger a full rerender.
+ *
+ * @param {any} state
+ * @param {Object} params
+ * @returns {Deferred}
+ */
+ updateState: function (state, params) {
+ this.state = state;
+ return this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Render the view
+ *
+ * @abstract
+ * @private
+ * @returns {Deferred}
+ */
+ _render: function () {
+ return $.when();
+ },
+});
+
+});
+
+ // // this method is used to get local state unknown by renderer parent, but
+ // // necessary to restore the renderer to a full state after an update. For
+ // // example, the currently opened tabs in a form view, or the current scrolling
+ // // position
+ // getLocalState: function () {
+ // // to be implemented by actual renderer
+ // },
+ // // this method is used after an update (and may also be used after a new renderer
+ // // is instantiated). For example, opening a new record in a form view is
+ // // done by instantiating a new renderer. But the curently opened page should
+ // // be maintained if possible.
+ // setLocalState: function (local_state) {
+ // // to be implemented by actual renderer
+ // },
+
+ // var local_state = this.getLocalState();
+ // .then(this.setLocalState.bind(this, local_state));
+ // // todo: add and use getWidget method
+ // getWidget: function (node) {
+ // // read field type from node
+ // // or get widget name from attrs
+ // },
+ // },
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 00000000000..c95e4f084fe
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -0,0 +1,181 @@
+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 createController 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.
+ */
+
+var Class = require('web.Class');
+var ajax = require('web.ajax');
+var AbstractModel = require('web.AbstractModel');
+var AbstractRenderer = require('web.AbstractRenderer');
+var AbstractController = require('web.AbstractController');
+
+var AbstractView = Class.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,
+
+ // determine if a search view should be displayed in the control panel and
+ // allowed to interact with the view. Currently, the only not searchable
+ // views are the form view and the diagram view.
+ searchable: true,
+
+ config: {
+ Model: AbstractModel,
+ Renderer: AbstractRenderer,
+ Controller: AbstractController,
+ js_libs: [],
+ css_libs: [],
+ },
+
+ /**
+ * 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 {any} arch
+ * @param {any} fields
+ * @param {Object} params
+ * @param {string} params.modelName The actual model name
+ * @param {Object} params.context
+ * @param {number} [params.count]
+ * @param {string[]} params.domain
+ * @param {string[]} params.groupBy
+ * @param {number} [params.currentId]
+ * @param {number[]} [params.ids]
+ * @param {string} [params.action.help]
+ */
+ init: function (arch, fields, params) {
+ this.rendererParams = {
+ arch: arch
+ };
+
+ this.controllerParams = {
+ modelName: params.modelName,
+ activeActions: {
+ edit: arch.attrs.edit ? JSON.parse(arch.attrs.edit) : true,
+ create: arch.attrs.create ? JSON.parse(arch.attrs.create) : true,
+ delete: arch.attrs.delete ? JSON.parse(arch.attrs.delete) : true,
+ },
+ noContentHelp: params.action && params.action.help,
+ };
+
+ this.loadParams = {
+ context: params.context,
+ count: params.count || ((this.controllerParams.ids !== undefined) &&
+ this.controllerParams.ids.length) || 0,
+ domain: params.domain,
+ groupedBy: params.groupBy,
+ modelName: params.modelName,
+ res_id: params.currentId,
+ res_ids: params.ids,
+ };
+ if (params.modelName) {
+ this.loadParams.modelName = params.modelName;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Main method of the view class.
+ *
+ * @param {Widget} parent The parent of the resulting Controller (most
+ * likely a view manager)
+ * @returns {Deferred} The deferred resolves to a controller
+ */
+ createController: function (parent) {
+ var self = this;
+ var model = this.model || this.createModel(parent);
+ return $.when(model.load(this.loadParams), this._loadLibs()).then(function () {
+ var state = model.get(arguments[0]);
+ var renderer = self.createRenderer(parent, state);
+ var Controller = self.config.Controller;
+ var controllerParams = _.extend({
+ initialState: state,
+ }, self.controllerParams);
+ var controller = new Controller(parent, model, renderer, controllerParams);
+ renderer.setParent(controller);
+
+ if (!self.model) {
+ // if we have a model, it already has a parent. Otherwise, we
+ // set the controller, so the rpcs from the model actually work
+ model.setParent(controller);
+ }
+ return controller;
+ });
+ },
+ createModel: function (parent) {
+ var Model = this.config.Model;
+ return new Model(parent);
+ },
+ createRenderer: function (parent, state) {
+ var Renderer = this.config.Renderer;
+ return new Renderer(parent, state, this.rendererParams);
+ },
+ /**
+ * this is usedful to customize the actual class to use before calling
+ * createView.
+ *
+ * @param {Controller} Controller
+ */
+ setController: function (Controller) {
+ this.config.Controller = Controller;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Makes sure that the js_libs and css_libs are properly loaded. Note that
+ * the ajax loadJS and loadCSS methods don't do anything if the given file
+ * is already loaded.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _loadLibs: function () {
+ var defs = [];
+ _.each(this.config.js_libs, function (url) {
+ defs.push(ajax.loadJS(url));
+ });
+ _.each(this.config.css_libs, function (url) {
+ defs.push(ajax.loadCSS(url));
+ });
+ return $.when.apply($, defs);
+ },
+});
+
+return AbstractView;
+
+});
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 00000000000..24b5c7fa3d0
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -0,0 +1,178 @@
+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 FieldManagerMixin = require('web.FieldManagerMixin');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var Pager = require('web.Pager');
+
+var _t = core._t;
+
+return AbstractController.extend(FieldManagerMixin, {
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, {
+ reload: '_onReload',
+ }),
+ /**
+ * @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.handle = params.initialState.id;
+ },
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ return this._super.apply(this, arguments)
+ .then(this._updateEnv.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ renderPager: function ($node, options) {
+ var data = this.model.get(this.handle, {raw: true});
+ this.pager = new Pager(this, data.count, data.offset + 1, data.limit, options);
+
+ this.pager.on('pager_changed', this, function (newState) {
+ var self = this;
+ this.pager.disable();
+ var limitChanged = (data.limit !== newState.limit);
+ this.reload({limit: newState.limit, offset: newState.current_min - 1})
+ .then(function () {
+ // Reset the scroll position to the top on page changed only
+ if (!limitChanged) {
+ self.trigger_up('scrollTo', {offset: 0});
+ }
+ })
+ .then(this.pager.enable.bind(this.pager));
+ });
+ this.pager.appendTo($node);
+ this._updatePager(); // to force proper visibility
+ },
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ update: function (params, options) {
+ var self = this;
+ return this._super(params, options).then(function () {
+ self._updateEnv();
+ self._updatePager();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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, self.handle)
+ .then(self._onDeletedRecords.bind(self, ids));
+ }
+ if (this.confirmOnDelete) {
+ Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), {
+ confirm_callback: doIt,
+ });
+ } else {
+ doIt();
+ }
+ },
+ /**
+ * 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({});
+ },
+ /**
+ * Helper method, to get the current environment variables from the model
+ * and notifies the component chain (by bubbling an event up)
+ */
+ _updateEnv: function () {
+ var env = this.model.get(this.handle, {env: true});
+ this.trigger_up('env_updated', env);
+ },
+ /**
+ * Helper method, to make sure the information displayed by the pager is up
+ * to date.
+ */
+ _updatePager: function () {
+ if (this.pager) {
+ var data = this.model.get(this.handle, {raw: true});
+ this.pager.updateState({
+ current_min: data.offset + 1,
+ size: data.count,
+ });
+ var isRecord = data.type === 'record';
+ var hasData = !!data.count;
+ var isGrouped = data.groupedBy ? !!data.groupedBy.length : false;
+ var isNew = this.model.isNew(this.handle);
+ var isPagerVisible = isRecord ? !isNew : (hasData && !isGrouped);
+
+ this.pager.do_toggle(isPagerVisible);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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} event
+ * @param {Object} event.data
+ * @param {string} [event.data.db_id] handle of the data to reload and
+ * re-render (reload the whole form by default)
+ * @param {string[]} [event.data.fieldNames] list of the record's fields to
+ * reload
+ */
+ _onReload: function (event) {
+ var data = event && event.data || {};
+ var handle = data.db_id;
+ if (handle) {
+ // reload the relational field given its db_id
+ this.model.reload(handle).then(this._confirmSave.bind(this, handle));
+ } else {
+ // no db_id given, so reload the main record
+ this.reload({fieldNames: data.fieldNames});
+ }
+ },
+
+});
+
+});
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 00000000000..5baf65f1c30
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -0,0 +1,1996 @@
+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:
+ * {
+ * _changes: {
+ * data: { fieldName: field_values | [commands] },
+ * relational_data: { field_names: datapoint }
+ * },
+ * aggregate_values: { fieldName: number} | undefined
+ * count: number, default=0,
+ * context: {string:value},
+ * data: {string:value} | [{string:value}],
+ * field_names: [string],
+ * fields: {string:field description},
+ * grouped_by: [strings] | undefined,
+ * id: number,
+ * is_group: boolean,
+ * is_list: boolean,
+ * is_open: boolean,
+ * is_record: boolean,
+ * limit: number | undefined (default=80 for lists)
+ * model: string,
+ * offset: number, default=0,
+ * openGroupByDefault: boolean,
+ * ordered_by: [order] | undefined,
+ * res_id: [number]|undefined,
+ * res_ids: [number] | undefined,
+ * relational_data: {
+ * field_names: datapoints
+ * },
+ * static: boolean, * like previous DatasetStatic
+ * }
+ *
+ * Notes:
+ * - id: is totally unrelated to res_id. id is a web client local concept
+ * - res_id: if set to a number, 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 data = require('web.data'); // TODO: remove dependency to data.js
+var pyeval = require('web.pyeval');
+var session = require('web.session');
+
+var x2ManyCommands = {
+ // (0, _, {values})
+ CREATE: 0,
+ create: function (values) {
+ return [x2ManyCommands.CREATE, false, values];
+ },
+ // (1, id, {values})
+ UPDATE: 1,
+ update: function (id, values) {
+ 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 Model = AbstractModel.extend({
+ /**
+ * @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 dictionary is used to hold all many2one informations for widget
+ // selection.
+ this.many2ones = {};
+
+ // this dictionary is used to hold all many2many informations for widget
+ // many2many_checkboxes.
+ this.many2manys = {};
+
+ this.localData = Object.create(null);
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+
+ /**
+ * Add a default record to a list object. This method actually make a new
+ * record with the makeDefaultRecord method, then add it to the list object.
+ *
+ * @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 {Deferred<string>} resolves to the id of the new created record
+ */
+ addDefaultRecord: function (listID, options) {
+ var list = this.localData[listID];
+ var context = this._getContext(list);
+
+ var position = (options && options.position) || 'top';
+ var params = {
+ context: context,
+ fieldAttrs: list.fieldAttrs,
+ fieldNames: list.fieldNames,
+ fields: list.fields,
+ };
+ return this.makeDefaultRecord(list.model, params).then(function(id) {
+ list.count++;
+ if (position === 'top') {
+ list.data.unshift(id);
+ } else {
+ list.data.push(id);
+ }
+ return id;
+ });
+ },
+ /**
+ * Delete a list of records, then, if a parent_id is given, reload the
+ * parent.
+ *
+ * @todo we should remove the deleted records from the localData
+ * @todo is it really necessary to reload the data? it seems artificial, and
+ * the caller should be able to do that himself
+ * @todo why can't we infer modelName?
+ *
+ * @param {string[]} recordIds list of local resources ids. They should all
+ * be of type 'record', and of the same model
+ * @param {string} modelName
+ * @param {string} parent_id
+ * @returns {Deferred}
+ */
+ deleteRecords: function (recordIds, modelName, parent_id) {
+ var self = this;
+ var records = _.map(recordIds, function (id) { return self.localData[id]; });
+ return this.performModelRPC(modelName, 'unlink', [_.pluck(records, 'res_id')], {
+ context: session.user_context, // todo: combine with view context
+ }).then(function () {
+ _.each(records, function (record) {
+ record.res_ids.splice(record.offset, 1)[0];
+ record.res_id = record.res_ids[record.offset];
+ record.count--;
+ });
+ if (parent_id) {
+ return self.reload(parent_id);
+ }
+ });
+ },
+ /**
+ * Discard all changes in a local resource. Basically, it removes
+ * everything that was stored in a _changes key.
+ *
+ * @param {string} id local resource id
+ */
+ discardChanges: function (id) {
+ var element = this.localData[id];
+ this._visitChildren(element, function (elem) {
+ elem._changes = null;
+ });
+ },
+ /**
+ * Duplicate a record (by calling the 'copy' route)
+ *
+ * @param {string} recordID id for a local resource
+ * @returns {Deferred -> string} resolves to the id of duplicate record
+ */
+ duplicateRecord: function (recordID) {
+ var self = this;
+ var record = this.localData[recordID];
+ return this.performModelRPC(record.model, 'copy', [record.data.id], {
+ context: this._getContext(record),
+ }).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({
+ fieldAttrs: record.fieldAttrs,
+ fieldNames: record.fieldNames,
+ fields: record.fields,
+ modelName: record.model,
+ res_id: res_id,
+ res_ids: record.res_ids.slice(0),
+ });
+ });
+ },
+ /**
+ * 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 record = this.localData[id];
+
+ if (options.env) {
+ var env = {
+ ids: record.res_ids ? record.res_ids.slice(0) : [],
+ };
+ if (record.type === 'record') {
+ env.currentId = this.isNew(record.id) ? undefined : record.res_id;
+ }
+ return env;
+ }
+
+ // do not copy fields: this has a really big performance cost, for views
+ // with many records and lots of fields (for ex, kanban view contacts)
+ var fields = record.fields;
+ delete record.fields;
+ var element = $.extend(true, {}, record);
+ record.fields = fields;
+ element.fields = fields;
+
+ var field, relDataPoint;
+ if (element.type === 'record') {
+ // apply changes
+ _.extend(element.data, element._changes);
+
+ for (var fieldName in element.data) {
+ field = element.fields[fieldName];
+
+ // get relational datapoint
+ if (field.type === 'many2one') {
+ if (options.raw) {
+ relDataPoint = this.localData[element.data[fieldName]];
+ element.data[fieldName] = relDataPoint ? relDataPoint.res_id : false;
+ } else {
+ element.data[fieldName] = this.get(element.data[fieldName]) || false;
+ }
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ if (options.raw) {
+ relDataPoint = this.localData[element.data[fieldName]];
+ var ids = _.map(relDataPoint.data, function (id) {
+ return self.localData[id].res_id;
+ });
+ element.data[fieldName] = ids;
+ } else {
+ element.data[fieldName] = this.get(element.data[fieldName]) || [];
+ }
+ }
+
+ // weird way to get out many2ones for widget=selection
+ if (field.__fetch_selection) {
+ field.__selection_information = self.many2ones[field.relation];
+ }
+
+ // weird way to get out many2manys for widget=many2many_checkboxes
+ if (field.__fetch_many2manys) {
+ field.__many2many_information = self.many2manys[field.relation];
+ }
+ }
+
+ // this is not strictly necessary, but it hides some implementation
+ // details, and can easily be removed if needed.
+ delete element.orderedBy;
+ delete element.aggregateValues;
+ delete element.groupedBy;
+ }
+ if (element.type === 'list') {
+ // apply changes if any
+ if (element._changes) {
+ element.data = element._changes;
+ element.count = element._changes.length;
+ element.res_ids = _.map(element._changes, function (elemID) {
+ return self.localData[elemID].res_id;
+ });
+ }
+ // get relational datapoint
+ element.data = _.map(element.data, function (elemID) {
+ return self.get(elemID);
+ });
+ }
+
+ delete element._cache;
+ delete element._changes;
+ delete element.static;
+ delete element.parentID;
+ delete element.rawContext;
+ return element;
+ },
+ /**
+ * return true if a record is dirty. A record is considered dirty if it has
+ * some unsaved changes. A list is considered dirty if its _changes key is
+ * set to an array of its new datapoints (possibly empty)
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
+ */
+ isDirty: function (id) {
+ var isDirty = false;
+ var record = this.localData[id];
+ this._visitChildren(record, function (r) {
+ if (r.type === "record" ? !_.isEmpty(r._changes) : r._changes) {
+ isDirty = true;
+ }
+ });
+ return isDirty;
+ },
+ /**
+ * Check if a record is new, meaning if it is in the process of being
+ * created and no actual record exists in db.
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
+ */
+ isNew: function (id) {
+ return typeof this.localData[id].res_id !== 'number';
+ },
+ /**
+ * 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
+ *
+ * @param {any} params
+ * @param {Object} [params.fieldAttrs={}] contains the attrs of each field
+ * @param {Array} [params.fieldNames] the name of fields to load, the list
+ * of all fields by default
+ * @param {Object} params.fields contains the description of each field
+ * @param {string} [params.type] 'record' or 'list'
+ * @returns {Deferred -> string} resolves to a local id, or handle
+ */
+ load: function (params) {
+ params.type = params.type || (params.res_id !== undefined ? 'record' : 'list');
+
+ if (params.type === 'record' && params.res_id === undefined) {
+ return this.makeDefaultRecord(params.modelName, params);
+ }
+ var dataPoint = this._makeDataPoint(params);
+ return this._load(dataPoint).then(function () {
+ return dataPoint.id;
+ });
+ },
+ /**
+ * 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.
+ *
+ * @param {any} params
+ * @param {string} modelName model name
+ * @param {Object} params.context the context for the new record
+ * @param {Object} [params.fieldAttrs={}] contains the attrs of each field
+ * @param {Array} params.fieldNames the name of fields to load, the list
+ * of all fields by default
+ * @param {Object} params.fields contains the description of each field
+ * @param {Object} params.context the context for the new record
+ * @returns {Deferred -> string} resolves to the id for the created resource
+ */
+ makeDefaultRecord: function (modelName, params) {
+ var self = this;
+ var fields_key = _.without(params.fieldNames, '__last_update');
+
+ return this.performModelRPC(modelName, 'default_get', [fields_key], {
+ context: params.context,
+ }).then(function (result) {
+ // fill default values for missing fields
+ for (var i = 0; i < params.fieldNames.length; i++) {
+ var fieldName = params.fieldNames[i];
+ if (!(fieldName in result)) {
+ var field = params.fields[fieldName];
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ result[fieldName] = [];
+ } else {
+ result[fieldName] = null;
+ }
+ }
+ }
+
+ var data = {};
+ var record = self._makeDataPoint({
+ modelName: modelName,
+ data: data,
+ fields: params.fields,
+ fieldNames: params.fieldNames,
+ fieldAttrs: params.fieldAttrs,
+ context: params.context,
+ res_ids: params.res_ids,
+ });
+
+ var defs = []; // FIXME: remove defs?
+ _.each(params.fieldNames, function (name) {
+ var field = params.fields[name];
+ data[name] = null;
+ record._changes = record._changes || {};
+ if (field.type === 'many2one' && result[name]) {
+ var rec = self._makeDataPoint({
+ context: record.context,
+ data: {id: result[name]},
+ modelName: field.relation,
+ });
+ record._changes[name] = rec.id;
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ var x2manyList = self._makeDataPoint({
+ context: record.context,
+ fieldAttrs: field.fieldAttrs,
+ fields: field.relatedFields,
+ limit: field.limit,
+ modelName: field.relation,
+ res_ids: [],
+ static: true,
+ type: 'list',
+ });
+ record._changes[name] = x2manyList.id;
+ var many2ones = {};
+ _.each(result[name], function (command) {
+ if (command[0] === 0) {
+ // CREATE
+ var r = self._makeDataPoint({
+ modelName: x2manyList.model,
+ context: x2manyList.context,
+ fields: field.relatedFields,
+ fieldAttrs: field.fieldAttrs,
+ });
+ x2manyList._changes.push(r.id);
+ r._changes = command[2];
+
+ // this is necessary so the fields are initialized
+ for (var fieldName in command[2]) {
+ r.data[fieldName] = null;
+ }
+
+ for (var name in r._changes) {
+ if (r.fields[name].type === 'many2one') {
+ var rec = self._makeDataPoint({
+ context: r.context,
+ modelName: r.fields[name].relation,
+ data: {id: r._changes[name]}
+ });
+ r._changes[name] = rec.id;
+ many2ones[name] = true;
+ }
+ }
+ }
+ if (command[0] === 6) {
+ // REPLACE_WITH
+ x2manyList._changes = [];
+ }
+ });
+
+ // fetch many2ones display_name
+ _.each(_.keys(many2ones), function (name) {
+ defs.push(self._fetchNameGets(x2manyList, name));
+ });
+ } else {
+ record._changes[name] = result[name];
+ }
+ });
+ return $.when.apply($, defs)
+ .then(function () {
+ var shouldApplyOnchange = false;
+ var field;
+ for (var field_name in record.data) {
+ field = record.fields[field_name];
+ if (field.onChange) {
+ shouldApplyOnchange = true;
+ }
+ }
+ if (shouldApplyOnchange) {
+ return self._applyOnChange(record, fields_key);
+ } else {
+ return $.when();
+ }
+ })
+ .then(function () {
+ return self._fetchRelationalData(record);
+ })
+ .then(function () {
+ return self._postprocess(record);
+ })
+ .then(function () {
+ return record.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} [attrs] various field attrs that we want to set
+ * @returns {string} the local id for the created resource
+ */
+ makeRecord: function (model, fields, attrs) {
+ var self = this;
+ var record_fields = {};
+ _.each(fields, function (field) {
+ record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain');
+ });
+ var record = this._makeDataPoint({
+ modelName: model,
+ fields: record_fields,
+ fieldAttrs: attrs,
+ });
+ _.each(fields, function (field) {
+ if ('value' in field) {
+ if (field.type === 'many2one') {
+ var id = field.value[0];
+ var display_name = field.value[1];
+ var relatedRecord = self._makeDataPoint({
+ modelName: field.relation,
+ data: {
+ id: id,
+ display_name: display_name,
+ }
+ });
+ record.data[field.name] = relatedRecord.id;
+ } else {
+ record.data[field.name] = field.value;
+ }
+ }
+ });
+ return record.id;
+ },
+ /**
+ * This method just does a name_create call to the server.
+ *
+ * @todo Maybe this method should also create a local record...
+ *
+ * @param {string} model the name of the model
+ * @param {string} name the desired name of the created record
+ * @param {Object} context the context to be passed to the rpc
+ * @returns {Deferred}
+ */
+ nameCreate: function (model, name, context) {
+ return this.performModelRPC(model, 'name_create', [name], {
+ context: _.extend({}, session.user_context, context),
+ });
+ },
+ /**
+ * Do a name_search. I think that it should be done directly by the widget
+ * @todo remove this
+ * @deprecated
+ *
+ * @param {any} model
+ * @param {any} name
+ * @param {any} domain
+ * @param {any} operator
+ * @param {any} limit
+ * @returns
+ */
+ nameSearch: function (model, name, domain, operator, limit) {
+ // fixme: correctly handle context
+ return this.performModelRPC(model, 'name_search', [], {
+ name: name || '',
+ args: domain || [],
+ operator: operator || 'ilike',
+ limit: limit || 0,
+ });
+ },
+ /**
+ * 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
+ * @returns {string[]} list of changed fields
+ */
+ notifyChanges: function (record_id, changes) {
+ var self = this;
+ var record = this.localData[record_id];
+ var onChangeFields = []; // the fields that have changed and that have an on_change
+ var field;
+ var defs = [];
+ record._changes = record._changes || {};
+
+ // apply changes to local data
+ for (var fieldName in changes) {
+ field = record.fields[fieldName];
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName]));
+ } else if (field.type === 'many2one') {
+ defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName]));
+ } else {
+ record._changes[fieldName] = changes[fieldName];
+ }
+ if (field.onChange) {
+ onChangeFields.push(fieldName);
+ }
+ }
+
+ return $.when.apply($, defs).then(function () {
+ var onchangeDef;
+ if (onChangeFields.length) {
+ onchangeDef = self._applyOnChange(record, onChangeFields).then(function (result) {
+ return _.keys(changes).concat(Object.keys(result && result.value || {}));
+ });
+ } else {
+ onchangeDef = $.Deferred().resolve(_.keys(changes));
+ }
+ return onchangeDef.then(function (fieldNames) {
+ _.each(fieldNames, function (name) {
+ if (record._changes && record._changes[name] === record.data[name]) {
+ delete record._changes[name];
+ }
+ });
+ return fieldNames;
+ });
+ });
+ },
+ /**
+ * Reload all data for a given resource
+ *
+ * @param {string} id local id for a resource
+ * @param {Object} [options]
+ * @returns {Deferred -> string} resolves to the id of the resource
+ */
+ reload: function (id, options) {
+ options = options || {};
+ var element = this.localData[id];
+
+ if (element.type === 'record') {
+ if ('currentId' in options && !options.currentId) {
+ var params = {
+ context: element.context,
+ fieldAttrs: element.fieldAttrs,
+ fieldNames: element.fieldNames,
+ fields: element.fields,
+ };
+ return this.makeDefaultRecord(element.model, params);
+ }
+ this.discardChanges(id);
+ }
+
+ if (options.context !== undefined) {
+ element.context = options.context;
+ }
+ 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.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');
+ return this._load(element, loadOptions).then(function (result) {
+ return result.id;
+ });
+ },
+ /**
+ * 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} record_id local resource
+ * @param {Object} [options]
+ * @param {boolean} [options.reload=true] if true, data will be reloaded
+ * @returns {Deferred}
+ */
+ save: function (record_id, options) {
+ var self = this;
+ options = options || {};
+ var shouldReload = 'reload' in options ? options.reload : true;
+ return this.mutex.exec(function () {
+ var record = self.localData[record_id];
+ var method = self.isNew(record_id) ? 'create' : 'write';
+ var changes = _.extend({}, record._changes);
+ var commands = self._generateX2ManyCommands(record);
+ for (var fieldName in commands) {
+ if (commands[fieldName] === null) {
+ delete changes[fieldName];
+ } else {
+ changes[fieldName] = commands[fieldName];
+ }
+ }
+
+ // replace local ids by actual ids in relational changes
+ for (fieldName in changes) {
+ var field = record.fields[fieldName];
+ if (field.type === 'many2one') {
+ if (changes[fieldName]) {
+ var res_id = self.localData[changes[fieldName]].res_id;
+ changes[fieldName] = res_id;
+ } else {
+ changes[fieldName] = false;
+ }
+ }
+ }
+
+ if (method === 'create') {
+ _.each(record.fieldNames, function (name) {
+ changes[name] = changes[name] || record.data[name];
+ if (changes[name] === null) {
+ delete changes[name];
+ }
+ });
+ }
+ // make sure we don't write an undefined id
+ delete changes.id;
+
+ // in the case of a write, only perform the RPC if there are changes to save
+ if (method === 'create' || Object.keys(changes).length) {
+ var args = method === 'write' ? [[record.data.id], changes] : [changes];
+ return self.performModelRPC(record.model, method, args, {
+ context: session.user_context, // todo: combine with view context
+ }).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++;
+ }
+ // erase changes as they have been applied
+ record._changes = {};
+
+ return shouldReload ? self._fetchRecord(record) : false;
+ });
+ } else {
+ return $.when(record_id);
+ }
+ });
+ },
+ /**
+ * For list resources, this changes the orderedBy key, then performs the
+ * sort directly, in javascript. This is used for sorting static datasets,
+ * such as a one2many in a form view. For dynamic datasets, such as a list
+ * view, this method will be called, but then the sort will be ignored since
+ * we will reload data.
+ *
+ * @todo don't sort in js when we reload data anyway
+ *
+ * @param {string} list_id id for the list resource
+ * @param {string} fieldName valid field name
+ * @returns {this} so we can chain call this method
+ */
+ setSort: function (list_id, fieldName) {
+ var list = this.localData[list_id];
+ if (list.type === 'record') {
+ return;
+ }
+ list.offset = 0;
+ if (list.orderedBy.length === 0) {
+ list.orderedBy.push({name: fieldName, asc: true});
+ } else if (list.orderedBy[0].name === fieldName){
+ 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);
+ }
+ this._sortList(list);
+ return this;
+ },
+ /**
+ * 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 {Deferred -> string} resolves to the parent id
+ */
+ toggleActive: function(recordIDs, value, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ var resIDs = _.map(recordIDs, function(recordID) {
+ return self.localData[recordID].res_id;
+ });
+ return this
+ .performModelRPC(parent.model, 'write', [resIDs, { active: value }])
+ .then(this.reload.bind(this, parentID));
+
+ },
+ /**
+ * Toggle (open/close) a group in a grouped list, then fetches relevant
+ * data
+ *
+ * @param {string} groupId
+ * @returns {Deferred -> string} resolves to the group id
+ */
+ toggleGroup: function (groupId) {
+ var group = this.localData[groupId];
+ if (group.isOpen) {
+ group.isOpen = false;
+ group.data = [];
+ group.offset = 0;
+ return $.when(groupId);
+ }
+ if (!group.isOpen) {
+ group.isOpen = true;
+ var def;
+ if (group.count > 0) {
+ def = this._fetchUngroupedList(group);
+ }
+ return $.when(def).then(function () {
+ return groupId;
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply a many2one onchange. 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.
+ *
+ * @param {Object} record
+ * @param {string} fieldName
+ * @param {Object} [data]
+ * @returns {Deferred}
+ */
+ _applyMany2OneChange: function (record, fieldName, data) {
+ var self = this;
+ if (!data) {
+ record._changes[fieldName] = false;
+ return $.when();
+ }
+ var rel_data = _.pick(data, 'id', 'display_name');
+ var def;
+ if (!('display_name' in rel_data)) {
+ var field = record.fields[fieldName];
+ def = this.performModelRPC(field.relation, 'name_get', [data.id], {
+ context: record.context,
+ }).then(function (result) {
+ rel_data.display_name = result[0][1];
+ });
+ }
+ return $.when(def).then(function () {
+ var rec = self._makeDataPoint({
+ context: record.context,
+ data: rel_data,
+ fields: {},
+ modelName: record.fields[fieldName].relation,
+ });
+ record._changes[fieldName] = rec.id;
+ });
+ },
+ /**
+ * This method is quite important: it is supposed to perform the /onchange
+ * rpc and apply the result.
+ *
+ * @param {Object} record
+ * @param {string[]} fields changed fields
+ * @returns
+ */
+ _applyOnChange: function (record, fields) {
+ var self = this;
+ var onchange_spec = this._buildOnchangeSpecs(record);
+ var idList = record.data.id ? [record.data.id] : [];
+ var context = data.build_context(record, session.user_context).eval();
+
+ var rawData = this.get(record.id, {raw: true}).data;
+ var commands = this._generateX2ManyCommands(record);
+ var currentData = _.pick(_.extend(rawData, commands), record.fieldNames);
+ if (fields.length === 1) {
+ fields = fields[0];
+ }
+ return this.mutex.exec(function () {
+ var args = [idList, currentData, fields, onchange_spec, context];
+ return self.performModelRPC(record.model, 'onchange', args, {}).then(function (result) {
+ var defs = [];
+ _.each(result.value, function (val, name) {
+ var field = record.fields[name];
+ if (!field) { return; } // ignore changes of unknown fields
+
+ if (field.type === 'many2one' ) {
+ // 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.
+ // FIXME: shouldn't we erase the value in that case?
+ 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};
+ var rec = self._makeDataPoint({
+ context: context,
+ data: data,
+ modelName: field.relation,
+ });
+ record._changes[name] = rec.id;
+ }
+ } else if (field.type === 'one2many') {
+ record._changes = record._changes || {};
+ var listId = record._changes[name] || record.data[name];
+ var list = self.localData[listId];
+ var rec;
+ _.each(val, function (command) {
+ if (command[0] === 0 || command[0] === 1) {
+ // CREATE or UPDATE
+ var params = {
+ context: context,
+ fields: list.fields,
+ fieldAttrs: list.fieldAttrs,
+ modelName: list.model,
+ fieldNames: _.keys(command[2]),
+ };
+ if (command[0] === 1) {
+ params.res_id = command[1];
+ }
+ rec = self._makeDataPoint(params);
+ var data = command[2];
+ _.each(rec.fieldNames, function (name) {
+ var field = rec.fields[name];
+ if (field.type === 'many2one') {
+ var r = self._makeDataPoint({
+ modelName: field.relation,
+ data: {
+ id: data[name][0],
+ display_name: data[name][1],
+ },
+ });
+ data[name] = r.id;
+ }
+ });
+ rec._changes = data;
+ for (var f in rec._changes) {
+ rec.data[f] = null;
+ }
+ list._changes.push(rec.id);
+ }
+ if (command[0] === 5) {
+ // DELETE ALL
+ list._changes = [];
+ }
+ });
+ } else {
+ record._changes[name] = val;
+ }
+ });
+ return $.when.apply($, defs).then(function () {
+ return result;
+ });
+ });
+ });
+ },
+ /**
+ * 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'}
+ * @returns {Deferred}
+ */
+ _applyX2ManyChange: function (record, fieldName, command) {
+ var self = this;
+ var list = this.localData[record._changes[fieldName] || record.data[fieldName]];
+ var field = record.fields[fieldName];
+ var rec;
+ var defs = [];
+ list._changes = list._changes || list.data;
+
+ 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);
+ newRecord.fields = list.fields;
+ list._changes.push(newRecord.id);
+ break;
+ case 'ADD_M2M':
+ // 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];
+ var list_records = {};
+ _.each(data, function (d) {
+ rec = self._makeDataPoint({
+ context: record.context,
+ modelName: field.relation,
+ fields: field.relatedFields,
+ fieldAttrs: field.fieldAttrs,
+ res_id: d.id,
+ });
+ list_records[d.id] = rec;
+ list._changes.push(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 def = this
+ .performModelRPC(list.model, 'read', [_.pluck(data, 'id'), list.fieldNames], {})
+ .then(function (records) {
+ _.each(records, function (record) {
+ list_records[record.id].data = record;
+ });
+ });
+ defs.push(def);
+ break;
+ case 'CREATE':
+ defs.push(this.addDefaultRecord(list.id));
+ break;
+ case 'UPDATE':
+ var changedRecord = this.localData[command.id];
+ changedRecord._changes = changedRecord._changes || {};
+ _.extend(changedRecord._changes, _.pick(command.data, list.fieldNames));
+ break;
+ case 'REMOVE':
+ list._changes = _.without(list._changes, command.id);
+ break;
+ 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
+ var currentIds = _.map(list._changes, function (localId) {
+ return self.localData[localId].res_id;
+ });
+ var newIds = _.difference(command.ids, currentIds);
+ var removedIds = _.difference(currentIds, command.ids);
+ var addDef, removedDef;
+ if (newIds.length) {
+ var values = _.map(newIds, function (id) {
+ return {id: id};
+ });
+
+ addDef = this._applyX2ManyChange(record, fieldName, {
+ operation: 'ADD_M2M',
+ ids: values
+ });
+ }
+ if (removedIds.length) {
+ removedDef = this._applyX2ManyChange(record, fieldName, {
+ operation: 'REMOVE',
+ ids: removedIds
+ });
+ }
+ return $.when(addDef, removedDef);
+ }
+
+ return $.when.apply($, defs);
+ },
+ /**
+ * 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"}
+ *
+ * @see _applyOnChange
+ *
+ * @param {Object} record resource object of type 'record'
+ * @returns {Object} an onchange spec
+ */
+ _buildOnchangeSpecs: function (record) {
+ // TODO: replace this function by some generic tree function in utils
+ var specs = {};
+ _.each(record.fieldNames, function (name) {
+ var field = record.fields[name];
+ specs[name] = (field.onChange) || "";
+ _.each(field.views, function (view) {
+ _.each(view.fields, function (field, subname) {
+ specs[name + '.' + subname] = (field.onChange) || "";
+ });
+ });
+ });
+ return specs;
+ },
+ /**
+ * Fetch all name_gets for the many2ones in a group
+ *
+ * @param {Object} group a valid resource object
+ * @returns {Deferred}
+ */
+ _fetchMany2OneGroup: function (group) {
+ var ids = _.uniq(_.pluck(group, 'res_id'));
+
+ return this.performModelRPC(group[0].model, 'name_get', [ids], {
+ context: group[0].context,
+ }).then(function (name_gets) {
+ _.each(group, function (record) {
+ var nameGet = _.find(name_gets, function (n) { return n[0] === record.res_id;});
+ record.data.display_name = nameGet[1];
+ });
+ });
+ },
+ _fetchNameGets: function (list, fieldName) {
+ var self = this;
+ var model;
+ var records = [];
+ var ids = _.map(list._changes || list.data, function (localId) {
+ var record = self.localData[localId];
+ var data = record._changes || record.data;
+ var many2oneId = data[fieldName];
+ var many2oneRecord = self.localData[many2oneId];
+ records.push(many2oneRecord);
+ model = many2oneRecord.model;
+ return many2oneRecord.res_id;
+ });
+ return this.performModelRPC(model, 'name_get', [ids], {
+ context: list.context,
+ }).then(function (name_gets) {
+ for (var i = 0; i < name_gets.length; i++) {
+ records[i].data.display_name = name_gets[i][1];
+ }
+ });
+ },
+ /**
+ * For a given resource of type 'record', fetch all data.
+ *
+ * @param {Object} record local resource
+ * @param {string[]} [fieldNames] the list of fields to fetch. If not given,
+ * fetch all the fields in record.fieldNames (+ display_name)
+ * @returns {Deferred -> Object} resolves to the record
+ */
+ _fetchRecord: function (record, fieldNames) {
+ var self = this;
+ fieldNames = fieldNames || _.uniq(record.fieldNames.concat(['display_name']));
+ var oldData = record.data;
+ return this.performModelRPC(record.model, 'read', [record.res_id, fieldNames], {
+ // context: { 'bin_size': true }, // FIXME: when editing a subrecord in the partner form view, it tries to write the bin_size on the image field
+ }).then(function (result) {
+ result = result[0];
+ record.data = _.extend({}, record.data, result);
+ return self._postprocess(record);
+ }).then(function (record) {
+ // process many2one: split [id, nameget] and create corresponding record
+ _.each(fieldNames, function (name) {
+ var field = record.fields[name];
+ var val = record.data[name];
+ if (field.type === 'many2one') {
+ if (val !== false) {
+ // the many2one value is of the form [id, display_name]
+ var r = self._makeDataPoint({
+ modelName: field.relation,
+ data: {
+ display_name: val[1],
+ id: val[0],
+ },
+ });
+ record.data[name] = r.id;
+ } else {
+ // no value for the many2one
+ record.data[name] = false;
+ }
+ }
+ });
+ }).then(function () {
+ return self._fetchX2Manys(record, oldData, fieldNames).then(function () {
+ return record;
+ });
+ });
+ },
+ /**
+ * This method is incorrectly named. It should be named something like
+ * _fetchMany2OneData.
+ *
+ * For a given record, this method fetches all many2ones information,
+ * batching the requests if possible (for example, if 3 many2ones are in
+ * relation on the same model, then we can probably fetch them in one rpc)
+ *
+ * This method is currently only called by makeDefaultRecord, it should be
+ * called by the onchange methods at some point.
+ *
+ * @todo fix bug: returns a list of deferred, not a deferred
+ *
+ * @param {Object} record a valid resource object
+ * @returns {Deferred}
+ */
+ _fetchRelationalData: function (record) {
+ var self = this;
+ var toBeFetched = [];
+
+ // find all many2one related records to be fetched
+ _.each(record.fieldNames, function (name) {
+ var field = record.fields[name];
+ if (field.type === 'many2one') {
+ var localId = (record._changes && record._changes[name]) || record.data[name];
+ var relatedRecord = self.localData[localId];
+ if (!relatedRecord) {
+ return;
+ }
+ toBeFetched.push(relatedRecord);
+ }
+ });
+
+ // group them by model and context. Using the context as key is
+ // necessary to make sure the correct context is used for the rpc;
+ var groups = _.groupBy(toBeFetched, function (rec) {
+ return [rec.model, JSON.stringify(rec.context)].join();
+ });
+
+ return $.when.apply($, _.map(groups, this._fetchMany2OneGroup.bind(this)));
+ },
+ /**
+ * Fetch all data in a ungrouped list
+ *
+ * @param {Object} list a valid resource object
+ * @returns {Deferred -> Object} resolves to the fecthed list
+ */
+ _fetchUngroupedList: function (list) {
+ var self = this;
+ var def;
+ if (list.static) {
+ def = this._readUngroupedList(list);
+ } else {
+ def = this._searchReadUngroupedList(list);
+ }
+ return def.then(function () {
+ return self._fetchX2ManysBatched(list);
+ }).then(function () {
+ return list;
+ });
+ },
+ /**
+ * 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} oldData the data fetched previously
+ * @param {string[]} [fieldNames] list of fields to fetch
+ * @returns {Deferred}
+ */
+ _fetchX2Manys: function (record, oldData, fieldNames) {
+ var self = this;
+ var defs = [];
+ fieldNames = fieldNames || record.fieldNames;
+ _.each(fieldNames, function (fieldName) {
+ var field = record.fields[fieldName];
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ var list;
+
+ // a previously loaded list exists
+ if (oldData[fieldName]) {
+ list = self.localData[oldData[fieldName]];
+ list.res_ids = record.data[fieldName];
+ list.count = list.res_ids.length;
+ list.offset = list.offset < list.count ? list.offset : 0;
+ } else {
+ // need to create a list from scratch
+ var attrs = record.fieldAttrs[fieldName];
+ var rawContext = attrs && attrs.context;
+ var ids = record.data[fieldName] || [];
+ list = self._makeDataPoint({
+ count: ids.length,
+ fieldAttrs: field.fieldAttrs,
+ fields: field.relatedFields,
+ limit: field.limit,
+ modelName: field.relation,
+ res_ids: ids,
+ static: true,
+ type: 'list',
+ parentID: record.id,
+ rawContext: rawContext,
+ });
+ }
+ record.data[fieldName] = list.id;
+ if (!field.__no_fetch) {
+ defs.push(self._readUngroupedList(list));
+ }
+ }
+ });
+ return $.when.apply($, defs);
+ },
+ /**
+ * batch requests for 1 x2m in list
+ *
+ * @see _fetchX2ManysBatched
+ * @param {Object} list
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchX2ManyBatched: function (list, fieldName) {
+ var self = this;
+ var field = list.fields[fieldName];
+
+ // step 1: collect ids
+ var ids = [];
+ _.each(list.data, function (dataPoint) {
+ var record = self.localData[dataPoint];
+ ids = _.unique(ids.concat(record.data[fieldName] || []));
+ var m2mList = self._makeDataPoint({
+ fieldAttrs: field.fieldAttrs,
+ fields: field.relatedFields,
+ modelName: field.relation,
+ res_ids: record.data[fieldName],
+ static: true,
+ type: 'list',
+ });
+ record.data[fieldName] = m2mList.id;
+ });
+
+ if (!ids.length || field.__no_fetch) {
+ return $.when();
+ }
+
+ // step 2: fetch data from server
+ return this.performModelRPC(field.relation, 'read', [ids, _.keys(field.relatedFields)], {
+ context: {}, // FIXME
+ }).then(function (results) {
+ // step 3: assign values to correct datapoints
+ var dataPoints = _.map(results, function (result) {
+ return self._makeDataPoint({
+ modelName: field.relation,
+ data: result,
+ fields: field.relatedFields,
+ fieldAttrs: field.fieldAttrs,
+ });
+ });
+
+ _.each(list.data, function (dataPoint) {
+ var record = self.localData[dataPoint];
+ var m2mList = self.localData[record.data[fieldName]];
+
+ m2mList.data = [];
+ _.each(m2mList.res_ids, function (res_id) {
+ var dataPoint = _.find(dataPoints, function (d) {
+ return d.res_id === res_id;
+ });
+ m2mList.data.push(dataPoint.id);
+ m2mList.count++;
+ });
+ });
+ });
+ },
+ /**
+ * batch request for x2ms for datapoint of type list
+ *
+ * @param {Object} list
+ * @returns {Deferred}
+ */
+ _fetchX2ManysBatched: function (list) {
+ var defs = [];
+ for (var i = 0; i < list.fieldNames.length; i++) {
+ var field = list.fields[list.fieldNames[i]];
+ if (field.type === 'many2many' || field.type === 'one2many') {
+ defs.push(this._fetchX2ManyBatched(list, list.fieldNames[i]));
+ }
+ }
+ return $.when.apply($, defs);
+ },
+ /**
+ * Read all x2many fields and generate the commands for the server to create
+ * or write them...
+ *
+ * @param {Object} record
+ * @returns {Object} a map from some field names to commands
+ */
+ _generateX2ManyCommands: function (record) {
+ var self = this;
+ var commands = {};
+ var type, list, relData, relIds, removedIds, addedIds, keptIds, relRecord, i;
+
+ var data = _.extend({}, record.data, record._changes);
+
+ for (var fieldName in record.fields) {
+ type = record.fields[fieldName].type;
+
+ if (type === 'many2many' || type === 'one2many') {
+ // skip if this field is empty
+ if (!data[fieldName]) {
+ commands[fieldName] = null;
+ continue;
+ }
+ list = this.localData[data[fieldName]];
+ if (!list._changes) {
+ commands[fieldName] = null;
+ // skip if this field hasn't changed
+ continue;
+ }
+ relData = _.map(list._changes, function (localId) {
+ return self.localData[localId];
+ });
+ relIds = _.pluck(relData, 'res_id');
+ commands[fieldName] = [];
+
+ if (type === 'many2many') {
+ // 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
+ // an complete override of the actual value of the m2m)
+ commands[fieldName].push(x2ManyCommands.replace_with(relIds));
+ } else if (type === 'one2many') {
+ removedIds = _.difference(list.res_ids, relIds);
+ addedIds = _.difference(relIds, list.res_ids);
+ keptIds = _.intersection(list.res_ids, relIds);
+ for (i = 0; i < keptIds.length; i++) {
+ commands[fieldName].push(x2ManyCommands.link_to(keptIds[i]));
+ }
+ for (i = 0; i < removedIds.length; i++) {
+ commands[fieldName].push(x2ManyCommands.delete(removedIds[i]));
+ }
+ for (i = 0; i < addedIds.length; i++) {
+ relRecord = _.findWhere(relData, {res_id: addedIds[i]});
+ relRecord = this.get(relRecord.id, {raw: true});
+ commands[fieldName].push(x2ManyCommands.create(_.omit(relRecord.data, 'id')));
+ }
+ // FIXME: update of records temporarily disabled as it doesn't work if we updated
+ // the record in a form view, as the _changes contains all the fields of the
+ // form view instead of the one that have changed
+ // _.each(relData, function (relRecord) {
+ // var new_values = relRecord._changes;
+ // if (!_.isEmpty(new_values)) {
+ // commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, new_values));
+ // }
+ // });
+ }
+ }
+ }
+
+ 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} [options.field] if given, the context of the field will
+ * also be added (and evaluated) to the result
+ * @returns {Object} the evaluated context
+ */
+ _getContext: function (element, options) {
+ var context = new data.CompoundContext(session.user_context, element.context);
+ if (options && options.field) {
+ var attrs = element.fieldAttrs[options.field];
+ if (attrs && attrs.context) {
+ context.add(attrs.context);
+ }
+ }
+ if (element.rawContext) {
+ context.add(element.rawContext);
+ var evalContext = this.get(element.parentID, {raw: true}).data;
+ context.set_eval_context(evalContext);
+ }
+ return context.eval();
+ },
+ /**
+ * 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
+ * @returns {Deferred}
+ */
+ _load: function (dataPoint, options) {
+ if (dataPoint.type === 'record') {
+ return this._fetchRecord(dataPoint, options && options.fieldNames);
+ }
+ if (dataPoint.type === 'list' && dataPoint.groupedBy.length) {
+ return this._readGroup(dataPoint);
+ }
+ if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) {
+ return this._fetchUngroupedList(dataPoint);
+ }
+ },
+ /**
+ * Turns a bag of properties into a valid local resource. Also, register
+ * the resource in the localData object.
+ *
+ * @param {Object} params
+ * @param {Object} [params.fieldAttrs={}] contains the attrs of each field
+ * @param {Array} [params.fieldNames] the name of fields to load, the list
+ * of all fields by default
+ * @param {Object} params.fields contains the description of each field
+ * @returns {Object} the resource created
+ */
+ _makeDataPoint: function (params) {
+ var type = params.type || ('domain' in params && 'list') || 'record';
+
+ var res_id;
+ var value;
+ if (type === 'record') {
+ res_id = params.res_id || (params.data && params.data.id) || _.uniqueId('virtual_');
+ } else {
+ var value_is_array = params.value instanceof Array;
+ res_id = value_is_array ? params.value[0] : undefined;
+ value = value_is_array ? params.value[1] : params.value;
+ }
+ params.res_id = res_id;
+
+ var dataPoint = {
+ context: params.context || {},
+ domain: params.domain || [],
+ groupedBy: params.groupedBy || [],
+ id: _.uniqueId(params.modelName + '_'),
+ model: params.modelName,
+ res_id: res_id,
+ res_ids: params.res_ids || [],
+ };
+
+ this.localData[dataPoint.id] = dataPoint;
+
+ var fields = _.extend({
+ display_name: {type: 'char'},
+ id: {type: 'integer'},
+ }, params.fields);
+
+ _.extend(dataPoint, {
+ _cache: type === 'list' ? {} : undefined,
+ _changes: null,
+ aggregateValues: params.aggregateValues || {},
+ count: params.count || dataPoint.res_ids.length,
+ data: params.data || (type === 'record' ? {} : []),
+ fieldNames: params.fieldNames || Object.keys(fields),
+ fields: fields,
+ fieldAttrs: params.fieldAttrs || {},
+ isOpen: params.isOpen,
+ limit: type === 'record' ? 1 : params.limit,
+ offset: params.offset || (type === 'record' ? _.indexOf(dataPoint.res_ids, res_id) : 0),
+ openGroupByDefault: params.openGroupByDefault,
+ orderedBy: params.orderedBy || [],
+ parentID: params.parentID,
+ rawContext: params.rawContext,
+ static: params.static || false,
+ type: type, // 'record' | 'list'
+ value: value,
+ });
+
+ return dataPoint;
+ },
+ /**
+ * 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 {any} record
+ * @returns {Deferred -> Object} resolves to the finished resource
+ */
+ _postprocess: function (record) {
+ var self = this;
+ var defs = [];
+ _.each(record.fieldNames, function (name) {
+ var field = record.fields[name];
+ var attrs = record.fieldAttrs[name];
+ if (field.__fetch_status && !field.__status_information) {
+ var field_values = _.mapObject(record.data, function (val, key) {
+ var fieldType = record.fields[key].type;
+ if (fieldType === 'many2one') {
+ return val instanceof Array && val[0];
+ }
+ return val;
+ });
+ var _domain = new data.CompoundDomain(field.domain || {}).set_eval_context(field_values);
+ var domain = pyeval.eval('domain', _domain);
+ var fold_field = pyeval.py_eval(attrs.options || '{}').fold_field;
+ var fetch_status_information = self.performRPC('/web/dataset/search_read', {
+ model: field.relation,
+ fields: ['id'].concat(fold_field ? [fold_field] : []),
+ domain: domain,
+ }).then(function (result) {
+ var ids = _.pluck(result.records, 'id');
+ return self.performModelRPC(field.relation, 'name_get', [ids], {
+ context: self._getContext(record, {field: name}),
+ }).then(function (name_gets) {
+ _.each(result.records, function (record) {
+ var name_get = _.find(name_gets, function (n) {
+ return n[0] === record.id;
+ });
+ record.display_name = name_get[1];
+ });
+ field.__status_information = result.records;
+ });
+ });
+ defs.push(fetch_status_information);
+ }
+ if (field.__always_reload) {
+ if (record.data[name] instanceof Array) {
+ defs.push(self.performModelRPC(field.relation, 'name_get', [record.data[name][0]], {
+ context: self._getContext(record, {field: name}),
+ }).then(function (result) {
+ record.data[name] = result[0];
+ }));
+ }
+ }
+ if (field.__fetch_selection && !self.many2ones[field.relation]) {
+ var fetchSelection = self
+ .performModelRPC(field.relation, 'name_search', [], {})
+ .then(function (result) {
+ self.many2ones[field.relation] = result;
+ });
+ defs.push(fetchSelection);
+ }
+ if (field.__fetch_many2manys) {
+ var fetchMany2Manys = self
+ .performModelRPC(field.relation, 'name_search', [], {})
+ .then(function (result) {
+ self.many2manys[field.relation] = result;
+ });
+ defs.push(fetchMany2Manys);
+ }
+ });
+
+ return $.when.apply($, defs).then(function () {
+ return record;
+ });
+ },
+ /**
+ * 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
+ * @returns {Deferred<Object>} resolves to the fetched group object
+ */
+ _readGroup: function (list) {
+ var self = this;
+ var fields = _.uniq(list.fieldNames.concat(list.groupedBy));
+ return this.performModelRPC(list.model, 'read_group', [], {
+ fields: fields,
+ context: list.context,
+ groupby: list.groupedBy,
+ domain: list.domain,
+ orderby: false,
+ lazy: true,
+ }).then(function (groups) {
+ var rawGroupBy = list.groupedBy[0].split(':')[0];
+ var previousGroups = _.map(list.data, function (groupID) {
+ return self.localData[groupID];
+ });
+ list.data = [];
+ list.count = groups.length;
+ var defs = [];
+
+ _.each(groups, function (group) {
+ var aggregateValues = {};
+ _.each(group, function (value, key) {
+ if (_.contains(fields, key) && key !== list.groupedBy[0]) {
+ aggregateValues[key] = value;
+ }
+ });
+ var newGroup = self._makeDataPoint({
+ modelName: list.model,
+ count: group[rawGroupBy + '_count'],
+ domain: group.__domain,
+ context: list.context,
+ fields: list.fields,
+ fieldNames: list.fieldNames,
+ fieldAttrs: list.fieldAttrs,
+ value: group[rawGroupBy],
+ aggregateValues: aggregateValues,
+ groupedBy: list.groupedBy.slice(1),
+ orderedBy: list.orderedBy,
+ limit: list.limit,
+ openGroupByDefault: list.openGroupByDefault,
+ type: 'list', // nested groupedBys not handled yet
+ });
+ list.data.push(newGroup.id);
+ var old_group = _.find(previousGroups, function (g) {
+ return g.res_id === newGroup.res_id && g.value === newGroup.value;
+ });
+ if (old_group) {
+ newGroup.isOpen = old_group.isOpen;
+ } else if (!newGroup.openGroupByDefault) {
+ newGroup.isOpen = false;
+ } else {
+ newGroup.isOpen = '__fold' in group ? !group.__fold : true;
+ }
+ if (newGroup.isOpen && newGroup.count > 0) {
+ defs.push(self._fetchUngroupedList(newGroup));
+ }
+ });
+ return $.when.apply($, defs).then(function () {
+ // generate the res_ids of the main list, being the concatenation
+ // of the fetched res_ids in each group
+ list.res_ids = _.flatten(_.map(arguments, function (group) {
+ return group ? group.res_ids : [];
+ }));
+ 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 {Deferred -> Object} resolves to the fetched list object
+ */
+ _readUngroupedList: function (list) {
+ var self = this;
+ var def;
+ var ids = [];
+ var missingIds = [];
+ var upper_bound = list.limit ? Math.min(list.offset + list.limit, list.count) : list.count;
+ for (var i = list.offset; i < upper_bound; i++) {
+ var id = list.res_ids[i];
+ ids.push(id);
+ if (!list._cache[id]) {
+ missingIds.push(id);
+ }
+ }
+ if (missingIds.length) {
+ def = this.performModelRPC(list.model, 'read', [missingIds, list.fieldNames], {
+ context: {}, // FIXME
+ });
+ } else {
+ def = $.when();
+ }
+ return def.then(function (records) {
+ list.data = [];
+ _.each(ids, function (id) {
+ var dataPoint;
+ if (id in list._cache) {
+ dataPoint = self.localData[list._cache[id]];
+ } else {
+ dataPoint = self._makeDataPoint({
+ modelName: list.model,
+ data: _.findWhere(records, {id: id}),
+ fields: list.fields,
+ fieldNames: list.fieldNames,
+ fieldAttrs: list.fieldAttrs,
+ });
+
+ // add many2one records
+ _.each(dataPoint.fieldNames, function (name) {
+ var field = dataPoint.fields[name];
+ if (field.type === 'many2one') {
+ var r = self._makeDataPoint({
+ modelName: field.relation,
+ data: {
+ id: dataPoint.data[name][0],
+ display_name: dataPoint.data[name][1],
+ }
+ });
+
+ dataPoint.data[name] = r.id;
+ }
+ });
+ list._cache[id] = dataPoint.id;
+ }
+ list.data.push(dataPoint.id);
+ });
+ self._sortList(list);
+ return list;
+ });
+ },
+ /**
+ * 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 {Deferred}
+ */
+ _searchReadUngroupedList: function (list) {
+ var self = this;
+ return this.performRPC('/web/dataset/search_read', {
+ model: list.model,
+ fields: list.fieldNames,
+ domain: list.domain || [],
+ offset: list.offset,
+ limit: list.limit,
+ sort: this._serializeSort(list.orderedBy),
+ }).then(function (result) {
+ list.count = result.length;
+ var data = _.map(result.records, function (record) {
+ var dataPoint = self._makeDataPoint({
+ data: record,
+ fields: list.fields,
+ fieldNames: list.fieldNames,
+ fieldAttrs: list.fieldAttrs,
+ modelName: list.model,
+ });
+
+ // add many2one records
+ _.each(dataPoint.fieldNames, function (name) {
+ var field = dataPoint.fields[name];
+ if (field.type === 'many2one') {
+ var r = self._makeDataPoint({
+ modelName: field.relation,
+ data: {
+ id: dataPoint.data[name][0],
+ display_name: dataPoint.data[name][1],
+ }
+ });
+
+ dataPoint.data[name] = r.id;
+ }
+ });
+ return dataPoint.id;
+ });
+ list.data = data;
+ list.res_ids = _.pluck(result.records, 'id');
+ return list;
+ });
+ },
+ /**
+ * Helper method, generates a string to describe a ordered by sequence for
+ * SQL.
+ *
+ * For example, [{name: 'foo', asc: true}, {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.asc ? order.name + ' ASC' : order.name + ' DESC';
+ }).join(', ');
+ },
+ /**
+ * 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. Its intended use is for
+ * static datasets, such as a one2many in a form view.
+ *
+ * @param {Object} list
+ */
+ _sortList: function (list) {
+ var self = this;
+ if (list.orderedBy.length) {
+ // sort records according to ordered_by[0]
+ var order = list.orderedBy[0];
+ list.data.sort(function (r1, r2) {
+ var data1 = self.localData[r1].data;
+ var data2 = self.localData[r2].data;
+ if (data1[order.name] < data2[order.name]) {
+ return order.asc ? -1 : 1;
+ }
+ if (data1[order.name] > data2[order.name]) {
+ return order.asc ? 1 : -1;
+ }
+ return 0;
+ });
+ }
+ },
+ /**
+ * 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.
+ *
+ * @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 (_.contains(['one2many', 'many2one', 'many2many'], field.type)) {
+ var relationalElement = this.localData[element.data[fieldName]];
+
+ // relationalElement could be empty in the case of a many2one
+ if (relationalElement) {
+ self._visitChildren(relationalElement, fn);
+ }
+ }
+ }
+ }
+ if (element.type === 'list') {
+ _.each(element.data, function (elemId) {
+ var elem = self.localData[elemId];
+ self._visitChildren(elem, fn);
+ });
+ }
+ },
+});
+
+return Model;
+
+});
+
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 00000000000..22ec34f714b
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -0,0 +1,115 @@
+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.
+ *
+ * When a concrete view extends a BasicRenderer, it should add each field
+ * widgets in the this.widget list.
+ */
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var config = require('web.config');
+var core = require('web.core');
+
+var qweb = core.qweb;
+
+return AbstractRenderer.extend({
+ /**
+ * @override
+ */
+ init: function (paret, state, params) {
+ this._super.apply(this, arguments);
+ this.widgets = [];
+ this.activeActions = params.activeActions;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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({
+ delay: { show: 1000, hide: 0 },
+ title: function () {
+ return qweb.render('WidgetLabel.tooltip', {
+ debug: config.debug,
+ widget: widget,
+ });
+ }
+ });
+ },
+ /**
+ * Helper method, used to get an object with the 'raw' field values. It is
+ * important when we want to evaluate if a record matches a given domain.
+ *
+ * A basic model record is a tree structure, but we sometimes need 'pure'
+ * data, so for example, [1,4] for the value of a one2many, not
+ * [{Object}, {Object}].
+ *
+ * @param {Object} record
+ * @returns {Object}
+ */
+ _getFieldValues: function (record) {
+ return _.mapObject(record.data, function (value, name) {
+ var field = record.fields[name];
+ if (field.type === 'many2one') {
+ return value.data ? value.data.id || false : false;
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ if (value) {
+ return _.pluck(value.data, 'res_id');
+ } else {
+ return [];
+ }
+ }
+ if (field.type === 'boolean') {
+ // we want an explicit false value, not null if it is unset
+ return value || false;
+ }
+ return value;
+ });
+ },
+ /**
+ * Render the view
+ *
+ * @override
+ * @returns {Deferred}
+ */
+ _render: function () {
+ var oldWidgets = this.widgets;
+ this.widgets = [];
+ return this._renderView().then(_.invoke.bind(_, oldWidgets, 'destroy'));
+ },
+ /**
+ * Actual rendering. Supposed to be overridden by concrete renderers.
+ * The basic responsabilities of _renderView are:
+ * - use the xml arch of the view to render a jQuery representation
+ * - instantiate a widget from the widgets_registry for each field in the
+ * arch
+ *
+ * Note that the 'state' field should contains all necessary information
+ * for the rendering. The field widgets should be as synchronous as
+ * possible.
+ *
+ * @abstract
+ * @returns {Deferred}
+ */
+ _renderView: function () {
+ return $.when();
+ },
+});
+
+});
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 00000000000..1601cf4c350
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -0,0 +1,171 @@
+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 pyeval = require('web.pyeval');
+
+/**
+ * Visit a tree of objects, where each children are in an attribute 'children'.
+ * For each children, we call the callback function given in arguments.
+ *
+ * @todo move this somewhere in web.utils
+ *
+ * @param {Object} tree an object describing a tree structure
+ * @param {function} f a callback
+ */
+function traverse(tree, f) {
+ if (f(tree)) {
+ _.each(tree.children, function (c) { traverse(c, f); });
+ }
+}
+
+var BasicView = AbstractView.extend({
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: BasicModel,
+ Controller: BasicController,
+ }),
+ /**
+ * 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} arch
+ * @param {Object} fields
+ * @param {Object} params
+ */
+ init: function (arch, fields, params) {
+ this._super.apply(this, arguments);
+
+ var result = this._processFieldsView(arch, fields);
+ var fieldAttrs = result.fieldAttrs;
+ var view_fields = result.view_fields;
+ var fieldNames = _.keys(view_fields);
+ fields = _.defaults(view_fields, fields);
+ var self = this;
+ // 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
+ for (var field_name in fields) {
+ var field = fields[field_name];
+ _.each(field.views, function (inner_fields_view) {
+ result = self._processFieldsView(inner_fields_view.arch, inner_fields_view.fields);
+ inner_fields_view.fieldAttrs = result.fieldAttrs;
+ inner_fields_view.fields = result.view_fields;
+ });
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ if (field.views && field.views.tree) {
+ field.relatedFields = field.views.tree.fields;
+ field.fieldAttrs = field.views.tree.fieldAttrs;
+ field.limit = 80;
+ } else if (field.views && field.views.kanban) {
+ field.relatedFields = field.views.kanban.fields;
+ field.fieldAttrs = field.views.kanban.fieldAttrs;
+ field.limit = 40;
+ } else {
+ field.relatedFields = {
+ color: {type: 'int'},
+ display_name: {type: 'char'},
+ id: {type: 'integer'},
+ };
+ field.fieldAttrs = {};
+ }
+ }
+ }
+
+ this.controllerParams.confirmOnDelete = true;
+ this.controllerParams.archiveEnabled = 'active' in fields;
+ this.controllerParams.hasButtons =
+ 'action_buttons' in params ? params.action_buttons : true;
+
+ this.loadParams.fieldAttrs = fieldAttrs;
+ this.loadParams.fieldNames = fieldNames;
+ this.loadParams.fields = fields;
+ this.loadParams.limit = parseInt(arch.attrs.limit, 10) || params.limit;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Visit all nodes in the arch field and process each fields
+ *
+ * @param {Object} arch
+ * @param {Object} fields
+ * @returns {Object} An object with 2 keys: view_fields and fieldAttrs
+ */
+ _processFieldsView: function (arch, fields) {
+ var self = this;
+ var view_fields = Object.create(null);
+ var fieldAttrs = Object.create(null);
+ traverse(arch, function (node) {
+ if (typeof node === 'string') {
+ return false;
+ }
+ if (node.tag === 'field') {
+ var attrs = node.attrs || {};
+ var field = _.extend({}, fields[node.attrs.name]);
+ self._processField(field, node, attrs);
+ view_fields[node.attrs.name] = field;
+ fieldAttrs[node.attrs.name] = attrs;
+ return false;
+ }
+ return node.tag !== 'arch';
+ });
+ return {
+ view_fields: view_fields,
+ fieldAttrs: fieldAttrs,
+ };
+ },
+ /**
+ * Process a field node, in particular, put a flag on the field to give
+ * special directives to the BasicModel.
+ *
+ * @param {Object} field
+ * @param {Object} node
+ * @param {Object} attrs
+ */
+ _processField: function (field, node, attrs) {
+ if (attrs.options) {
+ var options = pyeval.py_eval(attrs.options || '{}');
+ if (options.always_reload) {
+ field.__always_reload = true;
+ }
+ }
+ if (field.type === 'many2one') {
+ if (node.attrs.widget === 'statusbar' || node.attrs.widget === 'radio') {
+ field.__fetch_status = true;
+ } else if (node.attrs.widget === 'selection') {
+ field.__fetch_selection = true;
+ }
+ }
+ if (node.attrs.widget === 'many2many_checkboxes') {
+ field.__fetch_many2manys = true;
+ }
+ if (node.attrs.on_change) {
+ field.onChange = "1";
+ }
+ },
+});
+
+return BasicView;
+
+});
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 00000000000..dbb574176f9
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -0,0 +1,267 @@
+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 QuickCreate = require('web.CalendarQuickCreate');
+var dialogs = require('web.view_dialogs');
+var Dialog = require('web.Dialog');
+var core = require('web.core');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var CalendarController = AbstractController.extend({
+ defaults: _.extend({}, AbstractController.prototype.defaults, {
+ confirm_on_delete: true,
+ }),
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ openCreate: '_onOpenCreate',
+ openEvent: '_onOpenEvent',
+ dropRecord: '_onDropRecord',
+ updateRecord: '_onUpdateRecord',
+ changeDate: '_onChangeDate',
+ changeFilter: '_onChangeFilter',
+ toggleFullWidth: '_onToggleFullWidth',
+ }),
+ /**
+ * @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.quick_add_pop = params.quick_add_pop;
+ this.disable_quick_create = params.disable_quick_create;
+ this.confirm_on_delete = params.confirm_on_delete;
+ this.formViewId = params.formViewId;
+ this.readonlyFormViewId = params.readonlyFormViewId;
+ this.mapping = params.mapping;
+ this.context = params.context;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+
+ /**
+ * Render the buttons according to the CalendarView.buttons template and
+ * add listeners on it. Set this.$buttons with the produced jQuery element
+ *
+ * @param {jQueryElement} [$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) {
+ var self = this;
+ this.$buttons = $(QWeb.render("CalendarView.buttons", {'widget': this}));
+ this.$buttons.on('click', 'button.o_calendar_button_new', function () {
+ self.trigger_up('switch_view', {view_type: 'form'});
+ });
+
+ _.each(['prev', 'today', 'next'], function (action) {
+ self.$buttons.on('click', '.o_calendar_button_' + action, function () {
+ self.model[action]();
+ self.reload();
+ });
+ });
+ _.each(['day', 'week', 'month'], function (scale) {
+ self.$buttons.on('click', '.o_calendar_button_' + scale, function () {
+ self.model.setScale(scale);
+ self.reload();
+ });
+ });
+
+ 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
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Object} record
+ * @param {integer} record.id
+ */
+ _updateRecord: function (record) {
+ // Cannot modify actual name yet
+ var data = _.omit(this.model.calendarEventToRecord(record), 'name');
+ this.performModelRPC(this.model, 'write', [record.id, data])
+ .then(this.reload.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {OdooEvent} event
+ */
+ _onChangeDate: function (event) {
+ var modelData = this.model.get();
+ if (modelData.target_date.toString() === event.data.date) {
+ 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 {
+ this.model.setScale('week');
+ }
+ this.model.setDate(event.data.date, true);
+ this.reload();
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onChangeFilter: function (event) {
+ if (this.model.changeFilter(event.data) && !event.data.no_reload) {
+ this.reload();
+ }
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onDropRecord: function (event) {
+ this._updateRecord(event.data);
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onOpenCreate: function (event) {
+ var self = this;
+ if (this.model.get().scale === "month") {
+ event.data.allDay = true;
+ }
+ var data = this.model.calendarEventToRecord(event.data);
+
+ var context = _.extend({}, this.context);
+ context.default_name = data.name;
+ context['default_' + this.mapping.date_start] = data.start;
+ if (this.mapping.date_stop) {
+ context['default_' + this.mapping.date_stop] = data.stop;
+ }
+ if (this.mapping.date_delay) {
+ context['default_' + this.mapping.date_delay] = data.duration;
+ }
+ if (this.mapping.all_day) {
+ context['default_' + this.mapping.all_day] = true;
+ }
+
+ var options = _.extend({}, this.options, {context: context});
+
+ if(!options.disable_quick_create && !event.data.disable_quick_create) {
+ if (this.quick != null) {
+ return this.quick.close();
+ }
+ var model = this.modelName;
+ var quick = new QuickCreate(this, model, true, options, data, event.data);
+ quick.on('added', this, this.reload.bind(this));
+ quick.open();
+ quick.focus();
+ return;
+ }
+
+ new dialogs.FormViewDialog(self, {
+ res_model: this.modelName,
+ context: context,
+ title: _t("Create"),
+ disable_multiple_selection: true,
+ on_saved: function () {
+ if (event.data.on_save) {
+ event.data.on_save();
+ }
+ self.reload();
+ },
+ }).open();
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onOpenEvent: function (event) {
+ var self = this;
+ var id = event.data._id;
+ id = id && parseInt(id).toString() === id ? parseInt(id) : id;
+ var open_dialog = function (readonly) {
+ var options = {
+ res_model: self.modelName,
+ res_id: id || null,
+ context: event.context || self.context,
+ readonly: readonly,
+ title: _t("Open: ") + event.data.title,
+ on_saved: function () {
+ if (event.data.on_save) {
+ event.data.on_save();
+ }
+ self.reload();
+ },
+ };
+ if (readonly) {
+ if (self.readonlyFormViewId) {
+ options.view_id = parseInt(self.readonlyFormViewId);
+ }
+ options.buttons = [
+ {
+ text: _t("Edit"),
+ classes: 'btn-primary',
+ close: true,
+ click: function () { open_dialog(false); }
+ },
+ {
+ text: _t("Delete"),
+ click: function () {
+ Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), {
+ confirm_callback: function () {
+ self.performModelRPC(self.modelName, 'unlink', [id])
+ .then(function () {
+ self.dialog.destroy();
+ self.reload();
+ });
+ }
+ });
+ },
+ },
+ {text: _t("Close"), close: true}
+ ];
+ } else if (self.formViewId) {
+ options.view_id = parseInt(self.formViewId);
+ }
+ self.dialog = new dialogs.FormViewDialog(self, options).open();
+ };
+ open_dialog(true);
+ },
+ /**
+ * Called when we want to open or close the sidebar.
+ */
+ _onToggleFullWidth: function () {
+ this.model.toggleFullWidth();
+ this.reload();
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onUpdateRecord: function (event) {
+ this._updateRecord(event.data);
+ },
+});
+
+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 00000000000..16ba9999ed0
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -0,0 +1,620 @@
+odoo.define('web.CalendarModel', function (require) {
+"use strict";
+
+var AbstractModel = require('web.AbstractModel');
+var field_utils = require('web.field_utils');
+var session = require('web.session');
+var time = require('web.time');
+var core = require('web.core');
+var data = require('web.data');
+
+var _t = core._t;
+
+var scales = [
+ 'day',
+ 'week',
+ 'month'
+];
+
+return AbstractModel.extend({
+ init: function () {
+ this._super.apply(this, arguments);
+ this.end_date = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // 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 = moment(new Date(event.start));
+ var end = moment(new Date(event.end));
+
+ //Bug when we move an all_day event from week or day view, we don't have
+ // a date.end or duration...
+ if (end === null || end.diff(start) < 0) {
+ end = start.clone().add(1, 'h');
+ }
+
+ if (event.allDay) {
+ if (this.scale === 'month') {
+ if (event.r_start) {
+ start.hours(event.r_start.hours())
+ .minutes(event.r_start.minutes())
+ .seconds(event.r_start.seconds())
+ .utc();
+ end.add(-1, 'days')
+ .hours(event.r_end.hours())
+ .minutes(event.r_end.minutes())
+ .seconds(event.r_end.seconds())
+ .utc();
+ } else {
+ start.utc();
+ end.utc();
+ }
+ } else if (this.mapping.all_day) {
+ start.utc().startOf('day');
+ end.utc().startOf('day');
+ } else {
+ start.utc().hours(7);
+ end.utc().hours(19);
+ }
+ } else {
+ var offset = start.utcOffset();
+ start.utc().add(-offset, 'minutes');
+ end.utc().add(-offset, 'minutes');
+ }
+
+ if (this.mapping.all_day) {
+ if (event.record) {
+ data[this.mapping.all_day] =
+ (this.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] = time.datetime_to_str(start.toDate());
+ if (this.mapping.date_delay) {
+ data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600;
+ }
+
+ if (this.mapping.date_stop) {
+ data[this.mapping.date_stop] = time.datetime_to_str(end.toDate());
+ }
+
+ 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;
+ },
+ /**
+ * @todo I think this is dead code
+ *
+ * @param {any} ids
+ * @param {any} model
+ * @returns
+ */
+ deleteRecords: function (ids, model) {
+ return this.performModelRPC(model, 'unlink', [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 {Deferred}
+ */
+ load: function (params) {
+ var self = this;
+ this.modelName = params.modelName;
+ this.fields = params.fields;
+ this.fieldNames = params.fieldNames;
+ this.mapping = params.mapping;
+ this.mode = params.mode; // one of month, week or day
+ this.scales = params.scales; // one of month, week or day
+
+ // Check whether the date field is editable (i.e. if the events can be
+ // dragged and dropped)
+ this.editable = params.editable;
+
+ // 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.field_color = params.field_color;
+ if (!this.preload_def) {
+ var def;
+ if (this.field_color) {
+ var fields = this.field_color.split('.');
+ if (fields.length > 1) {
+ def = new $.Deferred();
+ if (!this.model_color) {
+ var dataset = new data.DataSetSearch(this, this.fields[fields[0]].relation, params.context, []);
+ this.loadFieldView(dataset, false, 'form').then(function (fieldView) {
+ self.model_color = fieldView.fields[fields[1]].relation;
+ def.resolve();
+ });
+ } else {
+ def.resolve();
+ }
+ }
+ }
+
+ this.preload_def = $.Deferred();
+ $.when(
+ def,
+ this.performModelRPC(this.modelName, 'check_access_rights', ["write", false]),
+ this.performModelRPC(this.modelName, 'check_access_rights', ["create", false]))
+ .then(function (write, create) {
+ self.write_right = write;
+ self.create_right = create;
+ self.preload_def.resolve();
+ });
+ }
+
+ 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,
+ };
+
+ // Use scale_zoom attribute in xml file to specify zoom timeline (day,week,month)
+ // by default month.
+ this.setDate(params.initialDate, true);
+ this.setScale(params.mode);
+
+ _.each(this.data.filters, function (filter) {
+ if (filter.avatar_field && !filter.avatar_model) {
+ filter.avatar_model = self.modelName;
+ }
+ });
+
+ return this.preload_def.then(this._loadCalendar.bind(this));
+ },
+ next: function () {
+ this.setDate(moment(this.data.target_date).add(1, this.data.scale));
+ },
+ prev: function () {
+ this.setDate(moment(this.data.target_date).add(-1, this.data.scale));
+ },
+ /**
+ * @todo: this should not work. it ignores the domain/context
+ *
+ * @override
+ * @param {any} _handle ignored
+ * @param {any} _params ignored ? really ?
+ * @returns {Deferred}
+ */
+ reload: function (_handle, params) {
+ return this._loadCalendar();
+ },
+ /**
+ * @param {any} start
+ * @param {any} highlight
+ */
+ setDate: function (start, highlight) {
+ this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = new Date(start);
+ switch (this.data.scale) {
+ case 'month':
+ this.data.start_date = moment(this.data.start_date)
+ .startOf('month')
+ .startOf('week')
+ .toDate();
+ this.data.end_date = moment(this.data.start_date)
+ .add(5, 'week')
+ .endOf('week')
+ .toDate();
+ break;
+ case 'week':
+ this.data.start_date = moment(this.data.start_date)
+ .startOf('week')
+ .toDate();
+ this.data.end_date = moment(this.data.end_date)
+ .endOf('week')
+ .toDate();
+ break;
+ default:
+ this.data.start_date = moment(this.data.start_date)
+ .startOf('day')
+ .toDate();
+ this.data.end_date = moment(this.data.end_date)
+ .endOf('day')
+ .toDate();
+ }
+ if (highlight) {
+ this.data.highlight_date = this.data.target_date;
+ }
+ },
+ setScale: function (scale) {
+ if (!_.contains(scales, scale)) {
+ scale = "week";
+ }
+ this.data.scale = scale;
+ this.setDate(this.data.target_date);
+ },
+ today: function () {
+ this.setDate(new Date());
+ },
+ toggleFullWidth: function () {
+ var fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') !== 'true';
+ this.call('local_storage', 'setItem', 'calendar_fullWidth', fullWidth);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _getFilterDomain: function () {
+ var domain = [];
+ _.each(this.data.filters, function (filter) {
+ if (filter.all) return;
+ var nb = {};
+ var not = {};
+ var contains = {};
+ _.each(filter.filters, function (f) {
+ if (!contains[filter.fieldName]) {
+ contains[filter.fieldName] = [];
+ not[filter.fieldName] = [];
+ nb[filter.fieldName] = 0;
+ }
+ if (f.value === 'all' && f.active) {
+ contains[filter.fieldName].all = true;
+ } else {
+ nb[filter.fieldName]++;
+ }
+
+ if (f.active && contains[filter.fieldName].indexOf(f.value) === -1) {
+ contains[filter.fieldName].push(f.value);
+ }
+ if (!f.active && not[filter.fieldName].indexOf(f.value) === -1) {
+ not[filter.fieldName].push(f.value);
+ }
+ });
+
+ for (var field in contains) {
+ if (contains[field].all || contains[field].length === nb[field]) {
+ continue;
+ }
+ if (contains[field].length) {
+ domain.push([field, 'in', contains[field]]);
+ } else if (not[field].length && not[field].length !== nb[field]) {
+ domain.push([field, 'not in', not[field]]);
+ }
+ }
+ });
+ return domain;
+ },
+ /**
+ * @returns {Object}
+ */
+ _getFullCalendarOptions: function () {
+ return {
+ defaultView: (this.mode == "month")? "month" : ((this.mode === "week")? "agendaWeek" : ((this.mode === "day")? "agendaDay" : "agendaWeek")),
+ header: false,
+ selectable: this.editable && this.create_right,
+ selectHelper: true,
+ editable: this.editable,
+ droppable: true,
+ navLinks: false,
+ eventLimit: this.eventLimit, // allow "more" link when too many events
+ snapMinutes: 15,
+ eventResizableFromStart: true,
+ weekNumberCalculation: function (date) {
+ return moment(date).week();
+ },
+ weekNumberTitle: _t("W"),
+ allDayText: _t("All day"),
+ views: {
+ week: {
+ columnFormat: 'ddd ' + time.strftime_to_moment_format(_t.database.parameters.date_format),
+ }
+ },
+ monthNames: moment.months(),
+ monthNamesShort: moment.monthsShort(),
+ dayNames: moment.weekdays(),
+ dayNamesShort: moment.weekdaysShort(),
+ };
+ },
+ _getRangeDomain: function () {
+ // Build OpenERP Domain to filter object by this.mapping.date_start field
+ // between given start, end dates.
+ var format = time.datetime_to_str;
+ var domain = [[this.mapping.date_start, '<=', format(this.data.end_date)]];
+ if (this.mapping.date_stop) {
+ domain.push([this.mapping.date_stop, '>=', format(this.data.start_date)]);
+ } else if (!this.mapping.date_delay) {
+ domain.push([this.mapping.date_start, '>=', format(this.data.start_date)]);
+ }
+ return domain;
+ },
+ /**
+ * @returns {Deferred}
+ */
+ _loadCalendar: function () {
+ var self = this;
+ this.data.fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') === 'true';
+ this.data.fc_options = this._getFullCalendarOptions();
+
+ var defs = _.map(this.data.filters, this._loadFilter.bind(this));
+
+ return $.when.apply($, defs).then(function () {
+ return self.performRPC('/web/dataset/search_read', {
+ model: self.modelName,
+ context: self.data.context,
+ domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()),
+ fields: self.fieldNames,
+ }).then(function (events) {
+ self.data.data = _.map(events.records, self._recordToCalendarEvent.bind(self));
+ return $.when(
+ self._loadColors(self.data, self.data.data),
+ self._loadRecordsToFilters(self.data, self.data.data)
+ );
+ });
+ });
+ },
+ /**
+ * @param {any} element
+ * @param {any} events
+ * @returns {Deferred}
+ */
+ _loadColors: function (element, events) {
+ if (this.field_color) {
+ var fields = this.field_color.split('.');
+ var first = fields[0];
+ var self = this;
+
+ if (fields.length === 1) {
+ _.each(events, function (event) {
+ var value = event.record[first];
+ event.color_index = _.isArray(value) ? value[0] : value;
+ });
+ this.model_color = this.fields[first].relation || element.model;
+ } else {
+ var model = this.fields[first].relation;
+ var ids = [];
+ _.each(events, function (event) {
+ ids.push(event.record[first][0]);
+ });
+ ids = _.uniq(ids);
+
+ return self.performModelRPC(model, 'read', [ids, [fields[1]]]).then(function (res) {
+ res = _.object(_.pluck(res, ['id']), _.pluck(res, [fields[1]]));
+ _.each(events, function (event) {
+ var value = res[event.record[first][0]];
+ event.color_index = _.isArray(value) ? value[0] : value;
+ });
+ });
+ }
+ }
+ return $.Deferred().resolve();
+ },
+ /**
+ * @param {any} filter
+ * @returns {Deferred}
+ */
+ _loadFilter: function (filter) {
+ if (!filter.write_model) {
+ return;
+ }
+
+ var field = this.fields[filter.fieldName];
+ return this.performRPC('/web/dataset/search_read', {
+ model: filter.write_model,
+ domain: [["user_id", "=", session.uid]],
+ fields: [filter.write_field]
+ }).then(function (res) {
+ var records = _.map(res.records, 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 = field_utils['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;
+ });
+ },
+ /**
+ * @param {any} element
+ * @param {any} events
+ * @returns {Deferred}
+ */
+ _loadRecordsToFilters: function (element, events) {
+ var self = this;
+ var new_filters = {};
+ var to_read = {};
+ _.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 = [];
+ _.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;
+ fs.push({
+ 'color': self.model_color === (field.relation || element.model) ? value : false,
+ 'value': value,
+ 'label': field_utils['format_'+ field.type](_value, field),
+ 'avatar_model': field.relation || element.model,
+ });
+ });
+ });
+ _.each(fs, function (f) {
+ var f1 = _.findWhere(filter.filters, f);
+ if (f1) {
+ f1.display = true;
+ } else {
+ f.display = f.active = true;
+ filter.filters.push(f);
+ }
+ });
+ });
+
+ var defs = [];
+ _.each(to_read, function (ids, model) {
+ defs.push(self.performModelRPC(model, 'name_get', [_.uniq(ids)]).then(function (res) {
+ to_read[model] = _.object(res);
+ }));
+ });
+ return $.when.apply($, 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];
+ });
+ }
+ });
+ });
+ },
+ /**
+ * Transform OpenERP event object to fullcalendar event object
+ */
+ _recordToCalendarEvent: function (evt) {
+ var date_start;
+ var date_stop;
+ var date_delay = evt[this.mapping.date_delay] || 1.0,
+ all_day = this.mapping.all_day ? evt[this.mapping.all_day] : false,
+ the_title = '',
+ attendees = [];
+
+ if (!all_day) {
+ date_start = time.auto_str_to_date(evt[this.mapping.date_start]);
+ date_stop = this.mapping.date_stop ? time.auto_str_to_date(evt[this.mapping.date_stop]) : null;
+ } else {
+ date_start = time.auto_str_to_date(evt[this.mapping.date_start].split(' ')[0],'start');
+ date_stop = this.mapping.date_stop ? time.auto_str_to_date(evt[this.mapping.date_stop].split(' ')[0],'start') : null;
+ }
+
+ if (!date_stop && date_delay) {
+ var m_start = moment(date_start).add(date_delay,'hours');
+ date_stop = m_start.toDate();
+ }
+
+ date_start = moment(date_start);
+ date_stop = moment(date_stop);
+
+ var r = {
+ 'record': evt,
+ 'start': date_start.format('YYYY-MM-DD HH:mm:ss'),
+ 'end': date_stop.format('YYYY-MM-DD HH:mm:ss'),
+ 'r_start':date_start.clone(),
+ 'r_end':date_stop.clone(),
+ 'title': the_title,
+ 'allDay': this.mapping.all_day && evt[this.mapping.all_day] || false,
+ 'id': evt.id,
+ 'attendees':attendees,
+ };
+
+ if (this.mapping.all_day && evt[this.mapping.all_day]) {
+ // r.start = date_start.format('YYYY-MM-DD');
+ // r.end = date_stop.format('YYYY-MM-DD');
+ } else if (this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') {
+ // allow to resize in month mode
+ r.reset_allday = r.allDay;
+ r.allDay = true;
+ r.start = date_start.format('YYYY-MM-DD');
+ r.end = date_stop.clone().add(1, 'day').startOf('day').format('YYYY-MM-DD');
+ }
+
+ return r;
+ },
+});
+
+});
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 00000000000..326c36ea9eb
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js
@@ -0,0 +1,140 @@
+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({
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {string} modelName
+ * @param {any} buttons
+ * @param {any} options
+ * @param {any} data_template
+ * @param {any} data_calendar
+ */
+ init: function (parent, modelName, buttons, options, data_template, data_calendar) {
+ this.modelName = modelName;
+ this._buttons = buttons || false;
+ this.options = options;
+
+ // Can hold data pre-set from where you clicked on agenda
+ this.data_template = data_template || {};
+ this.$input = $();
+
+ var self = this;
+ this._super(parent, {
+ title: this._getTitle(),
+ size: 'small',
+ buttons: this._buttons ? [
+ {text: _t("Create"), classes: 'btn-primary', click: function () {
+ if (!self._quickAdd()) {
+ self.focus();
+ }
+ }},
+ {text: _t("Edit"), click: function () {
+ data_calendar.disable_quick_create = true;
+ data_calendar.title = self.$input.val().trim();
+ data_calendar.on_save = self.destroy.bind(self);
+ self.trigger_up('openCreate', data_calendar);
+ }},
+ {text: _t("Cancel"), close: true},
+ ] : [],
+ $content: QWeb.render('CalendarView.quick_create', {widged: this})
+ });
+ },
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ var self = this;
+
+ if (this.options.disable_quick_create) {
+ this.slow_create();
+ return;
+ }
+ this.on('added', this, function () {
+ self.close();
+ });
+
+ this.$input = this.$('input').keyup(function enterHandler (e) {
+ if(e.keyCode === $.ui.keyCode.ENTER) {
+ self.$input.off('keyup', enterHandler);
+ if (!self._quickAdd()){
+ self.$input.on('keyup', enterHandler);
+ }
+ } else if (e.keyCode === $.ui.keyCode.ESCAPE && self._buttons) {
+ self.close();
+ }
+ });
+
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ focus: function () {
+ this.$input.focus();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ _getTitle: function () {
+ var parent = this.getParent();
+ if (_.isUndefined(parent)) {
+ return _t("Create");
+ }
+ var title = (_.isUndefined(parent.field_widget)) ?
+ (parent.title || parent.string || parent.name) :
+ (parent.field_widget.string || parent.field_widget.name || '');
+ return _t("Create: ") + title;
+ },
+ /**
+ * Gathers data from the quick create dialog a launch quick_create(data) method
+ */
+ _quickAdd: function () {
+ var val = this.$input.val().trim();
+ return (val)? this._quickCreate({'name': val}, this.options) : false;
+ },
+ /**
+ * Handles saving data coming from quick create box
+ */
+ _quickCreate: function (data, options) {
+ var self = this;
+ return this.performModelRPC(this.modelName, 'create',
+ [$.extend({}, this.data_template, data)], _.pick(options, 'context'))
+ .then(function (id) {
+ self.trigger('added', id);
+ self.$input.val("");
+ }, function (r, event) {
+ event.preventDefault();
+ // This will occurs if there are some more fields required
+ self.slow_create(data);
+ });
+ },
+});
+
+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 00000000000..5062051b8da
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -0,0 +1,450 @@
+odoo.define('web.CalendarRenderer', function (require) {
+"use strict";
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var relational_fields = require('web.relational_fields');
+var FieldManagerMixin = require('web.FieldManagerMixin');
+var field_utils = require('web.field_utils');
+var session = require('web.session');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+var utils = require('web.utils');
+var core = require('web.core');
+var QWeb = require('web.QWeb');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var scales = {
+ day: 'agendaDay',
+ week: 'agendaWeek',
+ month: 'month'
+};
+
+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.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;
+
+ if (this.write_model || this.write_field) {
+ this.many2one = new SidebarFilterM2O(this,
+ this.write_field,
+ this.model.get(this.model.makeRecord(this.write_model, [{
+ name: this.write_field,
+ relation: options.fields[this.fieldName].relation,
+ type: 'many2one',
+ }])),
+ {
+ mode: 'edit',
+ can_create: false,
+ });
+ }
+ },
+ 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_checkbox input', this._onFilterActive.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ var self = this;
+ event.stopPropagation();
+ var value = event.data.changes[this.write_field].id;
+ this.model.performModelRPC(this.write_model, 'create', [{'user_id': session.uid,'partner_id': value,}])
+ .then(function () {
+ self.trigger_up('changeFilter', {
+ 'fieldName': self.fieldName,
+ 'value': value,
+ 'active': true,
+ });
+ });
+ },
+ _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'),
+ });
+ },
+ /**
+ * @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.performModelRPC(self.write_model, 'unlink', [[$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",
+ events: _.extend({}, AbstractRenderer.prototype.events, {
+ 'click .o_calendar_sidebar_toggler': '_onToggleSidebar',
+ }),
+
+ /**
+ * @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.filters = [];
+ this.color_map = {};
+
+ if (params.eventTemplate) {
+ this.qweb = new QWeb(session.debug, {_s: session.origin});
+ params.eventTemplate.children = [{
+ 'tag': "t",
+ 'attrs': {'t-name': "calendar-box"},
+ 'children': params.eventTemplate.children
+ }];
+ this.qweb.add_template(utils.json_node_to_xml(params.eventTemplate));
+ }
+ },
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ this._initSidebar();
+ this._initCalendar();
+ return this._super();
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.$calendar) {
+ this.$calendar.fullCalendar('destroy');
+ }
+ if (this.$small_calendar) {
+ this.$small_calendar.datepicker('destroy');
+ }
+ 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];
+ var value = this._format(record, 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 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;
+ }
+ var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1;
+ this.color_map[key] = index;
+ return index;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {any} event
+ * @returns {string} the html for the rendered event
+ */
+ _eventRender: function (event) {
+ var qweb_context = {
+ event: event,
+ record: event.record,
+ widget: this,
+ read_only_mode: this.read_only_mode,
+ user_context: session.user_context,
+ format: this._format.bind(this),
+ fields: this.state.fields
+ };
+ this.qweb_context = qweb_context;
+ if (_.isEmpty(qweb_context.record)) {
+ return '';
+ } else {
+ return (this.qweb || qweb).render("calendar-box", qweb_context);
+ }
+ },
+ /**
+ * @param {any} record
+ * @param {any} fieldName
+ * @returns {string}
+ */
+ _format: function (record, fieldName) {
+ var field = this.state.fields[fieldName];
+ return field_utils['format_'+field.type](record[fieldName], field);
+ },
+ /**
+ * Initialize the main calendar
+ */
+ _initCalendar: function () {
+ var self = this;
+
+ this.$calendar = this.$(".o_calendar_widget");
+
+ //Documentation here : http://arshaw.com/fullcalendar/docs/
+ var fc_options = $.extend({}, this.state.fc_options, {
+ eventDrop: function (event) {
+ self.trigger_up('dropRecord', event);
+ },
+ eventResize: function (event) {
+ self.trigger_up('updateRecord', event);
+ },
+ eventClick: function (event) {
+ self.trigger_up('openEvent', event);
+ self.$calendar.fullCalendar('unselect');
+ },
+ select: function (target_date, end_date, event, _js_event, _view) {
+ self.trigger_up('openCreate', {'start': target_date, 'end': end_date});
+ self.$calendar.fullCalendar('unselect');
+ },
+ eventRender: function (event, element) {
+ var $render = $(self._eventRender(event));
+ event.title = $render.find('.o_field_type_char:first').text();
+ element.find('.fc-content').html($render.html());
+ element.addClass($render.attr('class'));
+ var display_hour = (event.start.format('HH:mm') === '00:00' ? event.r_start.format('HH:mm') : event.start.format('HH:mm')) + ' - ' +
+ (event.end && event.end.format('HH:mm') !== '00:00' ? event.end.format('HH:mm') : event.r_end.format('HH:mm'));
+ if (display_hour === '00:00 - 00:00') {
+ display_hour = _t('All the day');
+ }
+ element.find('.fc-content .fc-time').text(display_hour);
+ },
+
+ unselectAuto: false,
+ });
+
+ this.$calendar.fullCalendar(fc_options);
+ },
+ /**
+ * Initialize the mini calendar in the sidebar
+ */
+ _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: new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay).toString()
+ });
+ },
+ 'dayNamesMin' : this.state.fc_options.dayNamesShort,
+ 'monthNames': this.state.fc_options.monthNamesShort,
+ 'firstDay': this.state.fc_options.firstDay,
+ });
+ },
+ /**
+ * Initialize the sidebar
+ */
+ _initSidebar: function () {
+ this.$sidebar = this.$('.o_calendar_sidebar');
+ this.$sidebar_container = this.$(".o_calendar_sidebar_container");
+ this._initCalendarMini();
+ },
+ /**
+ * Render the calendar view, this is the main entry point.
+ *
+ * @override method from AbstractRenderer
+ * @returns {Deferred}
+ */
+ _render: function () {
+ var $calendar = this.$calendar;
+ var $fc_view = $calendar.find('.fc-view');
+ var scrollPosition = $fc_view.scrollLeft();
+
+ $fc_view.scrollLeft(0);
+ $calendar.fullCalendar('unselect');
+
+ if (scales[this.state.scale] !== $calendar.data('fullCalendar').getView().type) {
+ $calendar.fullCalendar('changeView', scales[this.state.scale]);
+ }
+
+ if (this.target_date !== this.state.target_date.toString()) {
+ $calendar.fullCalendar('gotoDate', moment(this.state.target_date));
+ this.target_date = this.state.target_date.toString();
+ }
+
+ var highlightDate = moment(this.state.highlight_date).format('YYYY-MM-DD');
+ $calendar.find('.o_target_date').removeClass('o_target_date');
+ $calendar.find('.fc-bg .fc-day[data-date="'+highlightDate+'"]')
+ .addClass('o_target_date');
+
+ this.$small_calendar.datepicker("setDate", this.state.highlight_date)
+ .find('.o_selected_range')
+ .removeClass('o_color o_selected_range');
+ var $a;
+ switch (this.state.scale) {
+ case 'month': $a = this.$small_calendar.find('td a'); break;
+ case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active) a'); 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');
+ });
+
+ $fc_view.scrollLeft(scrollPosition);
+
+ var fullWidth = this.state.fullWidth;
+ this.$('.o_calendar_sidebar_toggler')
+ .toggleClass('fa-close', !fullWidth)
+ .toggleClass('fa-chevron-left', fullWidth)
+ .attr('title', !fullWidth ? _('Close Sidebar') : _('Open Sidebar'));
+ this.$sidebar_container.toggleClass('o_sidebar_hidden', fullWidth);
+ this.$sidebar.toggleClass('o_hidden', fullWidth);
+
+ this._renderFilters();
+ this.$calendar.appendTo('body');
+ this.$calendar.fullCalendar('render');
+ this._renderEvents();
+ this.$calendar.prependTo(this.$('.o_calendar_view'));
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Render all events
+ */
+ _renderEvents: function () {
+ this.$calendar.fullCalendar('removeEvents');
+ this.$calendar.fullCalendar('addEventSource', this.state.data);
+ },
+ /**
+ * Render all filters
+ */
+ _renderFilters: function () {
+ var self = this;
+ _.each(this.filters || (this.filters = []), function (filter) {
+ filter.destroy();
+ });
+ if (this.state.fullWidth) {
+ return;
+ }
+ _.each(this.state.filters, function (options) {
+ if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) {
+ return;
+ }
+ options.getColor = self.getColor.bind(self);
+ options.fields = self.state.fields;
+ var filter = new SidebarFilter(self, options);
+ filter.appendTo(self.$sidebar);
+ self.filters.push(filter);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggle the sidebar
+ */
+ _onToggleSidebar: function () {
+ this.trigger_up('toggleFullWidth');
+ },
+});
+
+});
+
+ // dead code ?
+ //
+ // calendarMiniChanged: function (datum, obj) {
+ // this.$calendar.fullCalendar('gotoDate', +obj.currentYear , +obj.currentMonth, +obj.currentDay);
+ // },
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 00000000000..288e908797c
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_view.js
@@ -0,0 +1,129 @@
+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 utils = require('web.utils');
+
+var _lt = core._lt;
+
+// gather the fields to get
+var fieldsToGather = [
+ "date_start",
+ "date_delay",
+ "date_stop",
+ "all_day",
+];
+
+var CalendarView = AbstractView.extend({
+ display_name: _lt('Calendar'),
+ icon: 'fa-calendar',
+ config: {
+ Model: CalendarModel,
+ Controller: CalendarController,
+ Renderer: CalendarRenderer,
+ },
+ init: function (arch, fields, params) {
+ this._super.apply(this, arguments);
+ var attrs = arch.attrs;
+
+ if (!attrs.date_start) {
+ throw new Error(_t("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);
+ }
+ });
+
+ if (attrs.color) {
+ fieldNames.push(attrs.color.split('.')[0]);
+ }
+
+ var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit);
+
+ var filters = {};
+
+ _.each(arch.children, function (child) {
+ switch (child.tag) {
+ case "filter":
+ if (params.sidebar === false) break; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers"
+ var fieldName = child.attrs.name;
+ filters[fieldName] = {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ if (child.attrs.avatar_field) {
+ filters[fieldName].avatar_field = child.attrs.avatar_field;
+ filters[fieldName].avatar_model = fields[fieldName].relation;
+ }
+ if (child.attrs.write_model) {
+ filters[fieldName].write_model = child.attrs.write_model;
+ filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields
+ }
+ fieldNames.push(child.attrs.name);
+ break;
+ case "field":
+ fieldNames.push(child.attrs.name);
+ if (!child.attrs.invisible) {
+ displayFields[child.attrs.name] = child.attrs;
+ }
+ }
+ });
+
+ if (_.isEmpty(displayFields)) {
+ displayFields = fields.display_name ? {'display_name': {}} : [];
+ }
+
+ //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.quick_add_pop = (attrs.quick_add || utils.toBoolElse(attrs.quick_add+'', true));
+ this.controllerParams.disable_quick_create = params.disable_quick_create || !this.controllerParams.quick_add_pop;
+ this.controllerParams.confirm_on_delete = true;
+ // If this field is set ot true, we don't open the event in form view, but in a popup with the view_id passed by this parameter
+ this.controllerParams.formViewId = !attrs.form_view_id || !utils.toBoolElse(attrs.form_view_id, true) ? false : attrs.form_view_id;
+ this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id;
+ this.controllerParams.mapping = mapping;
+ this.controllerParams.context = params.context || {};
+
+ this.rendererParams.displayFields = displayFields;
+ this.rendererParams.eventTemplate = _.findWhere(arch.children, {'tag': 'template'});
+
+ this.loadParams.fieldNames = _.uniq(fieldNames);
+ this.loadParams.mapping = mapping;
+ this.loadParams.fields = fields;
+ this.loadParams.editable = !this.controllerParams.read_only_mode && !fields[mapping.date_start].readonly;
+ this.loadParams.eventLimit = eventLimit;
+ this.loadParams.field_color = attrs.color;
+
+ this.loadParams.filters = filters;
+ this.loadParams.mode = attrs.mode;
+ this.loadParams.scale_zoom = attrs.scale_zoom;
+ this.loadParams.initialDate = params.initialDate || new Date();
+
+ // is_action_enabled: function (action) {
+ // if (action === 'create' && !this.options.creatable) {
+ // return false;
+ // }
+ // return this._super(action);
+ // },
+
+
+ },
+});
+
+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 00000000000..f1a238e7316
--- /dev/null
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -0,0 +1,164 @@
+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');
+
+return {
+ custom_events: {
+ field_changed: '_onFieldChanged',
+ name_create: '_onNameCreate',
+ name_get: '_onNameGet',
+ name_search: '_onNameSearch',
+ load: '_onLoad',
+ },
+ /**
+ * 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);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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
+ */
+ _confirmChange: function (id, fields, event) {
+ // to be implemented
+ },
+ /**
+ * 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
+ */
+ _confirmSave: function (id) {
+ // to be implemented, if necessary
+ },
+
+ //--------------------------------------------------------------------------
+ // 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) {
+ var self = this;
+ // 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();
+ var dataPointID = event.data.dataPointID;
+ this.model.notifyChanges(dataPointID, event.data.changes).then(function (result) { // FIXME if datamodel is not a BasicModel, notifyChanges does not exists
+ if (event.data.force_save) {
+ self.model.save(dataPointID).then(function () {
+ self._confirmSave(dataPointID);
+ });
+ } else {
+ self._confirmChange(dataPointID, result, event);
+ }
+ });
+ },
+ /**
+ * 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;
+ 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.model.reload(data.id, params).then(function (db_id) {
+ data.on_success(self.model.get(db_id));
+ });
+ },
+ /**
+ * When a name_create event arrives, the nameCreate method from the model
+ * should be called.
+ *
+ * @param {OdooEvent} event
+ */
+ _onNameCreate: function (event) {
+ var data = event.data;
+ if (!data.on_success) { return; }
+ this.model
+ .nameCreate(data.model, data.name)
+ .then(data.on_success)
+ .fail(function () {
+ if (data.on_fail) {
+ data.on_fail();
+ }
+ });
+ },
+ /**
+ * When a name_get event arrives, the name_get method from the model should
+ * be called.
+ *
+ * @param {OdooEvent} event
+ */
+ _onNameGet: function (event) {
+ var data = event.data;
+ if (!data.on_success) { return; }
+ this.model.name_get(data.model, data.ids)
+ .then(data.on_success); // fixme: handle context
+ },
+ /**
+ * Some field widgets need to perform a namesearch. For example, a many2one
+ * widget when it needs to fetch its autocompletion data.
+ *
+ * @todo Note: I think that this is wrong, the field widget should just perform
+ * a name_search rpc on its own, no need for this.
+ *
+ * @deprecated Just do a regular name_search
+ * @param {OdooEvent} event
+ */
+ _onNameSearch: function (event) {
+ var data = event.data;
+ if (!data.on_success) { return; }
+ this.model
+ .nameSearch(data.model, data.search_val, data.domain, data.operator, data.limit)
+ .then(data.on_success);
+ },
+};
+
+});
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 00000000000..d1badda7993
--- /dev/null
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -0,0 +1,642 @@
+odoo.define('web.FormController', function (require) {
+"use strict";
+
+var BasicController = require('web.BasicController');
+var dialogs = require('web.view_dialogs');
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var Sidebar = require('web.Sidebar');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var FormController = BasicController.extend({
+ // className: "o_form_view",
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ open_record: '_onOpenRecord',
+ toggle_column_order: '_onToggleColumnOrder',
+ form_button_clicked: '_onFormButtonClicked',
+ bounce_edit: '_onBounceEdit',
+ add_one2many_record: '_onAddOne2ManyRecord',
+ }),
+ /**
+ * @override
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+
+ this.actionButtons = params.actionButtons;
+ this.footerToButtons = params.footerToButtons;
+ this.defaultButtons = params.defaultButtons;
+ this.hasSidebar = params.hasSidebar;
+ this.toolbar = params.toolbar;
+ this.mode = params.mode;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is supposed to focus the first active control, I think. It
+ * is currently only called by the FormViewDialog.
+ *
+ * @todo To be implemented
+ */
+ autofocus: function () {
+ },
+ /**
+ * This method switches the form view in edit mode, with a new record.
+ *
+ * @returns {Deferred}
+ */
+ createRecord: function () {
+ var self = this;
+ var record = this.model.get(this.handle, {raw: true});
+ return this.model.makeDefaultRecord(this.modelName, {
+ context: this.context,
+ fieldNames: record.fieldNames,
+ fields: record.fields,
+ res_ids: record.res_ids,
+ }).then(function (handle) {
+ self.handle = handle;
+ self._updateEnv();
+ self._toEditMode();
+ });
+ },
+ /**
+ * Returns the current res_id, wrapped in a list. This is only used by the
+ * sidebar (and the debugmanager)
+ *
+ * @todo This should be private. Need to change sidebar code
+ *
+ * @returns {number[]} either [current res_id] or []
+ */
+ getSelectedIds: function () {
+ var env = this.model.get(this.handle, {env: true});
+ // FIX ME : fix sidebar widget
+ return env.currentId ? [env.currentId] : [];
+ },
+ /**
+ * @override method from AbstractController
+ * @returns {string}
+ */
+ getTitle: function () {
+ var dataPoint = this.model.get(this.handle, {raw: true});
+ return dataPoint.data.display_name || _t('New');
+ },
+ /**
+ * 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 {jQueryElement} $node
+ */
+ renderButtons: function ($node) {
+ var $footer = this.$('footer');
+ if (!this.defaultButtons && (!this.footerToButtons || !$footer.length)) {
+ return;
+ }
+ this.$buttons = $('<div/>');
+ if (this.footerToButtons && $footer.length) {
+ this.$buttons.append($footer.detach().contents());
+ } else {
+ this.$buttons.append(qweb.render("FormView.buttons", {widget: this}));
+ this.$buttons.find('.o_form_button_edit')
+ .click(this._toEditMode.bind(this));
+ this.$buttons.find('.o_form_button_save')
+ .click(this.saveRecord.bind(this));
+ this.$buttons.find('.o_form_button_cancel')
+ .click(this._onDiscardChange.bind(this));
+ this.$buttons.find('.o_form_button_create')
+ .click(this.createRecord.bind(this));
+
+ this._updateButtons();
+ }
+ 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
+ */
+ renderPager: function ($node, options) {
+ options = _.extend({}, options, {
+ validate: this._canBeDiscarded.bind(this),
+ });
+ this._super($node, options);
+ },
+ /**
+ * Instantiate and render the sidebar if a sidebar is requested
+ * Sets this.sidebar
+ * @param {jQuery} [$node] a jQuery node where the sidebar should be
+ * inserted
+ **/
+ renderSidebar: function ($node) {
+ if (!this.sidebar && this.hasSidebar) {
+ this.sidebar = new Sidebar(this, {
+ editable: this.is_action_enabled('edit')
+ });
+ if (this.toolbar) {
+ this.sidebar.add_toolbar(this.toolbar);
+ }
+ var otherItems = [];
+ if (this.is_action_enabled('delete')) {
+ otherItems.push({
+ label: _t('Delete'),
+ callback: this._deleteRecords.bind(this, [this.handle]),
+ });
+ }
+ if (this.is_action_enabled('create')) {
+ otherItems.push({
+ label: _t('Duplicate'),
+ callback: this._onDuplicateRecord.bind(this),
+ });
+ }
+ this.sidebar.add_items('other', otherItems);
+ this.sidebar.appendTo($node);
+
+ // Show or hide the sidebar according to the view mode
+ this._updateSidebar();
+ }
+ },
+ /**
+ * Save the current record
+ *
+ * @param {Object} options
+ * @param {boolean} [options.stayInEdit=false] if true, don't switch to
+ * readonly mode after saving the record
+ * @param {boolean} [options.reload=true] if true, reload the record after
+ * saving
+ * @returns {Deferred}
+ */
+ saveRecord: function (options) {
+ var self = this;
+ options = options || {};
+ var stayInEdit = 'stayInEdit' in options ? options.stayInEdit : false;
+ var shouldReload = 'reload' in options ? options.reload : true;
+ var db_id = this.handle;
+ if (!this.model.isDirty(db_id) && !this.model.isNew(db_id)) {
+ if (!stayInEdit) {
+ this._toReadOnlyMode();
+ }
+ return $.Deferred().resolve();
+ } else {
+ var invalid_fields = this.renderer.checkInvalidFields();
+ if (invalid_fields.length) {
+ this._notifyInvalidFields(invalid_fields);
+ return $.Deferred().reject();
+ } else {
+ return this.model.save(db_id, {reload: shouldReload})
+ .then(function (element) {
+ if (!stayInEdit) {
+ self._toReadOnlyMode();
+ }
+ self.trigger_up('saved', _.clone(element.data));
+ });
+ }
+ }
+ },
+ /**
+ * We need to check for all 'mode' change, because the information is not
+ * available from the model.
+ *
+ * @override
+ * @param {Object} params
+ * @returns {Deferred}
+ */
+ update: function (params) {
+ this.mode = params.mode || this.mode;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * When the user clicks on a form button, this function determines what
+ * should happen.
+ *
+ * @param {Object} attrs the attrs of the button clicked
+ * @param {Object} [record] the current state of the view
+ * @returns {Deferred}
+ */
+ _callButtonAction: function (attrs, record) {
+ var self = this;
+ var def = $.Deferred();
+ var db_id = this.handle;
+ record = record || this.model.get(db_id);
+ var recordID = record.data.id;
+ this.trigger_up('execute_action', {
+ action_data: _.extend({}, attrs, {
+ context: data.build_context(record, attrs.context)
+ }),
+ model: record.model,
+ record_id: recordID,
+ on_close: function (reason) {
+ if (!_.isObject(reason)) {
+ return self.load_record(recordID)
+ .then(self.update_state.bind(self));
+ }
+ },
+ on_fail: function () {
+ self.reload();
+ },
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ /**
+ * Determines if we can discard the current change. If the model is not
+ * dirty, that is not a problem. However, if it is dirty, we have to ask
+ * the user for confirmation.
+ *
+ * @returns {Deferred} If the deferred is resolved, we assume the changes
+ * can be discarded. If it has been rejected, then we cannot discard.
+ */
+ _canBeDiscarded: function () {
+ if (!this.model.isDirty(this.handle)) {
+ return $.when();
+ }
+ var message = _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?");
+ var def = $.Deferred();
+ var options = {
+ title: _t("Warning"),
+ confirm_callback: function () {
+ this.on('closed', null, def.resolve.bind(def));
+ },
+ cancel_callback: def.reject.bind(def)
+ };
+ var dialog = Dialog.confirm(this, message, options);
+ dialog.$modal.on('hidden.bs.modal', def.reject.bind(def));
+ return def;
+ },
+ /**
+ * @override method from FieldManagerMixin
+ * @param {string} id it is ignored, because we already know it.
+ * @param {string[]} fields
+ * @param {OdooEvent} event the event that triggered the change
+ */
+ _confirmChange: function (id, fields, event) {
+ var record = this.model.get(this.handle);
+ this.renderer.updateWidgets(fields, record, event);
+ },
+ /**
+ * When a save operation has been confirmed from the model, this method is
+ * called.
+ *
+ * @override method from field manager mixin
+ * @param {string} id
+ */
+ _confirmSave: function (id) {
+ if (id === this.handle) {
+ this.reload();
+ } else {
+ // a subrecord 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);
+ var fieldsChanged = _.findKey(record.data, function (d) {
+ return _.isObject(d) &&
+ (d.id === id || _.findWhere(d.data, {id: id}));
+ });
+ this.renderer.updateWidgets([fieldsChanged], record);
+ }
+ },
+ /**
+ * Helper function to display a warning that some field have an invalid
+ * value. This is used when a save operation cannot be completed.
+ *
+ * @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 (field_name) {
+ var fieldStr = fields[field_name].string;
+ return _.str.sprintf('<li>%s</li>', _.escape(fieldStr));
+ });
+ warnings.unshift('<ul>');
+ warnings.push('</ul>');
+ this.do_warn(_t("The following fields are invalid:"), warnings.join(''));
+ },
+ /**
+ * We just add the current ID to the state pushed. This allows the web
+ * client to add it in the url, for example.
+ *
+ * @override method from AbstractController
+ * @param {Object} [state]
+ */
+ _pushState: function (state) {
+ state = state || {};
+ var env = this.model.get(this.handle, {env: true});
+ state.id = env.currentId;
+ this._super(state);
+ },
+ /**
+ * Change the view mode to 'edit'
+ *
+ * @see _toReadOnlyMode
+ */
+ _toEditMode: function () {
+ this.$el.addClass('o_form_editable');
+ this.update({mode: "edit"}, {reload: false});
+ },
+ /**
+ * Change the view mode to 'readonly'
+ *
+ * @see _toEditMode
+ */
+ _toReadOnlyMode: function () {
+ this.$el.removeClass('o_form_editable');
+ this.update({mode: "readonly"}, {reload: false});
+ },
+ /**
+ * Updates the controller's title according to the new state
+ *
+ * @override
+ * @param {Object} state
+ * @returns {Deferred}
+ */
+ _update: function (state) {
+ var title = this.getTitle();
+ this.set('title', title);
+ this._updateButtons();
+ this._updateSidebar();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @todo move this in _update
+ */
+ _updateButtons: function () {
+ if (this.$buttons) {
+ 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);
+ }
+ },
+ /**
+ * Show or hide the sidebar according to the actual_mode
+ * @todo move this in _update
+ */
+ _updateSidebar: function () {
+ if (this.sidebar) {
+ this.sidebar.do_toggle(this.mode === 'readonly');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onAddOne2ManyRecord: function (event) {
+ var field = event.data.field;
+ new dialogs.FormViewDialog(this, {
+ res_model: field.relation,
+ domain: event.data.domain,
+ context: event.data.context,
+ on_save: event.data.on_save,
+ title: _t('Create: ') + field.string,
+ initial_view: 'form',
+ fields_view: field.views ? field.views.form : undefined,
+ form_view_options: {'not_interactible_on_create': true},
+ model: this.model,
+ }).open();
+ },
+ /**
+ * Bounce the 'Edit' button.
+ */
+ _onBounceEdit: function () {
+ if (this.$buttons) {
+ this.$buttons.find('.o_form_button_edit').openerpBounce();
+ }
+ },
+ /**
+ * If the user clicks on 'Discard', we have to check if the changes can be
+ * discarded, then actually do it.
+ */
+ _onDiscardChange: function () {
+ var self = this;
+ this._canBeDiscarded().then(function () {
+ self.model.discardChanges(self.handle);
+ if (!self.model.isNew(self.handle)) {
+ self._toReadOnlyMode();
+ } else {
+ self.do_action('history_back');
+ }
+ });
+ },
+ /**
+ * Called when the user clicks on 'Duplicate Record' in the sidebar
+ */
+ _onDuplicateRecord: function () {
+ var self = this;
+ this.model.duplicateRecord(this.handle)
+ .then(function (handle) {
+ self.handle = handle;
+ self._updateEnv();
+ self._toEditMode();
+ });
+ },
+ /**
+ * This method comes from the field manager mixin. We force to save directly
+ * the changes if the form is in readonly, because in that case the changes
+ * come from widgets that are editable even in readonly (e.g. Priority).
+ *
+ * @override
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ if (this.mode === 'readonly') {
+ event.data.force_save = true;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onFormButtonClicked: function (event) {
+ // stop the event's propagation as a form controller might have other
+ // form controllers in its descendants (e.g. in a FormViewDialog)
+ event.stopPropagation();
+ var self = this;
+ var def;
+
+ var attrs = event.data.attrs;
+ if (attrs.confirm) {
+ var d = $.Deferred();
+ Dialog.confirm(this, attrs.confirm, { confirm_callback: function () {
+ self._callButtonAction(attrs, event.data.record);
+ }}).on("closed", null, function () {
+ d.resolve();
+ });
+ def = d.promise();
+ } else if (attrs.special) {
+ def = this._callButtonAction(attrs, event.data.record);
+ } else {
+ def = this.saveRecord().then(function () {
+ return self._callButtonAction(attrs, event.data.record);
+ });
+ }
+ def.then(function () {
+ self.reload();
+ });
+
+ if (event.data.show_wow) {
+ def.then(function () {
+ self.show_wow();
+ });
+ }
+ },
+ /**
+ * Open a record in a form view dialog
+ *
+ * @param {OdooEvent} event
+ */
+ _onOpenRecord: function (event) {
+ var self = this;
+ var record = this.model.get(event.data.id);
+ var model = record.model;
+ var res_id = record.res_id;
+ var fieldsViewDef;
+ if (event.data.form_view) {
+ fieldsViewDef = $.when(event.data.form_view);
+ } else {
+ var context = event.data.context;
+ var domain = event.data.domain;
+ var dataset = new data.DataSetSearch(this, model, context, domain);
+ fieldsViewDef = this.loadFieldView(dataset, false, 'form');
+ }
+ fieldsViewDef.then(function (form_fields_view) {
+ new dialogs.FormViewDialog(self, {
+ res_model: model,
+ res_id: res_id,
+ fields_view: form_fields_view,
+ context: data.build_context(record),
+ readonly: event.data.readonly,
+ title: _t("Open: ") + event.data.string,
+ on_save: event.data.on_save,
+ on_saved: event.data.on_saved,
+ }).open();
+ });
+ },
+ /**
+ * This method is called when someone tries to sort a column, most likely
+ * in a x2many list view
+ *
+ * @param {OdooEvent} event
+ */
+ _onToggleColumnOrder: function (event) {
+ this.model.setSort(event.data.id, event.data.name);
+ var field = event.data.field;
+ var state = this.model.get(this.handle);
+ this.renderer.updateWidgets([field], state);
+ },
+
+});
+
+return FormController;
+
+});
+
+ // load_record: function (id) {
+ // var index = _.indexOf(this.datasetIds, this.currentId);
+ // return this.model.load({
+ // // context: this.options.context,
+ // id: id,
+ // model: this.modelName,
+ // offset: index,
+ // res_ids: this.datasetIds,
+ // count: this.datasetIds.length,
+ // });
+ // },
+ // do_show: function (view_options) {
+ // var defs = [];
+ // defs.push(this._super.apply(this, arguments));
+ // if (this.has_been_started) {
+ // var dataset = view_options && view_options.dataset;
+ // this.dataset_ids = dataset ? dataset.res_ids : this.dataset.ids;
+ // this.current_id = dataset ? dataset.current_id : this.dataset.ids[this.dataset.index];
+ // defs.push(this.load_record(this.current_id).then(this.update_state.bind(this)));
+ // }
+ // this.has_been_started = true;
+ // return $.when.apply($, defs);
+ // },
+ // _onDeletedRecords: function (ids) {
+ // var index = this.dataset_ids.indexOf(this.current_id);
+ // this.dataset_ids.splice(index, 1);
+ // this.current_id = this.dataset_ids[index];
+ // if (!this.current_id) {
+ // this.current_id = this.dataset_ids[this.dataset_ids.length - 1];
+ // }
+ // if (!this.current_id) {
+ // this.do_action('history_back');
+ // } else {
+ // this.load_record(this.current_id).then(this.update_state.bind(this));
+ // }
+ // },
+ // _callButtonAction: function (attrs, record) {
+ // var self = this;
+ // var def = $.Deferred();
+ // record = record || this.model.get(this.db_id);
+ // var record_id = record.data.id;
+ // this.trigger_up('execute_action', {
+ // action_data: _.extend({}, attrs, { context: data.build_context(record, attrs.context) }),
+ // dataset: this.dataset,
+ // record_id: record_id,
+ // on_close: function (reason) {
+ // if (!_.isObject(reason)) {
+ // return self.load_record(record_id).then(self.update_state.bind(self));
+ // }
+ // },
+ // on_fail: function () {
+ // self.load_record(record_id).then(function (db_id) {
+ // self.update_state(db_id);
+ // def.resolve();
+ // });
+ // },
+ // on_success: def.resolve.bind(def),
+ // });
+ // return def;
+ // },
+ // show_wow: function () {
+ // var others = ['o_wow_peace', 'o_wow_heart'];
+ // var wow_class = Math.random() <= 0.9 ? 'o_wow_thumbs' : _.sample(others);
+ // var $body = $('body');
+ // $body.addClass(wow_class);
+ // setTimeout(function () {
+ // $body.removeClass(wow_class);
+ // }, 1000);
+ // },
+ // _onFieldChanged: function (event) {
+ // if (this.mode === 'readonly') {
+ // event.data.force_save = true;
+ // }
+ // this._super.apply(this, arguments);
+ // },
+ // update_renderer: function () {
+ // var state = this.model.get(this.db_id);
+ // this.renderer.update(state, {mode: this.mode});
+ // },
+ // do_action: function (event) {
+ // this.do_action(event.data.action, event.data.options || {}).then(function (result) {
+ // if (event.data.on_success) {
+ // event.data.on_success(result);
+ // }
+ // });
+ // },
+
+ // this.dataset_ids = this.options.dataset ? this.options.dataset.res_ids : this.dataset.ids;
+ // this.current_id = this.options.dataset ? this.options.dataset.current_id : this.dataset.ids[this.dataset.index];
+ // this.options.context = _.extend({}, this.options.action.context, this.options.context);
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 00000000000..91887d45b5d
--- /dev/null
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -0,0 +1,603 @@
+odoo.define('web.FormRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var field_registry = require('web.field_registry');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var FIELD_CLASSES = {
+ 'one2many': 'o_field_one2many',
+};
+
+return BasicRenderer.extend({
+ className: "o_form_view",
+ /**
+ * @override method from BasicRenderer
+ * @param {Object} params
+ * @param {string} params.mode either 'readonly' on 'edit'
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.idsForLabels = {};
+ this.widgets = [];
+ this.mode = params.mode;
+ this.field_values = this._getFieldValues(this.state);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method has two responsabilities: find every invalid fields in the
+ * current form, 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 there is a label.
+ *
+ * @returns {string[]} the list of invalid field names
+ */
+ checkInvalidFields: function () {
+ var self = this;
+ var invalid_fields = [];
+ _.each(this.widgets, function (widget) {
+ var is_valid = widget.isValid();
+ if (!is_valid) {
+ invalid_fields.push(widget.name);
+ }
+ widget.$el.toggleClass('o_form_invalid', !is_valid);
+ var idForLabel = self.idsForLabels[widget.name];
+ var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
+ $label.toggleClass('o_form_invalid', !is_valid);
+ });
+ return invalid_fields;
+ },
+ /**
+ * 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 () {
+ var state = {};
+ this.$('div.o_notebook').each(function () {
+ var $notebook = $(this);
+ var name = $notebook.data('name');
+ var index = -1;
+ $notebook.find('li').each(function (i) {
+ if ($(this).hasClass('active')) {
+ index = i;
+ }
+ });
+ state[name] = index;
+ });
+ return state;
+ },
+ /**
+ * restore active tab pages for each notebook
+ *
+ * @todo make sure this method is called
+ *
+ * @param {Object} state the result from a getLocalState call
+ */
+ setLocalState: function (state) {
+ this.$('div.o_notebook').each(function () {
+ var $notebook = $(this);
+ var name = $notebook.data('name');
+ if (name in state) {
+ $notebook.find('> ul > li > a[data-toggle="tab"]')
+ .eq(state[name])
+ .click();
+ }
+ });
+ },
+ /**
+ * @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 {Deferred}
+ */
+ updateState: function (state, params) {
+ 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
+ // updateWidgets method
+ if (params.fieldNames) {
+ // only update the given fields
+ return this.updateWidgets(params.fieldNames, state);
+ }
+ this.field_values = this._getFieldValues(state);
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Update some field widgets with a new state. We could rerender the form
+ * 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 {string[]} fields list of fields to be updated
+ * @param {Object} state new state
+ * @param {OdooEvent} [event] the event that triggered the change
+ * @returns {Deferred}
+ */
+ updateWidgets: function (fields, state, event) {
+ this.state = state;
+ this.field_values = this._getFieldValues(this.state);
+ var defs = [];
+ _.each(this.widgets, function (widget) {
+ if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) {
+ defs.push(widget.reset(state, event));
+ }
+ });
+ // re-evalute the modifiers with the new state
+ this._evaluateModifiers();
+ return $.when.apply($, defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds the adequate classnames to a field widget's $el.
+ *
+ * @param {Object} widget a field widget
+ * @param {Object} field description of the field
+ */
+ _addFieldClassNames: function (widget, field) {
+ widget.$el.addClass('o_form_field');
+ // classname 'o_form_field_empty' hides the widget, so we apply it in
+ // on existing records for fields with no value, in readonly mode or if the
+ // field is readonly
+ if (this.state.data.id && !widget.isSet() && (this.mode === 'readonly' || field.readonly)) {
+ widget.$el.addClass('o_form_field_empty');
+ }
+ },
+ _addOnClickAction: function ($el, node) {
+ var self = this;
+ $el.click(function () {
+ self.trigger_up('form_button_clicked', {
+ attrs: node.attrs,
+ show_wow: self.$el.hasClass('o_wow'), // TODO: implement this (in view)
+ });
+ });
+ },
+ _evaluateModifiers: function () {
+ var self = this;
+ _.each(this.nodeModifiers, function (d) {
+ if ('invisible' in d.modifiers) {
+ var is_invisible = new Domain(d.modifiers.invisible).compute(self.field_values);
+ d.$el.toggleClass('o_form_invisible', is_invisible);
+ if (d.onEvalCallback) {
+ d.onEvalCallback({invisible: is_invisible});
+ }
+ }
+ });
+ },
+ _getIDForLabel: function (name) {
+ var idForLabel = this.idsForLabels[name];
+ if (!idForLabel) {
+ idForLabel = _.uniqueId('o_field_input_');
+ this.idsForLabels[name] = idForLabel;
+ }
+ return idForLabel;
+ },
+ _handleAttributes: function ($el, node, onEvalCallback) {
+ // register the modifiers with their corresponding $el so that they can
+ // be re-evaluated at each field changed
+ this.nodeModifiers.push({
+ $el: $el,
+ modifiers: JSON.parse(node.attrs.modifiers || "{}"),
+ onEvalCallback: onEvalCallback,
+ });
+ if (node.attrs.class) {
+ $el.addClass(node.attrs.class);
+ }
+ },
+ _renderButtonBox: function (node) {
+ var $result = $('<' + node.tag + '>', { 'class': 'o_not_full' });
+ // Avoid to show buttons if we are in create mode (edit mode without res_id)
+ if (this.mode === 'edit' && !this.state.res_id) {
+ return $result;
+ }
+ var buttons = _.map(node.children, this._renderStatButton.bind(this));
+ var buttons_partition = _.partition(buttons, function ($button) {
+ return $button.is('.o_form_invisible');
+ });
+ var invisible_buttons = buttons_partition[0];
+ var visible_buttons = buttons_partition[1];
+
+ // Get the unfolded buttons according to window size
+ var nb_buttons = [2, 4, 6, 7][config.device.size_class];
+ 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 (LESS 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($("<button>", {
+ type: 'button',
+ 'class': "btn btn-sm oe_stat_button o_button_more dropdown-toggle",
+ 'data-toggle': "dropdown",
+ text: _t("More"),
+ }));
+
+ var $ul = $("<ul>", {'class': "dropdown-menu o_dropdown_more", role: "menu"});
+ _.each(folded_buttons, function ($button) {
+ $('<li>').appendTo($ul).append($button);
+ });
+ $ul.appendTo($result);
+ }
+
+ this._handleAttributes($result, node);
+ return $result;
+ },
+ _renderFieldWidget: function (node) {
+ var name = node.attrs.name;
+ var field = this.state.fields[name];
+ var widget_keys = ['form.' + field.type, field.type, 'abstract'];
+ if (node.attrs.widget) {
+ widget_keys = ['form.' + node.attrs.widget, node.attrs.widget].concat(widget_keys);
+ }
+ var Widget = field_registry.getAny(widget_keys);
+
+ if (node.attrs.widget && !field_registry.contains(node.attrs.widget)) {
+ console.warn('missing widget: ' + node.attrs.widget + ' for field', name, 'of type', field.type);
+ }
+
+ var modifiers = JSON.parse(node.attrs.modifiers || "{}");
+ var is_readonly = new Domain(modifiers.readonly).compute(this.field_values);
+ var is_required = field.required || new Domain(modifiers.required).compute(this.field_values);
+ var options = {
+ mode: is_readonly ? 'readonly' : this.mode,
+ required: is_required,
+ idForLabel: this._getIDForLabel(name),
+ widgets_registry: field_registry,
+ };
+ var widget = new Widget(this, name, this.state, options);
+ this.widgets.push(widget);
+ var def = widget.__widgetRenderAndInsert(function () {});
+ if (def.state() === 'pending') {
+ this.defs.push(def);
+ }
+ widget.$el.addClass(FIELD_CLASSES[field.type]);
+ this._handleAttributes(widget.$el, node);
+ this._addFieldClassNames(widget, field);
+
+ return widget;
+ },
+ _renderGenericTag: function (node) {
+ var $result = $('<' + node.tag + '>');
+ _.each(node.attrs, function (attr, name) {
+ if (name !== 'class' && name !== 'modifiers') {
+ $result.attr(name, attr);
+ }
+ });
+ this._handleAttributes($result, node);
+ $result.append(_.map(node.children, this._renderNode.bind(this)));
+ return $result;
+ },
+ _renderHeaderButton: function (node) {
+ var $button = $('<button>')
+ .text(node.attrs.string)
+ .addClass('btn btn-sm btn-default');
+ this._addOnClickAction($button, node);
+ this._handleAttributes($button, node);
+ return $button;
+ },
+ _renderInnerField: function (node) {
+ var field = this.state.fields[node.attrs.name];
+ var field_descr = node.attrs.string || field.string;
+ var hasLabel = !node.attrs.nolabel;
+ var widget = this._renderFieldWidget(node);
+ if (hasLabel) {
+ var $label = $('<label>')
+ .addClass('o_form_label')
+ .attr('for', this._getIDForLabel(node.attrs.name))
+ .text(field_descr);
+ var isReadOnly = this.mode === 'readonly' || field.readonly;
+ if (this.state.data.id && !widget.isSet() && isReadOnly) {
+ $label.addClass('o_form_label_empty');
+ }
+ this._handleAttributes($label, node);
+ return $('<tr>')
+ .append($('<td class="o_td_label">').append($label))
+ .append($('<td style="width: 100%">').append(widget.$el));
+ } else {
+ var style = {
+ width: 100/2 + '%'
+ };
+ return $('<tr>')
+ .append($('<td colspan="1">').css(style).append(widget.$el));
+ }
+ },
+ _renderInnerGroup: function (node) {
+ var $result = $('<table class="o_group o_inner_group"/>');
+ this._handleAttributes($result, node);
+ if (node.attrs.string) {
+ var $sep = $('<tr><td colspan="2" style="width:100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>');
+ $result.append($sep);
+ }
+ var children = node.children;
+ for (var i = 0; i < children.length; i++) {
+ if (children[i].tag === 'field') {
+ var fieldNodes = [children[i]];
+ if (children[i].attrs.nolabel && children[i+1] &&
+ children[i+1].tag === 'field' && children[i+1].attrs.nolabel) {
+ fieldNodes.push(children[i+1]);
+ i++;
+ }
+ $result = this._renderInnerGroupRow($result, fieldNodes);
+ } else if (children[i].tag === 'label') {
+ var label = children[i];
+ // If there is a "for" attribute, we expect to have an id concerned in the next node.
+ if (label.attrs.for) {
+ var linked_node = children[i+1];
+ $result = this._renderInnerGroupLabel($result, label, linked_node);
+ i++; // Skip the rendering of the next node because we just did it.
+ } else {
+ $result = this._renderInnerGroupLabel($result, label);
+ }
+ } else {
+ var $td = $('<td colspan="2" style="width:100%;">').append(this._renderNode(children[i]));
+ $result.append($('<tr>').append($td));
+ }
+ }
+ return $result;
+ },
+ _renderInnerGroupLabel: function ($result, label, linked_node) {
+ var $first = $('<td class="o_td_label">')
+ .append(this._renderNode(label));
+ var $second = linked_node ? $('<td>').append(this._renderNode(linked_node)) : $('<td>');
+ var $tr = $('<tr>').append($first).append($second);
+ return $result.append($tr);
+ },
+ _renderInnerGroupRow: function ($result, nodes) {
+ var $tr = $('<tr>');
+ for (var i = 0; i < nodes.length; i++) {
+ $tr.append(this._renderInnerField(nodes[i]).contents());
+ }
+ return $result.append($tr);
+ },
+ /**
+ * 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 deferred in this.defs, and
+ * the _renderView method will properly wait.
+ *
+ * @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);
+ },
+ _renderStatButton: function (node) {
+ var $button = $('<button>').addClass('btn btn-sm oe_stat_button');
+ if (node.attrs.icon) {
+ $('<div>')
+ .addClass('fa fa-fw o_button_icon')
+ .addClass(node.attrs.icon)
+ .appendTo($button);
+ }
+ $button.append(_.map(node.children, this._renderNode.bind(this)));
+ this._addOnClickAction($button, node);
+ this._handleAttributes($button, node);
+ return $button;
+ },
+ _renderTabHeader: function (page, page_id) {
+ var $a = $('<a>', {
+ 'data-toggle': 'tab',
+ disable_anchor: 'true',
+ href: '#' + page_id,
+ role: 'tab',
+ text: page.attrs.string,
+ });
+ return $('<li>').append($a);
+ },
+ _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;
+ },
+ _renderTagButton: function (node) {
+ var widget = {
+ node: node,
+ string: (node.attrs.string || '').replace(/_/g, '')
+ };
+ if (node.attrs.icon) {
+ widget.fa_icon = node.attrs.icon.indexOf('fa-') === 0;
+ }
+ var $button = $(qweb.render('WidgetButton', {widget: widget}));
+ this._addOnClickAction($button, node);
+ this._handleAttributes($button, node);
+ return $button;
+ },
+ _renderTagField: function (node) {
+ return this._renderFieldWidget(node).$el;
+ },
+ _renderTagForm: function (node) {
+ var $result = $('<div/>');
+ if (node.attrs.class) {
+ $result.addClass(node.attrs.class);
+ }
+ $result.append(_.map(node.children, this._renderNode.bind(this)));
+ return $result;
+ },
+ _renderTagGroup: function (node) {
+ var self = this;
+ var is_outer_group = _.some(node.children, function (child) {
+ return child.tag === 'group';
+ });
+ if (!is_outer_group) {
+ return this._renderInnerGroup(node);
+ }
+
+ var $result = $('<div class="o_group"/>');
+ var $child;
+ _.each(node.children, function (child) {
+ if (child.tag === 'group') {
+ $child = self._renderInnerGroup(child);
+ } else {
+ $child = self._renderNode(child);
+ }
+ $child.addClass('o_group_col_6');
+ $child.appendTo($result);
+ });
+
+ return $result;
+ },
+ _renderTagHeader: function (node) {
+ var self = this;
+ var $statusbar = $('<div>').addClass('o_form_statusbar');
+ var $buttons = $('<div>').addClass('o_statusbar_buttons');
+ $statusbar.append($buttons);
+ _.each(node.children, function (child) {
+ if (child.tag === 'button') {
+ $buttons.append(self._renderHeaderButton(child));
+ }
+ if (child.tag === 'field') {
+ var widget = self._renderFieldWidget(child);
+ $statusbar.append(widget.$el);
+ }
+ });
+ return $statusbar;
+ },
+ _renderTagLabel: function (node) {
+ var text;
+ if ('string' in node.attrs) { // allow empty string
+ text = node.attrs.string;
+ } else if (node.attrs.for) {
+ text = this.state.fields[node.attrs.for].string;
+ } else {
+ return this._renderGenericTag(node);
+ }
+ var $result = $('<label>')
+ .addClass('o_form_label')
+ .attr('for', this._getIDForLabel(node.attrs.for))
+ .text(text);
+ this._handleAttributes($result, node);
+ return $result;
+ },
+ _renderTagNotebook: function (node) {
+ var self = this;
+ var $headers = $('<ul class="nav nav-tabs">');
+ var $pages = $('<div class="tab-content nav nav-tabs">');
+ _.each(node.children, function (child, index) {
+ var pageID = _.uniqueId('notebook_page_');
+ var $header = self._renderTabHeader(child, pageID);
+ var $page = self._renderTabPage(child, pageID);
+ if (index === 0) {
+ $header.addClass('active');
+ $page.addClass('active');
+ }
+ self._handleAttributes($header, child, function (attrs) {
+ // if the active tab is invisible, activate the first visible tab instead
+ if (attrs.invisible && $header.hasClass('active')) {
+ $header.removeClass('active');
+ $page.removeClass('active');
+ var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()');
+ $firstVisibleTab.addClass('active');
+ $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active');
+ }
+ });
+ $headers.append($header);
+ $pages.append($page);
+ });
+ return $('<div class="o_notebook">')
+ .data('name', node.attrs.name || '_default_')
+ .append($headers)
+ .append($pages);
+ },
+ _renderTagSeparator: function (node) {
+ return $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string);
+ },
+ _renderTagSheet: function (node) {
+ this.has_sheet = true;
+ var $result = $('<div>').addClass('o_form_sheet_bg');
+ var $sheet = $('<div>').addClass('o_form_sheet');
+ $sheet.append(_.map(node.children, this._renderNode.bind(this)));
+ $result.append($sheet);
+ return $result;
+ },
+ /**
+ * Main entry point for the rendering. From here, we call _renderNode on
+ * the root of the arch, then, when every deferred (from the field widgets)
+ * are done, it will resolves itself.
+ *
+ * @override method from BasicRenderer
+ * @returns {Deferred}
+ */
+ _renderView: function () {
+ var self = this;
+ this.defs = [];
+ this.nodeModifiers = [];
+
+ // render the form and evaluate the modifiers
+ var $form = this._renderNode(this.arch).addClass(this.className);
+ this._evaluateModifiers();
+
+ var defs = this.defs;
+ delete this.defs;
+ return $.when.apply($, defs).then(function () {
+ self.$el.html($form.contents());
+
+ // necessary to allow all sub widgets to use their dimensions in layout
+ // related activities, such as autoresize on fieldtexts
+ core.bus.trigger('DOM_updated');
+
+ self.$el.toggleClass('o_form_nosheet', !self.has_sheet);
+ self.$el.toggleClass('o_form_editable', self.mode === 'edit');
+ self.$el.toggleClass('o_form_readonly', self.mode === 'readonly');
+ // Attach the tooltips on the fields' label
+ _.each(self.widgets, function (widget) {
+ if (core.debug || widget.attrs.help || widget.field.help) {
+ var idForLabel = self.idsForLabels[widget.name];
+ var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
+ self._addFieldTooltip(widget, $label);
+ }
+ });
+
+ });
+ },
+});
+
+});
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 00000000000..ca667f6886d
--- /dev/null
+++ b/addons/web/static/src/js/views/form/form_view.js
@@ -0,0 +1,44 @@
+odoo.define('web.FormView', function (require) {
+"use strict";
+
+var BasicView = require('web.BasicView');
+var FormRenderer = require('web.FormRenderer');
+var FormController = require('web.FormController');
+var core = require('web.core');
+
+var _lt = core._lt;
+
+var FormView = BasicView.extend({
+ display_name: _lt('Form'),
+ icon: 'fa-edit',
+ multi_record: false,
+ searchable: false,
+
+ config: _.extend({}, BasicView.prototype.config, {
+ Renderer: FormRenderer,
+ Controller: FormController,
+ }),
+ init: function (arch, fields, params) {
+ this._super.apply(this, arguments);
+
+ var mode = params.mode || (params.currentId ? 'readonly' : 'edit');
+ this.loadParams.type = 'record';
+
+ this.controllerParams.hasSidebar = params.sidebar;
+ this.controllerParams.toolbar = false;
+ this.controllerParams.footerToButtons = params.footer_to_buttons;
+ if ('action' in params) {
+ this.controllerParams.footerToButtons = params.action.flags.footer_to_buttons;
+ }
+ var defaultButtons = 'default_buttons' in params ? params.default_buttons : true;
+ this.controllerParams.defaultButtons = defaultButtons;
+ this.controllerParams.mode = mode;
+
+ this.rendererParams.mode = mode;
+ this.model = params.model;
+ },
+});
+
+return FormView;
+
+});
diff --git a/addons/web/static/src/js/views/form_common.js b/addons/web/static/src/js/views/form_common.js
deleted file mode 100644
index 273da34f678..00000000000
--- a/addons/web/static/src/js/views/form_common.js
+++ /dev/null
@@ -1,1115 +0,0 @@
-odoo.define('web.form_common', function (require) {
-"use strict";
-
-var core = require('web.core');
-var data = require('web.data');
-var data_manager = require('web.data_manager');
-var Dialog = require('web.Dialog');
-var ListView = require('web.ListView');
-var pyeval = require('web.pyeval');
-var SearchView = require('web.SearchView');
-var session = require('web.session');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * Interface implemented by the form view or any other object
- * able to provide the features necessary for the fields to work.
- *
- * Properties:
- * - display_invalid_fields : if true, all fields where is_valid() return true should
- * be displayed as invalid.
- * - actual_mode : the current mode of the field manager. Can be "view", "edit" or "create".
- * Events:
- * - view_content_has_changed : when the values of the fields have changed. When
- * this event is triggered all fields should reprocess their modifiers.
- * - field_changed:<field_name> : when the value of a field change, an event is triggered
- * named "field_changed:<field_name>" with <field_name> replaced by the name of the field.
- * This event is not related to the on_change mechanism of OpenERP and is always called
- * when the value of a field is setted or changed. This event is only triggered when the
- * value of the field is syntactically valid, but it can be triggered when the value
- * is sematically invalid (ie, when a required field is false). It is possible that an event
- * about a precise field is never triggered even if that field exists in the view, in that
- * case the value of the field is assumed to be false.
- */
-var FieldManagerMixin = {
- /**
- * Must return the asked field as in fields_get.
- */
- get_field_desc: function(field_name) {},
- /**
- * Returns the current value of a field present in the view. See the get_value() method
- * method in FieldInterface for further information.
- */
- get_field_value: function(field_name) {},
- /**
- Gives new values for the fields contained in the view. The new values could not be setted
- right after the call to this method. Setting new values can trigger on_changes.
-
- @param {Object} values A dictonary with key = field name and value = new value.
- @return {$.Deferred} Is resolved after all the values are setted.
- */
- set_values: function(values) {},
- /**
- Computes an OpenERP domain.
-
- @param {Array} expression An OpenERP domain.
- @return {boolean} The computed value of the domain.
- */
- compute_domain: function(expression) {},
- /**
- Builds an evaluation context for the resolution of the fields' contexts. Please note
- the field are only supposed to use this context to evualuate their own, they should not
- extend it.
-
- @return {CompoundContext} An OpenERP context.
- */
- build_eval_context: function() {},
-};
-
-/**
- Welcome.
-
- If you read this documentation, it probably means that you were asked to use a form view widget outside of
- a form view. Before going further, you must understand that those fields were never really created for
- that usage. Don't think that this class will hold the answer to all your problems, at best it will allow
- you to hack the system with more style.
-*/
-var DefaultFieldManager = Widget.extend({
- init: function(parent, eval_context) {
- this._super(parent);
- this.field_descs = {};
- this.eval_context = eval_context || {};
- this.set({
- display_invalid_fields: false,
- actual_mode: 'create',
- });
- },
- get_field_desc: function(field_name) {
- if (this.field_descs[field_name] === undefined) {
- this.field_descs[field_name] = {
- string: field_name,
- };
- }
- return this.field_descs[field_name];
- },
- extend_field_desc: function(fields) {
- var self = this;
- _.each(fields, function(v, k) {
- _.extend(self.get_field_desc(k), v);
- });
- },
- get_field_value: function(field_name) {
- return false;
- },
- set_values: function(values) {
- // nothing
- },
- compute_domain: function(expression) {
- return data.compute_domain(expression, {});
- },
- build_eval_context: function() {
- return new data.CompoundContext(this.eval_context);
- },
-});
-
-/**
- * A mixin to apply on any FormWidget that has to completely re-render when its readonly state
- * switch.
- */
-var ReinitializeWidgetMixin = {
- /**
- * Default implementation of, you should not override it, use initialize_field() instead.
- */
- start: function() {
- this.initialize_field();
- this._super();
- },
- initialize_field: function() {
- this.on("change:effective_readonly", this, this.reinitialize);
- this.initialize_content();
- },
- reinitialize: function() {
- this.destroy_content();
- this.renderElement();
- this.initialize_content();
- },
- /**
- * Called to destroy anything that could have been created previously, called before a
- * re-initialization.
- */
- destroy_content: function() {},
- /**
- * Called to initialize the content.
- */
- initialize_content: function() {},
-};
-
-/**
- * A mixin to apply on any field that has to completely re-render when its readonly state
- * switch.
- */
-var ReinitializeFieldMixin = _.extend({}, ReinitializeWidgetMixin, {
- reinitialize: function() {
- ReinitializeWidgetMixin.reinitialize.call(this);
- if (!this.no_rerender) {
- var res = this.render_value();
- if (this.view && this.view.render_value_defs){
- this.view.render_value_defs.push(res);
- }
- }
- },
-});
-
-/**
- A mixin containing some useful methods to handle completion inputs.
-
- The widget containing this option can have these arguments in its widget options:
- - no_quick_create: if true, it will disable the quick create
-*/
-var CompletionFieldMixin = {
- init: function() {
- this.limit = 7;
- this.orderer = new utils.DropMisordered();
- this.can_create = this.node.attrs.can_create || true;
- this.can_write = this.node.attrs.can_write || true;
- },
- /**
- * Call this method to search using a string.
- */
- get_search_result: function(search_val) {
- var self = this;
-
- var dataset = new data.DataSet(this, this.field.relation, self.build_context());
- this.last_query = search_val;
- var exclusion_domain = [], ids_blacklist = this.get_search_blacklist();
- if (!_(ids_blacklist).isEmpty()) {
- exclusion_domain.push(['id', 'not in', ids_blacklist]);
- }
-
- return this.orderer.add(dataset.name_search(
- search_val, new data.CompoundDomain(self.build_domain(), exclusion_domain),
- 'ilike', this.limit + 1, self.build_context())).then(function(_data) {
- self.last_search = _data;
- // possible selections for the m2o
- var values = _.map(_data, function(x) {
- x[1] = x[1].split("\n")[0];
- return {
- label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent,
- value: x[1],
- name: x[1],
- id: x[0],
- };
- });
-
- // search more... if more results that max
- if (values.length > self.limit) {
- values = values.slice(0, self.limit);
- values.push({
- label: _t("Search More..."),
- action: function() {
- dataset.name_search(search_val, self.build_domain(), 'ilike', 160).done(function(_data) {
- self._search_create_popup("search", _data);
- });
- },
- classname: 'o_m2o_dropdown_option'
- });
- }
- // quick create
- var raw_result = _(_data.result).map(function(x) {return x[1];});
- if (search_val.length > 0 && !_.include(raw_result, search_val) &&
- ! (self.options && (self.options.no_create || self.options.no_quick_create))) {
- self.can_create && values.push({
- label: _.str.sprintf(_t('Create "<strong>%s</strong>"'),
- $('<span />').text(search_val).html()),
- action: function() {
- self._quick_create(search_val);
- },
- classname: 'o_m2o_dropdown_option'
- });
- }
- // create...
- if (!(self.options && (self.options.no_create || self.options.no_create_edit)) && self.can_create){
- values.push({
- label: _t("Create and Edit..."),
- action: function() {
- self._search_create_popup("form", undefined, self._create_context(search_val));
- },
- classname: 'o_m2o_dropdown_option'
- });
- }
- else if (values.length === 0) {
- values.push({
- label: _t("No results to show..."),
- action: function() {},
- });
- }
-
- return values;
- });
- },
- get_search_blacklist: function() {
- return [];
- },
- _quick_create: function(name) {
- var self = this;
- var slow_create = function () {
- self._search_create_popup("form", undefined, self._create_context(name));
- };
- if (self.options.quick_create === undefined || self.options.quick_create) {
- new data.DataSet(this, this.field.relation, self.build_context())
- .name_create(name).done(function(data) {
- if (!self.get('effective_readonly'))
- self.add_id(data[0]);
- }).fail(function(error, event) {
- event.preventDefault();
- slow_create();
- });
- } else
- slow_create();
- },
- // all search/create popup handling
- _search_create_popup: function(view, ids, context) {
- var self = this;
- new SelectCreateDialog(this, _.extend({}, (this.options || {}), {
- res_model: self.field.relation,
- domain: self.build_domain(),
- context: new data.CompoundContext(self.build_context(), context || {}),
- title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
- initial_ids: ids ? _.map(ids, function(x) {return x[0];}) : undefined,
- initial_view: view,
- disable_multiple_selection: true,
- on_selected: function(element_ids) {
- self.add_id(element_ids[0]);
- self.focus();
- }
- })).open();
- },
- /**
- * To implement.
- */
- add_id: function(id) {},
- _create_context: function(name) {
- var tmp = {};
- var field = (this.options || {}).create_name_field;
- if (field === undefined)
- field = "name";
- if (field !== false && name && (this.options || {}).quick_create !== false)
- tmp["default_" + field] = name;
- return tmp;
- },
-};
-
-/**
- * Must be applied over an class already possessing the PropertiesMixin.
- *
- * Apply the result of the "invisible" domain to this.$el.
- */
-var InvisibilityChangerMixin = {
- init: function(field_manager, invisible_domain) {
- var self = this;
- this._ic_field_manager = field_manager;
- this._ic_invisible_modifier = invisible_domain;
- this._ic_field_manager.on("view_content_has_changed", this, function() {
- var result = self._ic_invisible_modifier === undefined ? false :
- self._ic_field_manager.compute_domain(self._ic_invisible_modifier);
- self.set({"invisible": result});
- });
- this.set({invisible: this._ic_invisible_modifier === true, force_invisible: false});
- var check = function() {
- if (self.get("invisible") || self.get('force_invisible')) {
- self.set({"effective_invisible": true});
- } else {
- self.set({"effective_invisible": false});
- }
- };
- this.on('change:invisible', this, check);
- this.on('change:force_invisible', this, check);
- check.call(this);
- },
- start: function() {
- this.on("change:effective_invisible", this, this._check_visibility);
- this._check_visibility();
- },
- _check_visibility: function() {
- this.$el.toggleClass('o_form_invisible', this.get("effective_invisible"));
- },
-};
-
-var InvisibilityChanger = core.Class.extend(core.mixins.PropertiesMixin, InvisibilityChangerMixin, {
- init: function(parent, field_manager, invisible_domain, $el) {
- this.setParent(parent);
- core.mixins.PropertiesMixin.init.call(this);
- InvisibilityChangerMixin.init.call(this, field_manager, invisible_domain);
- this.$el = $el;
- this.start();
- },
-});
-
-// Specialization of InvisibilityChanger for the `notebook` form element to handle more
-// elegantly its special cases (i.e. activate the closest visible sibling)
-var NotebookInvisibilityChanger = InvisibilityChanger.extend({
- // Override start so that it does not call _check_visibility since it will be
- // called again when view content will be loaded (event view_content_has_changed)
- start: function() {
- this.on("change:effective_invisible", this, this._check_visibility);
- },
- _check_visibility: function() {
- this._super();
- if (this.get("effective_invisible") === true) {
- // Switch to invisible
- // Remove this element as active and set a visible sibling active (if there is one)
- if (this.$el.hasClass('active')) {
- this.$el.removeClass('active');
- var visible_siblings = this.$el.siblings(':not(.o_form_invisible)');
- if (visible_siblings.length) {
- $(visible_siblings[0]).addClass('active');
- }
- }
- } else {
- // Switch to visible
- // If there is no visible active sibling, set this element as active,
- // otherwise if that sibling hasn't autofocus and if we are in edit mode,
- // remove that sibling as active and set this element as active
- var visible_active_sibling = this.$el.siblings(':not(.o_form_invisible).active');
- if (!(visible_active_sibling.length)) {
- this.$el.addClass('active');
- } else if (!$(visible_active_sibling[0]).data('autofocus') && this._ic_field_manager.get('actual_mode') === "edit") {
- this.$el.addClass('active');
- $(visible_active_sibling[0]).removeClass('active');
- }
- }
- },
-});
-
-/**
- Base class for all fields, custom widgets and buttons to be displayed in the form view.
-
- Properties:
- - effective_readonly: when it is true, the widget is displayed as readonly. Vary depending
- the values of the "readonly" property and the "mode" property on the field manager.
-*/
-var FormWidget = Widget.extend(InvisibilityChangerMixin, {
- /**
- * @constructs instance.web.form.FormWidget
- * @extends instance.web.Widget
- *
- * @param field_manager
- * @param node
- */
- init: function(field_manager, node) {
- this._super(field_manager);
- this.field_manager = field_manager;
- this.view = this.field_manager;
- this.node = node;
- this.session = session;
- this.modifiers = JSON.parse(this.node.attrs.modifiers || '{}');
- InvisibilityChangerMixin.init.call(this, this.field_manager, this.modifiers.invisible);
-
- this.field_manager.on("view_content_has_changed", this, this.process_modifiers);
-
- this.set({
- required: false,
- readonly: false,
- });
- // some events to make the property "effective_readonly" sync automatically with "readonly" and
- // "mode" on field_manager
- var self = this;
- var test_effective_readonly = function() {
- self.set({"effective_readonly": self.get("readonly") || self.field_manager.get("actual_mode") === "view"});
- };
- this.on("change:readonly", this, test_effective_readonly);
- this.field_manager.on("change:actual_mode", this, test_effective_readonly);
- test_effective_readonly.call(this);
- },
- renderElement: function() {
- this.process_modifiers();
- this._super();
- this.$el.addClass(this.node.attrs["class"] || "");
- this.$el.attr('style', this.node.attrs.style);
- },
- destroy: function() {
- $.fn.tooltip('destroy');
- this._super.apply(this, arguments);
- },
- /**
- * Sets up blur/focus forwarding from DOM elements to a widget (`this`).
- *
- * This method is an utility method that is meant to be called by child classes.
- *
- * @param {jQuery} $e jQuery object of elements to bind focus/blur on
- */
- setupFocus: function ($e) {
- var self = this;
- $e.on({
- focus: function () { self.trigger('focused'); },
- blur: function () { self.trigger('blurred'); }
- });
- },
- process_modifiers: function() {
- var to_set = {};
- for (var a in this.modifiers) {
- if (!this.modifiers.hasOwnProperty(a)) { continue; }
- if (!_.include(["invisible"], a)) {
- var val = this.field_manager.compute_domain(this.modifiers[a]);
- to_set[a] = val;
- }
- }
- this.set(to_set);
- },
- do_attach_tooltip: function(widget, trigger, options) {
- widget = widget || this;
- trigger = trigger || this.$el;
- options = _.extend({
- delay: { show: 1000, hide: 0 },
- title: function() {
- var template = widget.template + '.tooltip';
- if (!QWeb.has_template(template)) {
- template = 'WidgetLabel.tooltip';
- }
- return QWeb.render(template, {
- debug: session.debug,
- widget: widget
- });
- }
- }, options || {});
- //only show tooltip if we are in debug or if we have a help to show, otherwise it will display
- //as empty
- if (session.debug || widget.node.attrs.help || (widget.field && widget.field.help)){
- $(trigger).tooltip(options);
- }
- },
- /**
- * Builds a new context usable for operations related to fields by merging
- * the fields'context with the action's context.
- */
- build_context: function() {
- // only use the model's context if there is not context on the node
- var v_context = this.node.attrs.context;
- if (! v_context) {
- v_context = (this.field || {}).context || {};
- }
-
- if (v_context.__ref || true) { //TODO: remove true
- var fields_values = this.field_manager.build_eval_context();
- v_context = new data.CompoundContext(v_context).set_eval_context(fields_values);
- }
- return v_context;
- },
- build_domain: function() {
- var f_domain = this.field.domain || [];
- var n_domain = this.node.attrs.domain || null;
- // if there is a domain on the node, overrides the model's domain
- var final_domain = n_domain !== null ? n_domain : f_domain;
- if (!(final_domain instanceof Array) || true) { //TODO: remove true
- var fields_values = this.field_manager.build_eval_context();
- final_domain = new data.CompoundDomain(final_domain).set_eval_context(fields_values);
- }
- return final_domain;
- }
-});
-
-/*
-# Values: (0, 0, { fields }) create
-# (1, ID, { fields }) update
-# (2, ID) remove (delete)
-# (3, ID) unlink one (target id or target of relation)
-# (4, ID) link
-# (5) unlink all (only valid for one2many)
-*/
-var commands = {
- // (0, _, {values})
- CREATE: 0,
- 'create': function (values) {
- return [commands.CREATE, false, values];
- },
- // (1, id, {values})
- UPDATE: 1,
- 'update': function (id, values) {
- return [commands.UPDATE, id, values];
- },
- // (2, id[, _])
- DELETE: 2,
- 'delete': function (id) {
- return [commands.DELETE, id, false];
- },
- // (3, id[, _]) removes relation, but not linked record itself
- FORGET: 3,
- 'forget': function (id) {
- return [commands.FORGET, id, false];
- },
- // (4, id[, _])
- LINK_TO: 4,
- 'link_to': function (id) {
- return [commands.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];
- }
-};
-
-/**
- * Interface to be implemented by fields.
- *
- * Events:
- * - changed_value: triggered when the value of the field has changed. This can be due
- * to a user interaction or a call to set_value().
- *
- */
-var FieldInterface = {
- /**
- * Constructor takes 2 arguments:
- * - field_manager: Implements FieldManagerMixin
- * - node: the "<field>" node in json form
- */
- init: function(field_manager, node) {},
- /**
- * Called by the form view to indicate the value of the field.
- *
- * Multiple calls to set_value() can occur at any time and must be handled correctly by the implementation,
- * regardless of any asynchronous operation currently running. Calls to set_value() can and will also occur
- * before the widget is inserted into the DOM.
- *
- * set_value() must be able, at any moment, to handle the syntax returned by the "read" method of the
- * osv class in the OpenERP server as well as the syntax used by the set_value() (see below). It must
- * also be able to handle any other format commonly used in the _defaults key on the models in the addons
- * as well as any format commonly returned in a on_change. It must be able to autodetect those formats as
- * no information is ever given to know which format is used.
- */
- set_value: function(value_) {},
- /**
- * Get the current value of the widget.
- *
- * Must always return a syntactically correct value to be passed to the "write" method of the osv class in
- * the OpenERP server, although it is not assumed to respect the constraints applied to the field.
- * For example if the field is marked as "required", a call to get_value() can return false.
- *
- * get_value() can also be called *before* a call to set_value() and, in that case, is supposed to
- * return a default value according to the type of field.
- *
- * This method is always assumed to perform synchronously, it can not return a promise.
- *
- * If there was no user interaction to modify the value of the field, it is always assumed that
- * get_value() return the same semantic value than the one passed in the last call to set_value(),
- * although the syntax can be different. This can be the case for type of fields that have a different
- * syntax for "read" and "write" (example: m2o: set_value([0, "Administrator"]), get_value() => 0).
- */
- get_value: function() {},
- /**
- * Inform the current object of the id it should use to match a html <label> that exists somewhere in the
- * view.
- */
- set_input_id: function(id) {},
- /**
- * Returns true if is_syntax_valid() returns true and the value is semantically
- * valid too according to the semantic restrictions applied to the field.
- */
- is_valid: function() {},
- /**
- * Returns true if the field holds a value which is syntactically correct, ignoring
- * the potential semantic restrictions applied to the field.
- */
- is_syntax_valid: function() {},
- /**
- * Must set the focus on the field. Return false if field is not focusable.
- */
- focus: function() {},
- /**
- * Called when the translate button is clicked.
- */
- on_translate: function() {},
- /**
- This method is called by the form view before reading on_change values and before saving. It tells
- the field to save its value before reading it using get_value(). Must return a promise.
- */
- commit_value: function() {},
-};
-
-/**
- * Abstract class for classes implementing FieldInterface.
- *
- * Properties:
- * - value: useful property to hold the value of the field. By default, set_value() and get_value()
- * set and retrieve the value property. Changing the value property also triggers automatically
- * a 'changed_value' event that inform the view to trigger on_changes.
- *
- */
-var AbstractField = FormWidget.extend(FieldInterface, {
- /**
- * @constructs instance.web.form.AbstractField
- * @extends instance.web.form.FormWidget
- *
- * @param field_manager
- * @param node
- */
- init: function(field_manager, node) {
- this._super(field_manager, node);
- this.name = this.node.attrs.name;
- this.field = this.field_manager.get_field_desc(this.name);
- this.widget = this.node.attrs.widget;
- this.string = this.node.attrs.string || this.field.string || this.name;
- this.options = pyeval.py_eval(this.node.attrs.options || '{}');
- this.set({'value': false});
-
- this.on("change:value", this, function() {
- this.trigger('changed_value');
- this._check_css_flags();
- });
-
- this.$translate = (_t.database.multi_lang && this.field.translate) ? $('<button/>', {
- type: 'button',
- }).addClass('o_field_translate fa fa-globe btn btn-link') : $();
- },
-
- renderElement: function() {
- var self = this;
- this._super();
- this.$el.addClass('o_form_field');
- this.$label = this.view ? this.view.$('label[for=' + this.id_for_label + ']') : $();
- this.do_attach_tooltip(this, this.$label[0] || this.$el);
- if (session.debug) {
- this.$label.off('dblclick').on('dblclick', function() {
- console.log("Field '%s' of type '%s' in View: %o", self.name, (self.node.attrs.widget || self.field.type), self.view);
- window.w = self;
- console.log("window.w =", window.w);
- });
- }
- if (!this.disable_utility_classes) {
- this.off("change:required", this, this._set_required);
- this.on("change:required", this, this._set_required);
- this._set_required();
- }
- this._check_visibility();
- this.field_manager.off("change:display_invalid_fields", this, this._check_css_flags);
- this.field_manager.on("change:display_invalid_fields", this, this._check_css_flags);
- this._check_css_flags();
- },
- start: function() {
- this._super();
- this.on("change:value", this, function() {
- if (!this.no_rerender) {
- this.render_value();
- }
- this._toggle_label();
- });
- this.on("change:effective_readonly", this, function() {
- this._toggle_label();
- });
- this.render_value();
- this._toggle_label();
-
- if (this.view) {
- this.$translate
- .insertAfter(this.$el)
- .on('click', _.bind(this.on_translate, this));
- }
- },
- _toggle_label: function() {
- var empty = this.get('effective_readonly') && this.is_false();
- this.$label.toggleClass('o_form_label_empty', empty).toggleClass('o_form_label_false', this.get('effective_readonly') && this.get('value') === false);
- this.$el.toggleClass('o_form_field_empty', empty);
- },
- /**
- * Private. Do not use.
- */
- _set_required: function() {
- this.$el.toggleClass('o_form_required', this.get("required"));
- },
- set_value: function(value_) {
- this.set({'value': value_});
- },
- get_value: function() {
- return this.get('value');
- },
- /**
- Utility method that all implementations should use to change the
- value without triggering a re-rendering.
- */
- internal_set_value: function(value_) {
- var tmp = this.no_rerender;
- this.no_rerender = true;
- this.set({'value': value_});
- this.no_rerender = tmp;
- },
- /**
- This method is called each time the value is modified.
- */
- render_value: function() {},
- is_valid: function() {
- return this.is_syntax_valid() && !(this.get('required') && this.is_false());
- },
- is_syntax_valid: function() {
- return true;
- },
- /**
- * Method useful to implement to ease validity testing. Must return true if the current
- * value is similar to false in OpenERP.
- */
- is_false: function() {
- return this.get('value') === false;
- },
- _check_css_flags: function() {
- var show_translate = (!this.get('effective_readonly') && this.field_manager.get('actual_mode') !== "create");
- this.$translate.toggleClass('o_translate_active', !!show_translate);
-
- this.$el.add(this.$label)
- .toggleClass('o_form_invalid', !this.disable_utility_classes && !!this.field_manager.get('display_invalid_fields') && !this.is_valid());
- },
- focus: function() {
- return false;
- },
- set_input_id: function(id) {
- this.id_for_label = id;
- },
- on_translate: function() {
- var self = this;
- var trans = new data.DataSet(this, 'ir.translation');
- return trans.call_button('translate_fields', [this.view.model, this.view.datarecord.id, this.name, this.view.dataset.get_context()]).done(function(r) {
- self.do_action(r);
- });
- },
- set_dimensions: function(height, width) {
- this.$el.css({
- width: width,
- height: height,
- });
- },
- commit_value: function() {
- return $.when();
- },
-});
-
-/**
- * Class with everything which is common between FormViewDialog and SelectCreateDialog.
- */
-var ViewDialog = Dialog.extend({ // FIXME should use ViewManager
- /**
- * options:
- * -readonly: only applicable when not in creation mode, default to false
- * - alternative_form_view
- * - write_function
- * - read_function
- * - create_function
- * - parent_view
- * - child_name
- * - form_view_options
- */
- init: function(parent, options) {
- options = options || {};
- options.dialogClass = options.dialogClass || '';
- options.dialogClass += ' o_act_window';
-
- this._super(parent, $.extend(true, {}, options));
-
- this.res_model = options.res_model || null;
- this.res_id = options.res_id || null;
- this.domain = options.domain || [];
- this.context = options.context || {};
- this.options = _.extend(this.options || {}, options || {});
-
- this.on_selected = options.on_selected || (function() {});
- },
-
- init_dataset: function() {
- var self = this;
- this.created_elements = [];
- this.dataset = new data.ProxyDataSet(this, this.res_model, this.context);
- this.dataset.read_function = this.options.read_function;
- this.dataset.create_function = function(data, options, sup) {
- var fct = self.options.create_function || sup;
- return fct.call(this, data, options).done(function(r) {
- self.trigger('create_completed saved', r);
- self.created_elements.push(r);
- });
- };
- this.dataset.write_function = function(id, data, options, sup) {
- var fct = self.options.write_function || sup;
- return fct.call(this, id, data, options).done(function(r) {
- self.trigger('write_completed saved', r);
- });
- };
- this.dataset.parent_view = this.options.parent_view;
- this.dataset.child_name = this.options.child_name;
-
- this.on('closed', this, this.select);
- },
-
- select: function() {
- if (this.created_elements.length > 0) {
- this.on_selected(this.created_elements);
- this.created_elements = [];
- }
- }
-});
-
-/**
- * Create and edit dialog (displays a form view record and leave once saved)
- */
-var FormViewDialog = ViewDialog.extend({
- init: function(parent, options) {
- var self = this;
-
- var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection;
- var readonly = _.isNumber(options.res_id) && options.readonly;
-
- if(!options || !options.buttons) {
- options = options || {};
- options.buttons = [
- {text: (readonly ? _t("Close") : _t("Discard")), classes: "btn-default o_form_button_cancel", close: true, click: function() {
- self.view_form.trigger('on_button_cancel');
- }}
- ];
-
- if(!readonly) {
- options.buttons.splice(0, 0, {text: _t("Save") + ((multi_select)? " " + _t(" & Close") : ""), classes: "btn-primary", click: function() {
- self.view_form.onchanges_mutex.def.then(function() {
- if (!self.view_form.warning_displayed) {
- $.when(self.view_form.save()).done(function() {
- self.view_form.reload_mutex.exec(function() {
- self.trigger('record_saved');
- self.close();
- });
- });
- }
- });
- }
- });
-
- if(multi_select) {
- options.buttons.splice(1, 0, {text: _t("Save & New"), classes: "btn-primary", click: function() {
- $.when(self.view_form.save()).done(function() {
- self.view_form.reload_mutex.exec(function() {
- self.view_form.on_button_new();
- });
- });
- }});
- }
- }
- }
-
- this._super(parent, options);
- },
-
- open: function() {
- var self = this;
- var _super = this._super.bind(this);
- this.init_dataset();
-
- if (this.res_id) {
- this.dataset.ids = [this.res_id];
- this.dataset.index = 0;
- } else {
- this.dataset.index = null;
- }
- var options = _.clone(this.options.form_view_options) || {};
- if (this.res_id !== null) {
- options.initial_mode = this.options.readonly ? "view" : "edit";
- }
- _.extend(options, {
- $buttons: this.$buttons,
- });
- var FormView = core.view_registry.get('form');
- var fields_view_def;
- if (this.options.alternative_form_view) {
- fields_view_def = $.when(this.options.alternative_form_view);
- } else {
- fields_view_def = data_manager.load_fields_view(this.dataset, this.options.view_id, 'form', false);
- }
- fields_view_def.then(function (fields_view) {
- self.view_form = new FormView(self, self.dataset, fields_view, options);
- var fragment = document.createDocumentFragment();
- self.view_form.appendTo(fragment).then(function () {
- self.view_form.do_show().then(function() {
- _super().$el.append(fragment);
- self.view_form.autofocus();
- });
- });
- });
-
- return this;
- },
-});
-
-var SelectCreateListView = ListView.extend({
- do_add_record: function () {
- this.popup.create_edit_record();
- },
- select_record: function(index) {
- if (this.popup.options.readonly) return;
- this.popup.on_selected([this.dataset.ids[index]]);
- this.popup.close();
- },
- do_select: function(ids, records) {
- this._super.apply(this, arguments);
- this.popup.on_click_element(ids);
- }
-});
-
-/**
- * Search dialog (displays a list of records and permits to create a new one by switching to a form view)
- */
-var SelectCreateDialog = ViewDialog.extend({
- /**
- * options:
- * - initial_ids
- * - initial_view: form or search (default search)
- * - disable_multiple_selection
- * - list_view_options
- */
- init: function(parent, options) {
- this._super(parent, options);
-
- _.defaults(this.options, { initial_view: "search" });
- this.initial_ids = this.options.initial_ids;
- },
-
- open: function() {
- if(this.options.initial_view !== "search") {
- return this.create_edit_record();
- }
-
- var _super = this._super.bind(this);
- this.init_dataset();
- var context = pyeval.sync_eval_domains_and_contexts({
- domains: [],
- contexts: [this.context]
- }).context;
- var search_defaults = {};
- _.each(context, function (value_, key) {
- var match = /^search_default_(.*)$/.exec(key);
- if (match) {
- search_defaults[match[1]] = value_;
- }
- });
- data_manager
- .load_views(this.dataset, [[false, 'list'], [false, 'search']], {})
- .then(this.setup.bind(this, search_defaults))
- .then(function (fragment) {
- _super().$el.append(fragment);
- });
- return this;
- },
-
- setup: function(search_defaults, fields_views) {
- var self = this;
- if (this.searchview) {
- this.searchview.destroy();
- }
- var fragment = document.createDocumentFragment();
- var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment);
- var $pager = $('<div/>').addClass('o_pager').appendTo($header);
- var options = {
- $buttons: $('<div/>').addClass('o_search_options').appendTo($header),
- search_defaults: search_defaults,
- };
-
- this.searchview = new SearchView(this, this.dataset, fields_views.search, options);
- this.searchview.on('search_data', this, function(domains, contexts, groupbys) {
- if (this.initial_ids) {
- this.do_search(domains.concat([[["id", "in", this.initial_ids]], this.domain]),
- contexts.concat(this.context), groupbys);
- this.initial_ids = undefined;
- } else {
- this.do_search(domains.concat([this.domain]), contexts.concat(this.context), groupbys);
- }
- });
- return this.searchview.prependTo($header).then(function() {
- self.searchview.toggle_visibility(true);
-
- self.view_list = new SelectCreateListView(self,
- self.dataset, fields_views.list,
- _.extend({'deletable': false,
- 'selectable': !self.options.disable_multiple_selection,
- 'import_enabled': false,
- '$buttons': self.$buttons,
- 'disable_editable_mode': true,
- 'pager': true,
- }, self.options.list_view_options || {}));
- self.view_list.on('edit:before', self, function (e) {
- e.cancel = true;
- });
- self.view_list.popup = self;
- self.view_list.on('list_view_loaded', self, function() {
- this.on_view_list_loaded();
- });
-
- var buttons = [
- {text: _t("Cancel"), classes: "btn-default o_form_button_cancel", close: true}
- ];
- if(!self.options.no_create) {
- buttons.splice(0, 0, {text: _t("Create"), classes: "btn-primary", click: function() {
- self.create_edit_record();
- }});
- }
- if(!self.options.disable_multiple_selection) {
- buttons.splice(0, 0, {text: _t("Select"), classes: "btn-primary o_selectcreatepopup_search_select", disabled: true, close: true, click: function() {
- self.on_selected(self.selected_ids);
- }});
- }
- self.set_buttons(buttons);
-
- return self.view_list.appendTo(fragment).then(function() {
- self.view_list.do_show();
- self.view_list.render_pager($pager);
- if (self.options.initial_facet) {
- self.searchview.query.reset([self.options.initial_facet], {
- preventSearch: true,
- });
- }
- self.searchview.do_search();
-
- return fragment;
- });
- });
- },
- do_search: function(domains, contexts, groupbys) {
- var results = pyeval.sync_eval_domains_and_contexts({
- domains: domains || [],
- contexts: contexts || [],
- group_by_seq: groupbys || []
- });
- this.view_list.do_search(results.domain, results.context, results.group_by);
- },
- on_click_element: function(ids) {
- this.selected_ids = ids || [];
- this.$footer.find(".o_selectcreatepopup_search_select").prop('disabled', this.selected_ids.length <= 0);
- },
- create_edit_record: function() {
- this.close();
- return new FormViewDialog(this.__parentedParent, this.options).open();
- },
- on_view_list_loaded: function() {},
-});
-
-return {
- // mixins
- FieldManagerMixin: FieldManagerMixin,
- ReinitializeWidgetMixin: ReinitializeWidgetMixin,
- ReinitializeFieldMixin: ReinitializeFieldMixin,
- CompletionFieldMixin: CompletionFieldMixin,
-
- // misc
- FormWidget: FormWidget,
- DefaultFieldManager: DefaultFieldManager,
- InvisibilityChanger: InvisibilityChanger,
- NotebookInvisibilityChanger: NotebookInvisibilityChanger,
- commands: commands,
- AbstractField: AbstractField,
-
- // Dialogs
- ViewDialog: ViewDialog,
- FormViewDialog: FormViewDialog,
- SelectCreateDialog: SelectCreateDialog,
-};
-
-});
diff --git a/addons/web/static/src/js/views/form_relational_widgets.js b/addons/web/static/src/js/views/form_relational_widgets.js
deleted file mode 100644
index 63d04aaeef3..00000000000
--- a/addons/web/static/src/js/views/form_relational_widgets.js
+++ /dev/null
@@ -1,1726 +0,0 @@
-odoo.define('web.form_relational', function (require) {
-"use strict";
-
-var ControlPanel = require('web.ControlPanel');
-var core = require('web.core');
-var data = require('web.data');
-var Dialog = require('web.Dialog');
-var common = require('web.form_common');
-var ListView = require('web.ListView');
-require('web.ListEditor'); // one must be sure that the include of ListView are done (for eg: add start_edition methods)
-var Model = require('web.DataModel');
-var session = require('web.session');
-var utils = require('web.utils');
-var ViewManager = require('web.ViewManager');
-
-var _t = core._t;
-var QWeb = core.qweb;
-var COMMANDS = common.commands;
-var list_widget_registry = core.list_widget_registry;
-
-var M2ODialog = Dialog.extend({
- template: "M2ODialog",
- init: function(parent) {
- this.name = parent.string;
- this._super(parent, {
- title: _.str.sprintf(_t("Create a %s"), parent.string),
- size: 'medium',
- buttons: [
- {text: _t('Create'), classes: 'btn-primary', click: function(e) {
- if (this.$("input").val() !== ''){
- this.getParent()._quick_create(this.$("input").val());
- this.close();
- } else {
- e.preventDefault();
- this.$("input").focus();
- }
- }},
-
- {text: _t('Create and edit'), classes: 'btn-primary', close: true, click: function() {
- this.getParent()._search_create_popup("form", undefined, this.getParent()._create_context(this.$("input").val()));
- }},
-
- {text: _t('Cancel'), close: true}
- ]
- });
- },
- start: function() {
- var text = _.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name);
- this.$("p").text(text);
- this.$("input").val(this.getParent().$input.val());
- },
-});
-
-var FieldMany2One = common.AbstractField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, {
- template: "FieldMany2One",
- events: {
- 'keydown input': function (e) {
- switch (e.which) {
- case $.ui.keyCode.UP:
- case $.ui.keyCode.DOWN:
- e.stopPropagation();
- }
- },
- },
- init: function(field_manager, node) {
- this._super(field_manager, node);
- common.CompletionFieldMixin.init.call(this);
- this.display_value = {};
- this.display_value_backup = {};
- this.last_search = [];
- this.floating = false;
- this.current_display = null;
- this.is_started = false;
- this.ignore_focusout = false;
- },
- reinit_value: function(val) {
- this.internal_set_value(val);
- this.floating = false;
- if (this.is_started && !this.no_rerender) {
- this.render_value();
- }
- },
- initialize_field: function() {
- this.is_started = true;
- core.bus.on('click', this, function() {
- if (!this.get("effective_readonly") && this.$input && this.$input.autocomplete('widget').is(':visible')) {
- this.$input.autocomplete("close");
- }
- });
- common.ReinitializeFieldMixin.initialize_field.call(this);
- },
- initialize_content: function() {
- if (!this.get("effective_readonly")) {
- this.render_editable();
- }
- },
- destroy_content: function () {
- if (this.$dropdown) {
- this.$dropdown.off('click');
- delete this.$dropdown;
- }
- if (this.$input) {
- if (this.$input.data('ui-autocomplete')) {
- this.$input.autocomplete("destroy");
- }
- this.$input.closest(".modal .modal-content").off('scroll');
- this.$input.off('keyup blur autocompleteclose autocompleteopen ' +
- 'focus focusout change keydown');
- delete this.$input;
- }
- if (this.$follow_button) {
- this.$follow_button.off('blur focus click');
- delete this.$follow_button;
- }
- },
- destroy: function () {
- this.destroy_content();
- return this._super();
- },
- init_error_displayer: function() {
- // nothing
- },
- hide_error_displayer: function() {
- // doesn't work
- },
- show_error_displayer: function() {
- new M2ODialog(this).open();
- },
- render_editable: function() {
- var self = this;
- this.$input = this.$("input");
-
- this.init_error_displayer();
- self.$input.on('focus', function() {
- self.hide_error_displayer();
- });
-
- this.$dropdown = this.$(".o_dropdown_button");
- this.$follow_button = this.$(".o_external_button");
-
- this.$follow_button.click(function(ev) {
- ev.preventDefault();
- if (!self.get('value')) {
- self.focus();
- return;
- }
- var context = self.build_context().eval();
- var model_obj = new Model(self.field.relation);
- model_obj.call('get_formview_id', [[self.get("value")], context]).then(function(view_id){
- var pop = new common.FormViewDialog(self, {
- res_model: self.field.relation,
- res_id: self.get("value"),
- context: self.build_context(),
- title: _t("Open: ") + self.string,
- view_id: view_id,
- readonly: !self.can_write
- }).open();
- pop.on('write_completed', self, function(){
- self.display_value = {};
- self.display_value_backup = {};
- self.render_value();
- self.focus();
- self.trigger('changed_value');
- });
- });
- });
-
- // some behavior for input
- var input_changed = function() {
- if (self.current_display !== self.$input.val()) {
- self.current_display = self.$input.val();
- if (self.$input.val() === "") {
- self.internal_set_value(false);
- self.floating = false;
- } else {
- self.floating = true;
- }
- }
- };
- this.$input.keydown(input_changed);
- this.$input.change(input_changed);
- this.$input.on('click', function() {
- if (self.$input.autocomplete("widget").is(":visible")) {
- self.$input.autocomplete("close");
- } else {
- if (self.get("value") && ! self.floating) {
- self.$input.autocomplete("search", "");
- } else {
- self.$input.autocomplete("search");
- }
- }
- });
-
- // Autocomplete close on dialog content scroll
- var close_autocomplete = _.debounce(function() {
- if (self.$input.autocomplete("widget").is(":visible")) {
- self.$input.autocomplete("close");
- }
- }, 50);
- this.$input.closest(".modal .modal-content").on('scroll', this, close_autocomplete);
-
- self.ed_def = $.Deferred();
- self.uned_def = $.Deferred();
- var ed_delay = 200;
- var ed_duration = 15000;
- var anyoneLoosesFocus = function (e) {
- if (self.ignore_focusout) { return; }
- var used = false;
- if (self.floating) {
- if (self.last_search.length > 0) {
- if (self.last_search[0][0] != self.get("value")) {
- self.display_value = {};
- self.display_value_backup = {};
- self.display_value["" + self.last_search[0][0]] = self.last_search[0][1];
- self.reinit_value(self.last_search[0][0]);
- self.last_search = [];
- } else {
- used = true;
- self.render_value();
- }
- } else {
- used = true;
- }
- self.floating = false;
- }
- var has_changed = (self.get("value") === false || self.display_value["" + self.get("value")] !== self.$input.val());
- if (used && has_changed && ! self.no_ed && ! (self.options && (self.options.no_create || self.options.no_quick_create))) {
- self.ed_def.reject();
- self.uned_def.reject();
- self.ed_def = $.Deferred();
- self.ed_def.done(function() {
- self.can_create && self.$input && self.show_error_displayer();
- ignore_blur = false;
- self.trigger('focused');
- });
- ignore_blur = true;
- setTimeout(function() {
- self.ed_def.resolve();
- self.uned_def.reject();
- self.uned_def = $.Deferred();
- self.uned_def.done(function() {
- self.hide_error_displayer();
- });
- setTimeout(function() {self.uned_def.resolve();}, ed_duration);
- }, ed_delay);
- } else {
- self.no_ed = false;
- self.ed_def.reject();
- }
- };
- var ignore_blur = false;
- this.$input.on({
- focusout: anyoneLoosesFocus,
- focus: function () { self.trigger('focused'); },
- autocompleteopen: function () { ignore_blur = true; },
- autocompleteclose: function () { setTimeout(function() {ignore_blur = false;},0); },
- blur: function () {
- // autocomplete open
- if (ignore_blur) { $(this).focus(); return; }
- if (_(self.getChildren()).any(function (child) {
- return child instanceof common.ViewDialog;
- })) { return; }
- self.trigger('blurred');
- }
- });
-
- var isSelecting = false;
- // autocomplete
- this.$input.autocomplete({
- source: function(req, resp) {
- self.get_search_result(req.term).done(function(result) {
- resp(result);
- });
- },
- select: function(event, ui) {
- isSelecting = true;
- var item = ui.item;
- if (item.id) {
- self.display_value = {};
- self.display_value_backup = {};
- self.display_value["" + item.id] = item.name;
- self.reinit_value(item.id);
- } else if (item.action) {
- item.action();
- // Cancel widget blurring, to avoid form blur event
- self.trigger('focused');
- }
- return false;
- },
- focus: function(e, ui) {
- e.preventDefault();
- },
- autoFocus: true,
- html: true,
- // disabled to solve a bug, but may cause others
- //close: anyoneLoosesFocus,
- minLength: 0,
- delay: 200,
- });
- // set position for list of suggestions box
- this.$input.autocomplete( "option", "position", { my : "left top", at: "left bottom" } );
- // used to correct a bug when selecting an element by pushing 'enter' in an editable list
- this.$input.keyup(function(e) {
- if (e.which === 13) { // ENTER
- if (isSelecting)
- e.stopPropagation();
- }
- isSelecting = false;
- });
- this.setupFocus(this.$follow_button);
- },
- render_value: function(no_recurse) {
- var self = this;
- if (!this.get("value")) {
- this.display_string(null);
- return;
- }
- var display = this.display_value["" + this.get("value")];
- if (display) {
- this.display_string(display);
- return;
- }
- if (!no_recurse) {
- var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context());
- var def = this.alive(dataset.name_get([self.get("value")])).done(function(data) {
- if (!data[0]) {
- self.do_warn(_t("Render"),
- _.str.sprintf(_t("No value found for the field %s for value %s"), self.field.string, self.get("value")));
- return;
- }
- self.display_value["" + self.get("value")] = data[0][1];
- self.render_value(true);
- }).fail( function (data, event) {
- // avoid displaying crash errors as many2One should be name_get compliant
- event.preventDefault();
- self.display_value["" + self.get("value")] = self.display_value_backup["" + self.get("value")];
- self.render_value(true);
- });
- if (this.view && this.view.render_value_defs){
- this.view.render_value_defs.push(def);
- }
- }
- },
- display_string: function (str) {
- var noValue = (str === null);
- if (!this.get("effective_readonly")) {
- this.$input.val(noValue ? "" : (str.split("\n")[0].trim() || $(data.noDisplayContent).text()));
- this.current_display = this.$input.val();
- this.$follow_button.toggle(!this.is_false());
- this.$el.toggleClass('o_with_button', !!this.$follow_button && this.$follow_button.length > 0 && !this.is_false());
- } else {
- this.$el.html(noValue ? "" : (_.escape(str.trim()).split("\n").join("<br/>") || data.noDisplayContent));
- // Define callback to perform when clicking on the field
- if (!this.options.no_open) {
- // Remove potential previously added event handler
- this.$el.off('click');
- // Ensure that the callback is performed only once even with multiple clicks
- var execute_formview_action_once = _.once(this.execute_formview_action.bind(this));
- this.$el.click(function (ev) {
- ev.preventDefault();
- execute_formview_action_once();
- });
- }
- }
- },
- execute_formview_action: function() {
- var self = this;
- var context = self.build_context().eval();
- (new Model(self.field.relation)).call('get_formview_action', [[self.get("value")], context]).then(function(action) {
- self.do_action(action);
- });
- },
- set_value: function(value_) {
- if (value_ instanceof Array) {
- this.display_value = {};
- this.display_value_backup = {};
- if (! this.options.always_reload) {
- this.display_value["" + value_[0]] = value_[1];
- }
- else {
- this.display_value_backup["" + value_[0]] = value_[1];
- }
- value_ = value_[0];
- }
- value_ = value_ || false;
- this.reinit_value(value_);
- },
- get_displayed: function() {
- return this.display_value["" + this.get("value")];
- },
- add_id: function(id) {
- this.display_value = {};
- this.display_value_backup = {};
- this.reinit_value(id);
- },
- is_false: function() {
- return ! this.get("value");
- },
- focus: function () {
- var input = !this.get('effective_readonly') && this.$input && this.$input[0];
- return input ? input.focus() : false;
- },
- _quick_create: function() {
- this.no_ed = true;
- this.ed_def.reject();
- return common.CompletionFieldMixin._quick_create.apply(this, arguments);
- },
- _search_create_popup: function() {
- this.no_ed = true;
- this.ed_def.reject();
- this.ignore_focusout = true;
- this.reinit_value(false);
- var res = common.CompletionFieldMixin._search_create_popup.apply(this, arguments);
- this.ignore_focusout = false;
- this.no_ed = false;
- return res;
- },
-});
-
-/**
- * A Abstract field for one2many and many2many field
- * For all fields on2many or many2many:
- * - this.get('value') contains a list of ids and virtual ids
- * - get_value() return an odoo write command list
- */
-var AbstractManyField = common.AbstractField.extend({
- init: function(field_manager, node) {
- var self = this;
- this._super(field_manager, node);
- this.dataset = new X2ManyDataSet(this, this.field.relation, this.build_context());
- this.dataset.x2m = this;
- this.dataset.parent_view = this.view;
- this.dataset.child_name = this.name;
- this.set('value', []);
- this.starting_ids = [];
- this.mutex = new utils.Mutex();
- this.view.on("load_record", this, this._on_load_record);
- this.dataset.on('dataset_changed', this, function() {
- var options = _.clone(_.last(arguments));
- if (!_.isObject(options) || _.isArray(options)) {
- options = {};
- }
- // don't trigger changes if all commands are not resolved
- // the editable lists change the dataset without call AbstractManyField methods
- if (!self.internal_dataset_changed && !options.internal_dataset_changed) {
- self.trigger("change:commands", options);
- }
- });
- this.on("change:commands", this, function (options) {
- self._inhibit_on_change_flag = !!options._inhibit_on_change_flag;
- self.set({'value': self.dataset.ids.slice()});
- self._inhibit_on_change_flag = false;
- });
- },
-
- _on_load_record: function (record) {
- this.starting_ids = [];
- // don't set starting_ids for the new record
- if (record.id && record[this.name] && (!isNaN(record.id) || record.id.indexOf(this.dataset.virtual_id_prefix) === -1)) {
- this.starting_ids = this.get('value').slice();
- }
- this.trigger("load_record", record);
- },
-
- set_value: function(ids) {
- ids = (ids || []).slice();
- if (_.find(ids, function(id) { return typeof(id) === "string"; } )) {
- throw new Error("set_value of '"+this.name+"' must receive an list of ids without virtual ids.", ids);
- }
- if (_.find(ids, function(id) { return typeof(id) !== "number"; } )) {
- return this.send_commands(ids, {'_inhibit_on_change_flag': this._inhibit_on_change_flag});
- }
- this.dataset.reset_ids(ids);
- return $.when(this._super(ids));
- },
-
- internal_set_value: function(ids) {
- if (_.isEqual(ids, this.get("value"))) {
- return;
- }
- var tmp = this.no_rerender;
- this.no_rerender = true;
- var def = this.data_replace(ids.slice());
- this.no_rerender = tmp;
- return def;
- },
-
- commit_value: function() {
- return this.mutex.def;
- },
-
- /*
- *@value: data {object} contains all value to send to the db
- * options {object} options sent to the dataset (like the default values)
- *@return deferred resolve with the created virtual id
- */
- data_create: function (data, options) {
- return this.send_commands([COMMANDS.create(data)], options);
- },
-
- /*
- *@value: id {int or string} id or virtual id of the record to update
- * data {object} contains all value to send to the db
- * options {object} options sent to the dataset
- *@return deferred
- */
- data_update: function (id, data, options) {
- return this.send_commands([COMMANDS.update(id, data)], options);
- },
-
- /*
- *@value: id {int or string} id or virtual id of the record to add
- * options {object} options sent to the dataset
- *@return deferred
- */
- data_link: function (id, options) {
- return this.send_commands([COMMANDS.link_to(id)], options);
- },
-
- /*
- *@value: ids {array} list of ids or virtual ids of the record to add
- * options {object} options sent to the dataset
- *@return deferred
- */
- data_link_multi: function (ids, options) {
- return this.send_commands(_.map(ids, function (id) { return COMMANDS.link_to(id); }), options);
- },
-
- /*
- *@value: id {int or string} id or virtual id of the record to unlink or delete (function of field type)
- *@return deferred
- */
- data_delete: function (id) {
- return this.send_commands([COMMANDS.delete(id)]);
- },
-
- /*
- *@value: id {int or string} id or virtual id of the record to removes relation (unlink or delete function of field type)
- *@return deferred
- */
- data_forget: function (id) {
- return this.send_commands([COMMANDS.forget(id)]);
- },
-
- /*
- *@value: ids {array} list of ids or virtual ids of the record who replace the previous list
- * options {object} options sent to the dataset
- *@return deferred
- */
- data_replace: function (ids, options) {
- return this.send_commands([COMMANDS.replace_with(ids)], options);
- },
-
- /*
- *@value: ids {array} list of ids or virtual ids of the record to read
- * fields {array} list of the field to read
- * options {object} options sent to the dataset
- *@return deferred resolve with the records
- */
- data_read: function (ids, fields, options) {
- return this.dataset.read_ids(ids, fields, options);
- },
-
- /**
- *Compute the write command list into the dataset
- *@value: command_list {array} command list
- * options {object} options for the datasets (eg: the default values)
- *@return : deferred
- */
- send_commands: function (command_list, options) {
- var self = this;
- var def = $.Deferred();
- var dataset = this.dataset;
- var res = true;
- options = options || {};
- var internal_options = _.extend({}, options, {'internal_dataset_changed': true});
-
- _.each(command_list, function(command) {
- self.mutex.exec(function() {
- var id = command[1];
- switch (command[0]) {
- case COMMANDS.CREATE:
- var data = _.clone(command[2]);
- delete data.id;
- return dataset.create(data, internal_options).then(function (id) {
- dataset.ids.push(id);
- res = id;
- });
- case COMMANDS.UPDATE:
- return dataset.write(id, command[2], internal_options).then(function () {
- if (dataset.ids.indexOf(id) === -1) {
- dataset.ids.push(id);
- res = id;
- }
- });
- case COMMANDS.FORGET:
- return dataset.unlink([id]);
- case COMMANDS.DELETE:
- return dataset.unlink([id]);
- case COMMANDS.LINK_TO:
- if (dataset.ids.indexOf(id) === -1) {
- return dataset.add_ids([id], internal_options);
- }
- return;
- case COMMANDS.DELETE_ALL:
- return dataset.reset_ids([], {keep_read_data: true});
- case COMMANDS.REPLACE_WITH:
- dataset.ids = [];
- return dataset.alter_ids(command[2], internal_options);
- default:
- throw new Error("send_commands to '"+self.name+"' receive a non command value." +
- "\n" + JSON.stringify(command_list));
- }
- });
- });
-
- this.mutex.def.then(function () {
- self.trigger("change:commands", options);
- def.resolve(res);
- });
- return def;
- },
-
- /**
- *return list of commands: create and update (and delete_all if need) (function of the field type)
- */
- get_value: function() {
- var self = this,
- is_one2many = this.field.type === "one2many",
- not_delete = this.options.not_delete,
- starting_ids = this.starting_ids.slice(),
- replace_with_ids = [],
- add_ids = [],
- command_list = [],
- id, index, record;
-
- _.each(this.get('value'), function (id) {
- index = starting_ids.indexOf(id);
- if (index !== -1) {
- starting_ids.splice(index, 1);
- }
- var record = self.dataset.get_cache(id);
- if (!_.isEmpty(record.changes)) {
- var values = _.clone(record.changes);
- // format many2one values
- for (var k in values) {
- if ((values[k] instanceof Array) && values[k].length === 2 && typeof values[k][0] === "number" && typeof values[k][1] === "string") {
- values[k] = values[k][0];
- }
- }
- if (record.to_create) {
- command_list.push(COMMANDS.create(values));
- } else {
- command_list.push(COMMANDS.update(record.id, values));
- }
- return;
- }
- if (!is_one2many || not_delete || self.dataset.delete_all) {
- replace_with_ids.push(id);
- } else {
- command_list.push(COMMANDS.link_to(id));
- }
- });
- if ((!is_one2many || not_delete || self.dataset.delete_all) && (replace_with_ids.length || starting_ids.length)) {
- _.each(command_list, function (command) {
- if (command[0] === COMMANDS.UPDATE) {
- replace_with_ids.push(command[1]);
- }
- });
- command_list.unshift(COMMANDS.replace_with(replace_with_ids));
- }
-
- _.each(starting_ids, function(id) {
- if (is_one2many && !not_delete) {
- command_list.push(COMMANDS.delete(id));
- } else if (is_one2many && !self.dataset.delete_all) {
- command_list.push(COMMANDS.forget(id));
- }
- });
-
- return command_list;
- },
-
- is_valid: function () {
- return this.mutex.def.state() === "resolved" && this._super();
- },
-
- is_false: function() {
- return _(this.get('value')).isEmpty();
- },
-
- destroy: function () {
- this.view.off("load_record", this, this._on_load_record);
- this._super();
- }
-});
-
-var FieldX2Many = AbstractManyField.extend({
- multi_selection: false,
- disable_utility_classes: true,
- x2many_views: {},
- view_options: {},
- default_view: 'tree',
- init: function(field_manager, node) {
- this._super(field_manager, node);
-
- this.is_loaded = $.Deferred();
- this.initial_is_loaded = this.is_loaded;
- this.is_started = false;
- this.set_value([]);
- },
- start: function() {
- this._super.apply(this, arguments);
- var self = this;
-
- this.load_views();
- var destroy = function() {
- self.is_loaded = self.is_loaded.then(function() {
- self.renderElement();
- self.viewmanager.destroy();
- return $.when(self.load_views()).done(function() {
- self.reload_current_view();
- });
- });
- };
- this.is_loaded.done(function() {
- self.on("change:effective_readonly", self, destroy);
- });
- this.view.on("on_button_cancel", this, destroy);
- this.is_started = true;
- this.reload_current_view();
- },
- load_views: function() {
- var self = this;
-
- var view_types = this.node.attrs.mode;
- view_types = !!view_types ? view_types.split(",") : [this.default_view];
- var views = [];
- _.each(view_types, function(view_type) {
- if (! _.include(["list", "tree", "graph", "kanban"], view_type)) {
- throw new Error(_.str.sprintf(_t("View type '%s' is not supported in X2Many."), view_type));
- }
- var view = {
- view_id: false,
- view_type: view_type === "tree" ? "list" : view_type,
- fields_view: self.field.views && self.field.views[view_type],
- options: {},
- };
- if(view.view_type === "list") {
- _.extend(view.options, {
- action_buttons: false, // to avoid 'Save' and 'Discard' buttons to appear in X2M fields
- addable: null,
- selectable: self.multi_selection,
- sortable: true,
- import_enabled: false,
- deletable: true
- });
- if (self.get("effective_readonly")) {
- _.extend(view.options, {
- deletable: null,
- reorderable: false,
- });
- }
- } else if (view.view_type === "kanban") {
- _.extend(view.options, {
- action_buttons: true,
- confirm_on_delete: false,
- });
- if (self.get("effective_readonly")) {
- _.extend(view.options, {
- action_buttons: false,
- quick_creatable: false,
- creatable: false,
- read_only_mode: true,
- });
- }
- }
- views.push(view);
- });
- this.views = views;
-
- this.viewmanager = new X2ManyViewManager(this, this.dataset, views, this.view_options, this.x2many_views);
- this.viewmanager.x2m = self;
- var def = $.Deferred().done(function() {
- self.initial_is_loaded.resolve();
- });
- this.viewmanager.on("controller_inited", self, function(view_type, controller) {
- controller.x2m = self;
- if (view_type == "list") {
- if (self.get("effective_readonly")) {
- controller.on('edit:before', self, function (e) {
- e.cancel = true;
- });
- _(controller.columns).find(function (column) {
- if (!(column instanceof list_widget_registry.get('field.handle'))) {
- return false;
- }
- column.modifiers.invisible = true;
- return true;
- });
- }
- } else if (view_type == "graph") {
- self.reload_current_view();
- }
- def.resolve();
- });
- this.viewmanager.on("switch_mode", self, function(n_mode) {
- $.when(self.commit_value()).done(function() {
- if (n_mode === "list") {
- utils.async_when().done(function() {
- self.reload_current_view();
- });
- }
- });
- });
- utils.async_when().done(function () {
- self.$el.addClass('o_view_manager_content');
- self.alive(self.viewmanager.attachTo(self.$el));
- });
- return def;
- },
- reload_current_view: function() {
- var self = this;
- self.is_loaded = self.is_loaded.then(function() {
- var view = self.get_active_view();
- if (view.type === "list") {
- view.controller.current_min = 1;
- return view.controller.reload_content();
- } else if (view.controller.do_search) {
- return view.controller.do_search(self.build_domain(), self.dataset.get_context(), []);
- }
- }, undefined);
- return self.is_loaded;
- },
- get_active_view: function () {
- /**
- * Returns the current active view if any.
- */
- return (this.viewmanager && this.viewmanager.active_view);
- },
- set_value: function(value_) {
- var self = this;
- this._super(value_).then(function () {
- if (self.is_started && !self.no_rerender) {
- return self.reload_current_view();
- }
- });
- },
- commit_value: function() {
- var view = this.get_active_view();
- if (view && view.type === "list" && view.controller.__focus) {
- return $.when(this.mutex.def, view.controller._on_blur_one2many());
- }
- return this.mutex.def;
- },
- is_syntax_valid: function() {
- var view = this.get_active_view();
- if (!view){
- return true;
- }
- switch (this.viewmanager.active_view.type) {
- case 'form':
- return _(view.controller.fields).chain()
- .invoke('is_valid')
- .all(_.identity)
- .value();
- case 'list':
- return view.controller.is_valid();
- }
- return true;
- },
- is_false: function() {
- return _(this.dataset.ids).isEmpty();
- },
-});
-
-var X2ManyDataSet = data.BufferedDataSet.extend({
- get_context: function() {
- this.context = this.x2m.build_context();
- var self = this;
- _.each(arguments, function(context) {
- self.context.add(context);
- });
- return this.context;
- },
-});
-
-var X2ManyViewManager = ViewManager.extend({
- custom_events: {
- // Catch event scrollTo to prevent scrolling to the top when using the
- // pager of List and Kanban views in One2Many fields
- 'scrollTo': function() {},
- },
- init: function(parent, dataset, views, flags, x2many_views) {
- // By default, render buttons and pager in X2M fields, but no sidebar
- flags = _.extend({}, flags, {
- headless: false,
- search_view: false,
- action_buttons: true,
- pager: true,
- sidebar: false,
- });
- this.control_panel = new ControlPanel(parent, "X2ManyControlPanel");
- this.set_cp_bus(this.control_panel.get_bus());
- this._super(parent, dataset, views, flags);
- this.registry = core.view_registry.extend(x2many_views);
- },
- start: function() {
- this.control_panel.prependTo(this.$el);
- return this._super();
- },
- switch_mode: function(mode, unused) {
- if (mode !== 'form') {
- return this._super(mode, unused);
- }
- var self = this;
- var id = self.x2m.dataset.index !== null ? self.x2m.dataset.ids[self.x2m.dataset.index] : null;
- var pop = new common.FormViewDialog(this, {
- res_model: self.x2m.field.relation,
- res_id: id,
- context: self.x2m.build_context(),
- title: _t("Open: ") + self.x2m.string,
- create_function: function(data, options) {
- return self.x2m.data_create(data, options);
- },
- write_function: function(id, data, options) {
- return self.x2m.data_update(id, data, options).done(function() {
- self.x2m.reload_current_view();
- });
- },
- alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined,
- parent_view: self.x2m.view,
- child_name: self.x2m.name,
- read_function: function(ids, fields, options) {
- return self.x2m.data_read(ids, fields, options);
- },
- form_view_options: {'not_interactible_on_create':true},
- readonly: self.x2m.get("effective_readonly")
- }).open();
- pop.on("elements_selected", self, function() {
- self.x2m.reload_current_view();
- });
- },
-});
-
-var X2ManyListView = ListView.extend({
- is_valid: function () {
- if (!this.fields_view || !this.editable()){
- return true;
- }
- if (_.isEmpty(this.records.records)){
- return true;
- }
- var fields = this.editor.form.fields;
- var current_values = {};
- _.each(fields, function(field){
- field._inhibit_on_change_flag = true;
- field.__no_rerender = field.no_rerender;
- field.no_rerender = true;
- current_values[field.name] = field.get('value');
- });
- var ids = _.map(this.records.records, function (item) { return item.attributes.id; });
- var cached_records = _.filter(this.dataset.cache, function(item){return _.contains(ids, item.id) && !_.isEmpty(item.values) && !item.to_delete;});
- var valid = _.every(cached_records, function(record){
- _.each(fields, function(field){
- var value = record.values[field.name];
- field._inhibit_on_change_flag = true;
- field.no_rerender = true;
- field.set_value(_.isArray(value) && _.isArray(value[0]) ? [COMMANDS.delete_all()].concat(value) : value);
- });
- return _.every(fields, function(field){
- field.process_modifiers();
- field._check_css_flags();
- return field.is_valid();
- });
- });
- _.each(fields, function(field){
- field.set('value', current_values[field.name], {silent: true});
- field._inhibit_on_change_flag = false;
- field.no_rerender = field.__no_rerender;
- });
- return valid;
- },
- render_pager: function($node, options) {
- options = _.extend(options || {}, {
- single_page_hidden: true,
- });
- this._super($node, options);
- },
- display_nocontent_helper: function () {
- return false;
- },
-});
-
-/**
- * ListView.List subclass adding an "Add an item" row to replace the Create
- * button in the ControlPanel.
- */
-var X2ManyList = ListView.List.extend({
- pad_table_to: function (count) {
- if (!this.view.is_action_enabled('create') || this.view.x2m.get('effective_readonly')) {
- this._super(count);
- return;
- }
-
- this._super(count > 0 ? count - 1 : 0);
-
- var self = this;
- var columns = _(this.columns).filter(function (column) {
- return column.invisible !== '1';
- }).length;
- if (this.options.selectable) { columns++; }
- if (this.options.deletable) { columns++; }
-
- var $cell = $('<td>', {
- colspan: columns,
- 'class': 'o_form_field_x2many_list_row_add'
- }).append(
- $('<a>', {href: '#'}).text(_t("Add an item"))
- .click(function (e) {
- e.preventDefault();
- e.stopPropagation();
- var def;
- if (self.view.editable()) {
- // FIXME: there should also be an API for that one
- if (self.view.editor.form.__blur_timeout) {
- clearTimeout(self.view.editor.form.__blur_timeout);
- self.view.editor.form.__blur_timeout = false;
- }
- def = self.view.save_edition();
- }
- $.when(def).done(self.view.do_add_record.bind(self));
- }));
-
- var $padding = this.$current.find('tr:not([data-id]):first');
- var $newrow = $('<tr>').append($cell);
- if ($padding.length) {
- $padding.before($newrow);
- } else {
- this.$current.append($newrow);
- }
- },
-});
-
-var One2ManyListView = X2ManyListView.extend({
- init: function () {
- this._super.apply(this, arguments);
- this.options = _.extend(this.options, {
- GroupsType: One2ManyGroups,
- ListType: X2ManyList
- });
- this.on('edit:after', this, this.proxy('_after_edit'));
- this.on('save:before cancel:before', this, this.proxy('_before_unedit'));
-
- /* detect if the user try to exit the one2many widget */
- core.bus.on('click', this, this._on_click_outside);
-
- this.dataset.on('dataset_changed', this, function () {
- this._dataset_changed = true;
- this.dataset.x2m._dirty_flag = true;
- });
- this.dataset.x2m.on('load_record', this, function () {
- this._dataset_changed = false;
- });
-
- this.on('warning', this, function(e) { // In case of editable list view, we do not want any warning which comes from the editor
- if (this.editable()) {
- e.stop_propagation();
- }
- });
- },
- do_add_record: function () {
- if (this.editable()) {
- this._super.apply(this, arguments);
- } else {
- var self = this;
- new common.SelectCreateDialog(this, {
- res_model: self.x2m.field.relation,
- domain: self.x2m.build_domain(),
- context: self.x2m.build_context(),
- title: _t("Create: ") + self.x2m.string,
- initial_view: "form",
- alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined,
- create_function: function(data, options) {
- return self.x2m.data_create(data, options);
- },
- read_function: function(ids, fields, options) {
- return self.x2m.data_read(ids, fields, options);
- },
- parent_view: self.x2m.view,
- child_name: self.x2m.name,
- form_view_options: {'not_interactible_on_create':true},
- on_selected: function() {
- self.x2m.reload_current_view();
- }
- }).open();
- }
- },
- do_activate_record: function(index, id) {
- var self = this;
- new common.FormViewDialog(self, {
- res_model: self.x2m.field.relation,
- res_id: id,
- context: self.x2m.build_context(),
- title: _t("Open: ") + self.x2m.string,
- write_function: function(id, data, options) {
- return self.x2m.data_update(id, data, options).done(function() {
- self.x2m.reload_current_view();
- });
- },
- alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined,
- parent_view: self.x2m.view,
- child_name: self.x2m.name,
- read_function: function(ids, fields, options) {
- return self.x2m.data_read(ids, fields, options);
- },
- form_view_options: {'not_interactible_on_create':true},
- readonly: !this.is_action_enabled('edit') || self.x2m.get("effective_readonly")
- }).open();
- },
- do_button_action: function (name, id, callback) {
- if (!_.isNumber(id)) {
- this.do_warn(_t("Action Button"),
- _t("The o2m record must be saved before an action can be used"));
- return;
- }
- var parent_form = this.x2m.view;
- var self = this;
- this.save_edition().then(function () {
- if (parent_form) {
- return parent_form.save();
- } else {
- return $.when();
- }
- }).done(function () {
- var ds = self.x2m.dataset;
- var changed_records = _.find(ds.cache, function(record) {
- return record.to_create || record.to_delete || !_.isEmpty(record.changes);
- });
- if (!self.x2m.options.reload_on_button && !changed_records) {
- self.handle_button(name, id, callback);
- } else {
- self.handle_button(name, id, function(){
- self.x2m.view.reload();
- });
- }
- });
- },
- start_edition: function (record, options) {
- if (!this.__focus) {
- this._on_focus_one2many();
- }
- return this._super(record, options);
- },
- reload_content: function () {
- var self = this;
- if (self.__focus) {
- self._on_blur_one2many();
- return this._super().then(function () {
- var record_being_edited = self.records.get(self.editor.form.datarecord.id);
- if (record_being_edited) {
- self.start_edition(record_being_edited);
- }
- });
- }
- return this._super();
- },
- _on_focus_one2many: function () {
- if(!this.editor.is_editing()) {
- return;
- }
- this.dataset.x2m.internal_dataset_changed = true;
- this._dataset_changed = false;
- this.__focus = true;
- },
- _on_click_outside: function(e) {
- if(this.__ignore_blur || !this.editor.is_editing()) {
- return;
- }
-
- var $target = $(e.target);
-
- // If click on a button, a ui-autocomplete dropdown or modal-backdrop, it is not considered as a click outside
- var click_outside = ($target.closest('.ui-autocomplete,.btn,.modal-backdrop').length === 0);
-
- // Check if click inside the current list editable
- var $o2m = $target.closest(".o_list_editable");
- if($o2m.length && $o2m[0] === this.el) {
- click_outside = false;
- }
-
- // Check if click inside a modal which is on top of the current list editable
- var $modal = $target.closest(".modal");
- if($modal.length) {
- var $currentModal = this.$el.closest(".modal");
- if($currentModal.length === 0 || $currentModal[0] !== $modal[0]) {
- click_outside = false;
- }
- }
-
- if (click_outside) {
- this._on_blur_one2many();
- }
- },
- _on_blur_one2many: function() {
- if(this.__ignore_blur) {
- return $.when();
- }
-
- this.__ignore_blur = true;
- this.__focus = false;
- this.dataset.x2m.internal_dataset_changed = false;
-
- var self = this;
- return this.save_edition(true).done(function () {
- if (self._dataset_changed) {
- self.dataset.trigger('dataset_changed');
- }
- }).always(function() {
- self.__ignore_blur = false;
- });
- },
- _after_edit: function () {
- this.editor.form.on('blurred', this, this._on_blur_one2many);
-
- // The form's blur thing may be jiggered during the edition setup,
- // potentially leading to the x2m instasaving the row. Cancel any
- // blurring triggered the edition startup here
- this.editor.form.widgetFocused();
- },
- _before_unedit: function () {
- this.editor.form.off('blurred', this, this._on_blur_one2many);
- },
- do_delete: function (ids) {
- var confirm = window.confirm;
- window.confirm = function () { return true; };
- try {
- return this._super(ids);
- } finally {
- window.confirm = confirm;
- }
- },
- reload_record: function (record, options) {
- if (!options || !options.do_not_evict) {
- // Evict record.id from cache to ensure it will be reloaded correctly
- this.dataset.evict_record(record.get('id'));
- }
-
- return this._super(record);
- },
-});
-
-var One2ManyGroups = ListView.Groups.extend({
- setup_resequence_rows: function () {
- if (!this.view.x2m.get('effective_readonly')) {
- this._super.apply(this, arguments);
- }
- }
-});
-
-var FieldOne2Many = FieldX2Many.extend({
- init: function() {
- this._super.apply(this, arguments);
- this.x2many_views = {
- kanban: core.view_registry.get('one2many_kanban'),
- list: One2ManyListView,
- };
- },
- start: function() {
- this.$el.addClass('o_form_field_one2many');
- return this._super.apply(this, arguments);
- },
- commit_value: function() {
- var self = this;
- return this.is_loaded.then(function() {
- var view = self.viewmanager.active_view;
- if(view.type === "list" && view.controller.editable()) {
- return self.mutex.def.then(function () {
- return view.controller.save_edition();
- });
- }
- return self.mutex.def;
- });
- },
- is_false: function() {
- return false;
- },
-});
-
-var Many2ManyListView = X2ManyListView.extend({
- init: function () {
- this._super.apply(this, arguments);
- this.options = _.extend(this.options, {
- ListType: X2ManyList,
- });
- this.on('edit:after', this, this.proxy('_after_edit'));
- this.on('save:before cancel:before', this, this.proxy('_before_unedit'));
- },
- do_add_record: function () {
- var self = this;
-
- new common.SelectCreateDialog(this, {
- res_model: this.model,
- domain: new data.CompoundDomain(this.x2m.build_domain(), ["!", ["id", "in", this.x2m.dataset.ids]]),
- context: this.x2m.build_context(),
- title: _t("Add: ") + this.x2m.string,
- alternative_form_view: this.x2m.field.views ? this.x2m.field.views.form : undefined,
- no_create: this.x2m.options.no_create || !this.is_action_enabled('create'),
- on_selected: function(element_ids) {
- return self.x2m.data_link_multi(element_ids).then(function() {
- self.x2m.reload_current_view();
- });
- }
- }).open();
- },
- do_activate_record: function(index, id) {
- var self = this;
- var pop = new common.FormViewDialog(this, {
- res_model: this.model,
- res_id: id,
- context: this.x2m.build_context(),
- title: _t("Open: ") + this.x2m.string,
- alternative_form_view: this.x2m.field.views ? this.x2m.field.views.form : undefined,
- readonly: !this.is_action_enabled('edit') || self.x2m.get("effective_readonly"),
- }).open();
- pop.on('write_completed', self, function () {
- self.dataset.evict_record(id);
- self.reload_content();
- });
- },
- do_button_action: function(name, id, callback) {
- var self = this;
- var _sup = _.bind(this._super, this);
- if (! this.x2m.options.reload_on_button) {
- return _sup(name, id, callback);
- } else {
- return this.x2m.view.save().then(function() {
- return _sup(name, id, function() {
- self.x2m.view.reload();
- });
- });
- }
- },
- _after_edit: function () {
- this.editor.form.on('blurred', this, this._on_blur_many2many);
- },
- _before_unedit: function () {
- this.editor.form.off('blurred', this, this._on_blur_many2many);
- },
- _on_blur_many2many: function() {
- return this.save_edition().done(function () {
- if (self._dataset_changed) {
- self.dataset.trigger('dataset_changed');
- }
- });
- },
-
-});
-
-var FieldMany2Many = FieldX2Many.extend({
- init: function() {
- this._super.apply(this, arguments);
- this.x2many_views = {
- list: Many2ManyListView,
- kanban: core.view_registry.get('many2many_kanban'),
- };
- },
- start: function() {
- this.$el.addClass('o_form_field_many2many');
- return this._super.apply(this, arguments);
- }
-});
-
-var FieldMany2ManyKanban = FieldMany2Many.extend({
- default_view: 'kanban',
- init: function() {
- this._super.apply(this, arguments);
- this.view_options = _.extend({}, this.view_options, {
- 'create_text': _t("Add"),
- });
- }
-});
-
-var FieldMany2ManyTags = AbstractManyField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, {
- className: "o_form_field_many2manytags",
- tag_template: "FieldMany2ManyTag",
-
- events: {
- 'click .o_delete': function(e) {
- this.remove_id($(e.target).parent().data('id'));
- },
- 'click .badge': 'open_color_picker',
- 'mousedown .o_colorpicker span': 'update_color',
- 'focusout .o_colorpicker': 'close_color_picker',
- },
-
- init: function(field_manager, node) {
- this._super(field_manager, node);
- common.CompletionFieldMixin.init.call(this);
- this.set({"value": []});
- },
- willStart: function () {
- var self = this;
- return this.dataset.call('fields_get', []).then(function(fields) {
- self.fields = fields;
- });
- },
- commit_value: function() {
- this.dataset.cancel_read();
- return this._super();
- },
- initialize_content: function() {
- if(!this.get("effective_readonly")) {
- this.many2one = new FieldMany2One(this.field_manager, this.node);
- this.many2one.options.no_open = true;
- this.many2one.on('changed_value', this, function() {
- var newValue = this.many2one.get('value');
- if(newValue) {
- this.add_id(newValue);
- this.many2one.set({'value': false});
- }
- });
-
- this.many2one.prependTo(this.$el);
-
- var self = this;
- this.many2one.$('input').on('keydown', function(e) {
- if(!$(e.target).val() && e.which === 8) {
- var $badges = self.$('.badge');
- if($badges.length) {
- self.remove_id($badges.last().data('id'));
- }
- }
- });
- this.many2one.get_search_blacklist = function () {
- return self.get('value');
- };
- }
- },
- destroy_content: function() {
- if(this.many2one) {
- this.many2one.destroy();
- this.many2one = undefined;
- }
- },
- get_render_data: function(ids){
- this.dataset.cancel_read();
- var fields = this.fields.color ? ['display_name', 'name', 'color'] : ['display_name', 'name']; // TODO master: remove useless 'name'
- return this.dataset.read_ids(ids, fields);
- },
- render_tag: function(data) {
- this.$('.badge').remove();
- this.$el.prepend(QWeb.render(this.tag_template, {elements: data, readonly: this.get('effective_readonly')}));
- },
- render_value: function() {
- var self = this;
- var values = this.get("value");
- var handle_names = function(_data) {
- _.each(_data, function(el) {
- el.display_name = el.display_name.trim() ? _.str.escapeHTML(el.display_name) : data.noDisplayContent;
- });
- self.render_tag(_data);
- };
- if (!values || values.length > 0) {
- return this.alive(this.get_render_data(values)).done(handle_names);
- } else {
- handle_names([]);
- }
- },
- add_id: function(id) {
- this.set({'value': _.uniq(this.get('value').concat([id]))});
- },
- remove_id: function(id) {
- this.set({'value': _.without(this.get("value"), id)});
- },
- focus: function () {
- if(!this.get("effective_readonly")) {
- return this.many2one.focus();
- }
- return false;
- },
- set_dimensions: function(height, width) {
- if(this.many2one) {
- this.many2one.$el.css('height', 'auto');
- }
- this.$el.css({
- width: width,
- minHeight: height,
- });
- if(this.many2one) {
- this.many2one.$el.css('height', this.$el.height());
- }
- },
- open_color_picker: function(ev){
-
- if (this.fields.color) {
- this.$color_picker = $(QWeb.render('FieldMany2ManyTag.colorpicker', {
- 'widget': this,
- 'tag_id': $(ev.currentTarget).data('id'),
- }));
-
- $(ev.currentTarget).append(this.$color_picker);
- this.$color_picker.dropdown('toggle');
- this.$color_picker.attr("tabindex", 1).focus();
- }
- },
- close_color_picker: function(){
- this.$color_picker.remove();
- },
- update_color: function(ev) {
- ev.preventDefault();
-
- var color = $(ev.currentTarget).data('color');
- var id = $(ev.currentTarget).data('id');
-
- var self = this;
- this.dataset.call('write', [id, {'color': color}]).done(function(){
- self.dataset.cache[id].from_read = {};
- self.dataset.evict_record(id);
- var tag = self.$el.find("span.badge[data-id='" + id + "']");
- var old_color = tag.data('color');
- tag.removeClass('o_tag_color_' + old_color);
- tag.data('color', color);
- tag.addClass('o_tag_color_' + color);
- });
- },
-});
-
-/**
- * Widget for (many2many field) to upload one or more file in same time and display in list.
- * The user can delete his files.
- * Options on attribute ; "blockui" {Boolean} block the UI or not during the file is uploading
- */
-var FieldMany2ManyBinaryMultiFiles = AbstractManyField.extend(common.ReinitializeFieldMixin, {
- template: "FieldBinaryFileUploader",
- events: {
- 'click .o_attach': function(e) {
- this.$('.o_form_input_file').click();
- },
- 'change .o_form_input_file': function(event) {
- event.stopPropagation();
- var files = event.target.files,
- attachments = this.get('value');
-
- if(this.node.attrs.blockui){
- framework.blockUI();
- }
- _.each(files, function(file){
- var attachment = _.findWhere(_.values(this.data), {filename: file.name});
- if(attachment && !attachment.no_unlink){
- this.ds_file.unlink([attachment.id]);
- attachments = _.without(attachments, attachment.id);
- this.data = _.omit(this.data, attachment.id);
- }
- this.files_uploading.push(file);
- }.bind(this));
- this.set({value: attachments});
- this.$('form.o_form_binary_form').submit();
- this.$(".oe_fileupload").hide();
- this.render_value();
- },
- 'click .oe_delete': function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- var file_id = $(e.currentTarget).data("id");
- if(file_id) {
- var files = _.without(this.get('value'), file_id);
- if(!this.data[file_id].no_unlink) {
- this.ds_file.unlink([file_id]);
- this.data = _.omit(this.data, file_id);
- }
- this.set({'value': files});
- }
- },
- },
- init: function(field_manager, node) {
- this._super.apply(this, arguments);
- this.session = session;
- if(this.field.type != "many2many" || this.field.relation != 'ir.attachment') {
- throw _.str.sprintf(_t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model."), this.field.string);
- }
- this.data = {};
- this.files_uploading = [];
- this.set_value([]);
- this.ds_file = new data.DataSetSearch(this, 'ir.attachment');
- this.fileupload_id = _.uniqueId('oe_fileupload_temp');
- $(window).on(this.fileupload_id, _.bind(this.on_file_loaded, this));
- },
- get_file_url: function(attachment) {
- return '/web/content/' + attachment.id + '?download=true';
- },
- read_name_values : function() {
- var self = this;
- // don't reset know values
- var ids = this.get('value');
- var _value = _.filter(ids, function(id) { return self.data[id] === undefined; });
- // send request for get_name
- if(_value.length) {
- return this.ds_file.call('read', [_value, ['id', 'name', 'datas_fname', 'mimetype']])
- .then(process_data);
- } else {
- return $.when(ids);
- }
-
- function process_data(datas) {
- _.each(datas, function(data) {
- data.no_unlink = true;
- data.url = self.get_file_url(data);
- self.data[data.id] = data;
- });
- return ids;
- }
- },
- render_value: function() {
- var self = this;
- this.read_name_values().then(function (ids) {
- self.$('.oe_placeholder_files, .oe_attachments')
- .replaceWith($(QWeb.render('FieldBinaryFileUploader.files', {'widget': self, 'values': ids})));
- self.$(".oe_fileupload").show();
-
- // display image thumbnail
- self.$(".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') + "')");
- }
- });
- });
- },
- on_file_loaded: function(e, result) {
- var attachments = this.get('value'),
- files = Array.prototype.slice.call(arguments, 1);
- this.files_uploading = []; // files has been uploaded clear uploading
-
- if(this.node.attrs.blockui) { // unblock UI
- framework.unblockUI();
- }
- var upload_error = _.filter(files, function(attachment) {return attachment.error;});
- if (upload_error.length) {
- this.do_warn(_t('Uploading Error'), upload_error[0].error);
- }
- _.each(files, function(file){
- if(!file.error){
- attachments.push(file.id);
- file.url = this.get_file_url(file);
- this.data[file.id] = file;
- }
- }.bind(this));
- this.set({value: _.clone(attachments)});
- this.render_value();
- },
-});
-
-/*
- This type of field display a list of checkboxes. It works only with m2ms. This field will display one checkbox for each
- record existing in the model targeted by the relation, according to the given domain if one is specified. Checked records
- will be added to the relation.
-*/
-var FieldMany2ManyCheckBoxes = AbstractManyField.extend(common.ReinitializeFieldMixin, {
- className: "oe_form_many2many_checkboxes",
- init: function() {
- this._super.apply(this, arguments);
- this.set("records", []);
- this.field_manager.on("view_content_has_changed", this, function() {
- var domain = new data.CompoundDomain(this.build_domain()).eval();
- if (!_.isEqual(domain, this.get("domain"))) {
- this.set("domain", domain);
- }
- });
- this.records_orderer = new utils.DropMisordered();
- },
- initialize_field: function() {
- common.ReinitializeFieldMixin.initialize_field.call(this);
- this.on("change:domain", this, this.query_records);
- this.set("domain", new data.CompoundDomain(this.build_domain()).eval());
- this.on("change:records", this, this.render_value);
- },
- query_records: function() {
- var self = this;
- var model = new Model(this.field.relation);
- this.records_orderer.add(model.call("search", [this.get("domain")], {"context": this.build_context()}).then(function(record_ids) {
- return model.call("name_get", [record_ids] , {"context": self.build_context()});
- })).then(function(res) {
- self.set("records", res);
- });
- },
- render_value: function() {
- this.$el.html(QWeb.render("FieldMany2ManyCheckBoxes", {widget: this}));
- var $inputs = this.$("input");
- $inputs.change(_.bind(this.from_dom, this));
- if (this.get("effective_readonly")) {
- $inputs.attr("disabled", "true");
- }
- },
- from_dom: function() {
- var new_value = this.$("input:checked").map(function() { return +$(this).data("record-id"); }).get();
- if (!_.isEqual(new_value, this.get("value"))) {
- this.internal_set_value(new_value);
- }
- },
- is_false: function() {
- return false;
- },
-});
-
-core.form_widget_registry
- .add('many2one', FieldMany2One)
- .add('many2many', FieldMany2Many)
- .add('many2many_tags', FieldMany2ManyTags)
- .add('many2many_kanban', FieldMany2ManyKanban)
- .add('one2many', FieldOne2Many)
- .add('one2many_list', FieldOne2Many)
- .add('many2many_binary', FieldMany2ManyBinaryMultiFiles)
- .add('many2many_checkboxes', FieldMany2ManyCheckBoxes);
-
-core.one2many_view_registry
- .add('list', One2ManyListView);
-
-return {
- FieldMany2ManyTags: FieldMany2ManyTags,
- AbstractManyField: AbstractManyField,
- FieldMany2One: FieldMany2One,
-};
-
-});
diff --git a/addons/web/static/src/js/views/form_upgrade_widgets.js b/addons/web/static/src/js/views/form_upgrade_widgets.js
deleted file mode 100644
index 57f7ec858a3..00000000000
--- a/addons/web/static/src/js/views/form_upgrade_widgets.js
+++ /dev/null
@@ -1,101 +0,0 @@
-odoo.define('web.form_upgrade_widgets', function (require) {
-"use strict";
-
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var form_widgets = require('web.form_widgets');
-var framework = require('web.framework');
-var Model = require('web.DataModel');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-/**
- * This widget is intended to be used in config settings.
- * When checked, an upgrade popup is showed to the user.
- */
-var AbstractFieldUpgrade = {
- events: {
- 'click input': 'on_click_input',
- },
-
- start: function() {
- this._super.apply(this, arguments);
- this.get_enterprise_label().after($("<span>", {
- text: "Enterprise",
- 'class': "label label-primary oe_inline"
- }));
- },
-
- open_dialog: function() {
- var message = $(QWeb.render('EnterpriseUpgrade'));
-
- var buttons = [
- {
- text: _t("Upgrade now"),
- classes: 'btn-primary',
- close: true,
- click: this.confirm_upgrade,
- },
- {
- text: _t("Cancel"),
- close: true,
- },
- ];
-
- return new Dialog(this, {
- size: 'medium',
- buttons: buttons,
- $content: $('<div>', {
- html: message,
- }),
- title: _t("Odoo Enterprise"),
- }).open();
- },
-
- confirm_upgrade: function() {
- new Model("res.users").call("search_count", [[["share", "=", false]]]).then(function(data) {
- framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data);
- });
- },
-
- get_enterprise_label: function() {},
- on_click_input: function() {},
-};
-
-var UpgradeBoolean = form_widgets.FieldBoolean.extend(AbstractFieldUpgrade, {
- template: "FieldUpgradeBoolean",
-
- get_enterprise_label: function() {
- return this.$label;
- },
-
- on_click_input: function() {
- if(this.$checkbox.prop("checked")) {
- this.open_dialog().on('closed', this, function() {
- this.$checkbox.prop("checked", false);
- });
- }
- },
-});
-
-var UpgradeRadio = form_widgets.FieldRadio.extend(AbstractFieldUpgrade, {
- get_enterprise_label: function() {
- // override the margin:0px
- this.$('label').addClass('mr4');
- return this.$('label').last();
- },
- on_click_input: function(event) {
- if($(event.target).val() === "1") {
- this.open_dialog().on('closed', this, function() {
- this.$('input').first().prop("checked", true);
- });
- }
- },
-});
-
-core.form_widget_registry
- .add('upgrade_boolean', UpgradeBoolean)
- .add('upgrade_radio', UpgradeRadio);
-
-});
diff --git a/addons/web/static/src/js/views/form_view.js b/addons/web/static/src/js/views/form_view.js
deleted file mode 100644
index 0471f6d3156..00000000000
--- a/addons/web/static/src/js/views/form_view.js
+++ /dev/null
@@ -1,1653 +0,0 @@
-odoo.define('web.FormView', function (require) {
-"use strict";
-
-var common = require('web.form_common');
-var core = require('web.core');
-var crash_manager = require('web.crash_manager');
-var data = require('web.data');
-var Dialog = require('web.Dialog');
-var FormRenderingEngine = require('web.FormRenderingEngine');
-var Model = require('web.DataModel');
-var Pager = require('web.Pager');
-var Sidebar = require('web.Sidebar');
-var utils = require('web.utils');
-var View = require('web.View');
-
-var _t = core._t;
-var _lt = core._lt;
-var QWeb = core.qweb;
-
-/**
- * Properties:
- * - actual_mode: always "view", "edit" or "create". Read-only property. Determines
- * the mode used by the view.
- */
-var FormView = View.extend(common.FieldManagerMixin, {
- className: "o_form_view",
- defaults: _.extend({}, View.prototype.defaults, {
- not_interactible_on_create: false,
- initial_mode: "view",
- disable_autofocus: false,
- footer_to_buttons: false,
- }),
- display_name: _lt('Form'),
- icon: 'fa-edit',
- multi_record: false,
- // Indicates that this view is not searchable, and thus that no search view should be displayed.
- searchable: false,
-
- /**
- * Called each time the form view is attached into the DOM
- */
- on_attach_callback: function() {
- this.trigger('attached');
- this.autofocus();
- },
- /**
- * Called each time the form view is detached from the DOM
- */
- on_detach_callback: function() {
- this.trigger('detached');
- },
- init: function() {
- var self = this;
- this._super.apply(this, arguments);
- this.fields = {};
- this.fields_order = [];
- this.datarecord = {};
- this.onchanges_mutex = new utils.Mutex();
- this.default_focus_field = null;
- this.default_focus_button = null;
- this.fields_registry = core.form_widget_registry;
- this.tags_registry = core.form_tag_registry;
- this.widgets_registry = core.form_custom_registry;
- this.has_been_loaded = $.Deferred();
- this.translatable_fields = [];
- this.is_initialized = $.Deferred();
- this.record_loaded = $.Deferred();
- this.mutating_mutex = new utils.Mutex();
- this.save_list = [];
- this.render_value_defs = [];
- this.reload_mutex = new utils.Mutex();
- this.__clicked_inside = false;
- this.__blur_timeout = null;
- this.rendering_engine = new FormRenderingEngine(this);
- this.set({actual_mode: this.options.initial_mode});
- this.has_been_loaded.done(function() {
- self.on("change:actual_mode", self, self.toggle_buttons);
- self.on("change:actual_mode", self, self.toggle_sidebar);
- });
- self.on("load_record", self, self.load_record);
- core.bus.on('clear_uncommitted_changes', this, function(chain_callbacks) {
- var self = this;
- chain_callbacks(function() {
- return self.can_be_discarded();
- });
- });
- },
- start: function() {
- if (this.$pager) {
- this.$pager.off();
- }
- var self = this;
-
- this.rendering_engine.set_fields_registry(this.fields_registry);
- this.rendering_engine.set_tags_registry(this.tags_registry);
- this.rendering_engine.set_widgets_registry(this.widgets_registry);
- this.rendering_engine.set_fields_view(this.fields_view);
- this.rendering_engine.render_to(this.$el);
-
- this.$el.on('mousedown.formBlur', function () {
- self.__clicked_inside = true;
- });
-
- this.has_been_loaded.resolve();
-
- // Add bounce effect on button 'Edit' when click on readonly page view.
- this.$(".oe_title,.o_group").on('click', function (e) {
- if(self.get("actual_mode") === "view" && self.$buttons && !$(e.target).is('[data-toggle]')) {
- self.$buttons.find(".o_form_button_edit").openerpBounce();
- core.bus.trigger('click', e);
- }
- });
- return this._super();
- },
- destroy: function() {
- _.each(this.get_widgets(), function(w) {
- w.off('focused blurred');
- w.destroy();
- });
- if (this.$el) {
- this.$el.off('.formBlur');
- }
- this._super();
- },
- /**
- * Render the buttons according to the FormView.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 FormView inserts them into this.options.$buttons
- * if it exists
- */
- render_buttons: function($node) {
- this.$buttons = $('<div/>');
-
- var $footer = this.$('footer');
- if (this.options.action_buttons !== false || this.options.footer_to_buttons && $footer.children().length === 0) {
- this.$buttons.append(QWeb.render("FormView.buttons", {'widget': this}));
- }
- if (this.options.footer_to_buttons) {
- $footer.appendTo(this.$buttons);
- }
-
- // Show or hide the buttons according to the view mode
- this.toggle_buttons();
- this.$buttons.on('click', '.o_form_button_create', this.on_button_create);
- this.$buttons.on('click', '.o_form_button_edit', this.on_button_edit);
- this.$buttons.on('click', '.o_form_button_save', this.on_button_save);
- this.$buttons.on('click', '.o_form_button_cancel', this.on_button_cancel);
-
- this.$buttons.appendTo($node);
- },
- /**
- * Instantiate and render the sidebar if a sidebar is requested
- * Sets this.sidebar
- * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted
- **/
- render_sidebar: function($node) {
- if (!this.sidebar && this.options.sidebar) {
- this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')});
- if (this.fields_view.toolbar) {
- this.sidebar.add_toolbar(this.fields_view.toolbar);
- }
- this.sidebar.add_items('other', _.compact([
- this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.on_button_delete },
- this.is_action_enabled('create') && { label: _t('Duplicate'), callback: this.on_button_duplicate }
- ]));
-
- this.sidebar.appendTo($node);
-
- // Show or hide the sidebar according to the view mode
- this.toggle_sidebar();
- }
- },
- /**
- * Instantiate and render the pager and add listeners on it.
- * Set this.pager
- * @param {jQuery} [$node] a jQuery node where the pager should be inserted
- * $node may be undefined, in which case the FormView inserts the pager into this.options.$pager
- */
- render_pager: function($node) {
- if (this.options.pager) {
- var self = this;
- var options = {
- validate: _.bind(this.can_be_discarded, this),
- };
-
- this.pager = new Pager(this, this.dataset.ids.length, this.dataset.index + 1, 1, options);
- this.pager.on('pager_changed', this, function (new_state) {
- this.pager.disable();
- this.dataset.index = new_state.current_min - 1;
- this.trigger('pager_action_executed');
- $.when(this.reload()).then(function () {
- self.pager.enable();
- });
- });
-
- this.pager.appendTo($node = $node || this.options.$pager);
-
- // Hide the pager in create mode
- if (this.get("actual_mode") === "create") {
- this.pager.do_hide();
- }
- }
- },
- /**
- * Show or hide the buttons according to the actual_mode
- */
- toggle_buttons: function() {
- var view_mode = this.get("actual_mode") === "view";
- if (this.$buttons) {
- this.$buttons.find('.o_form_buttons_view').toggle(view_mode);
- this.$buttons.find('.o_form_buttons_edit').toggle(!view_mode);
- }
- },
- /**
- * Show or hide the sidebar according to the actual_mode
- */
- toggle_sidebar: function() {
- if (this.sidebar) {
- this.sidebar.do_toggle(this.get("actual_mode") === "view");
- }
- },
- update_pager: function() {
- if (this.pager) {
- // Hide the pager in create mode
- if (this.get("actual_mode") === "create") {
- this.pager.do_hide();
- } else {
- this.pager.update_state({size: this.dataset.ids.length, current_min: this.dataset.index + 1});
- }
- }
- },
- widgetFocused: function() {
- // Clear click flag if used to focus a widget
- this.__clicked_inside = false;
- if (this.__blur_timeout) {
- clearTimeout(this.__blur_timeout);
- this.__blur_timeout = null;
- }
- },
- widgetBlurred: function() {
- if (this.__clicked_inside) {
- // clicked in an other section of the form (than the currently
- // focused widget) => just ignore the blurring entirely?
- this.__clicked_inside = false;
- return;
- }
- var self = this;
- // clear timeout, if any
- this.widgetFocused();
- this.__blur_timeout = setTimeout(function () {
- self.trigger('blurred');
- }, 0);
- },
-
- do_load_state: function(state, warm) {
- if (state.id && this.datarecord.id != state.id) {
- if (this.dataset.get_id_index(state.id) === null) {
- this.dataset.ids.push(state.id);
- }
- this.dataset.select_id(state.id);
- this.do_show();
- }
- },
- /**
- * @param {Boolean} [options.mode=undefined] If specified, switch the form to specified mode. Can be "edit" or "view".
- * @param {Boolean} [options.reload=true] whether the form should reload its content on show, or use the currently loaded record
- * @return {$.Deferred}
- */
- do_show: function (options) {
- var self = this;
- options = options || {};
- this.$el.removeClass('oe_form_dirty');
-
- var shown = this.has_been_loaded;
- if (options.reload !== false) {
- shown = shown.then(function() {
- if (self.dataset.index === null) {
- // null index means we should start a new record
- return self.on_button_new();
- }
- var fields = _.keys(self.fields_view.fields);
- fields.push('display_name');
- fields.push('__last_update');
- return self.dataset.read_index(fields, {
- context: { 'bin_size': true }
- }).then(function(r) {
- self.trigger('load_record', r);
- });
- });
- }
- return $.when(shown, this._super()).then(function() {
- self._actualize_mode(options.mode || self.options.initial_mode);
- core.bus.trigger('form_view_shown', self);
- });
- },
- load_record: function(record) {
- var self = this, set_values = [];
- if (!record) {
- this.set({ 'title' : undefined });
- this.do_warn(_t("Form"), _t("The record could not be found in the database."), true);
- return $.Deferred().reject();
- }
- this.datarecord = record;
-
- this.record_loaded = $.Deferred();
- _(this.fields).each(function (field, f) {
- field._dirty_flag = false;
- field._inhibit_on_change_flag = true;
- var result = field.set_value(self.datarecord[f] || false);
- field._inhibit_on_change_flag = false;
- set_values.push(result);
- });
- this._actualize_mode(); // call after updating the fields as it may trigger a re-rendering
- this.set({ 'title' : record.id ? record.display_name : _t("New") });
- this.update_pager(); // the mode must be actualized before updating the pager
- return $.when.apply(null, set_values).then(function() {
- if (!record.id) {
- // trigger onchange for new record after x2many with non-embedded views are loaded
- var fields_loaded = _.pluck(self.fields, 'is_loaded');
- $.when.apply(null, fields_loaded).done(function() {
- self.do_onchange(null);
- });
- }
- self.on_form_changed();
- self.rendering_engine.init_fields().then(function() {
- self.is_initialized.resolve();
- self.record_loaded.resolve();
- if (self.sidebar) {
- self.sidebar.do_attachement_update(self.dataset, self.datarecord.id);
- }
- if (record.id) {
- self.do_push_state({id:record.id});
- } else {
- self.do_push_state({});
- }
- self.$el.removeClass('oe_form_dirty');
- });
- });
- },
- /**
- * Loads and sets up the default values for the model as the current
- * record
- *
- * @return {$.Deferred}
- */
- load_defaults: function () {
- var self = this;
- var keys = _.keys(this.fields_view.fields);
- if (keys.length) {
- return this.dataset.default_get(keys).then(function(r) {
- self.trigger('load_record', _.clone(r));
- });
- }
- return $.when().then(this.trigger.bind(this, 'load_record', {}));
- },
- on_form_changed: function() {
- this.trigger("view_content_has_changed");
- },
- do_notify_change: function() {
- this.$el.addClass('oe_form_dirty');
- },
- _build_onchange_specs: function() {
- var self = this;
- var find = function(field_name, root) {
- var fields = [root];
- while (fields.length) {
- var node = fields.pop();
- if (!node) {
- continue;
- }
- if (node.tag === 'field' && node.attrs.name === field_name) {
- return node.attrs.on_change || "";
- }
- fields = _.union(fields, node.children);
- }
- return "";
- };
-
- self._onchange_fields = [];
- self._onchange_specs = {};
- _.each(this.fields, function(field, name) {
- self._onchange_fields.push(name);
- self._onchange_specs[name] = find(name, field.node);
-
- // we get the list of first-level fields of x2many firstly by
- // getting them from the field embedded views, then if no embedded
- // view is present for a loaded view, we get them from the default
- // view that has been loaded
-
- // gather embedded view objects
- var views = _.clone(field.field.views);
- // also gather default view objects
- if (field.viewmanager) {
- _.each(field.viewmanager.views, function(view, view_type) {
- // add default view if it was not embedded and it is loaded
- if (views[view_type] === undefined && view.controller) {
- views[view_type] = view.controller.fields_view;
- }
- });
- }
- _.each(views, function(view) {
- _.each(view.fields, function(_, subname) {
- self._onchange_specs[name + '.' + subname] = find(subname, view.arch);
- });
- });
- });
- },
- _get_onchange_values: function() {
- var field_values = this.get_fields_values();
- if (field_values.id.toString().match(data.BufferedDataSet.virtual_id_regex)) {
- delete field_values.id;
- }
- if (this.dataset.parent_view) {
- // this belongs to a parent view: add parent field if possible
- var parent_view = this.dataset.parent_view;
- var child_name = this.dataset.child_name;
- var parent_name = parent_view.get_field_desc(child_name).relation_field;
- if (parent_name) {
- // consider all fields except the inverse of the parent field
- var parent_values = parent_view.get_fields_values();
- delete parent_values[child_name];
- field_values[parent_name] = parent_values;
- }
- }
- return field_values;
- },
-
- do_onchange: function(widget) {
- var self = this;
- if (self._onchange_specs === undefined) {
- self._build_onchange_specs();
- }
- var onchange_specs = self._onchange_specs;
- try {
- var def = $.when({});
- var change_spec = widget ? onchange_specs[widget.name] : null;
- if (!widget || (!_.isEmpty(change_spec) && change_spec !== "0")) {
- var ids = [],
- trigger_field_name = widget ? widget.name : self._onchange_fields,
- values = self._get_onchange_values(),
- context = new data.CompoundContext(self.dataset.get_context());
-
- if (widget && widget.build_context()) {
- context.add(widget.build_context());
- }
- if (self.dataset.parent_view) {
- var parent_name = self.dataset.parent_view.get_field_desc(self.dataset.child_name).relation_field;
- context.add({field_parent: parent_name});
- }
-
- if (self.datarecord.id && !data.BufferedDataSet.virtual_id_regex.test(self.datarecord.id)) {
- // In case of a o2m virtual id, we should pass an empty ids list
- ids.push(self.datarecord.id);
- }
- def = self.alive(self.dataset.call(
- "onchange", [ids, values, trigger_field_name, onchange_specs, context]));
- }
- this.onchanges_mutex.exec(function(){
- return def.then(function(response) {
- var fields = {};
- if (widget){
- fields[widget.name] = widget.field;
- }
- else{
- fields = self.fields_view.fields;
- }
- var defs = [];
- _.each(fields, function(field, fieldname){
- if (field && field.change_default) {
- var value_;
- if (response.value && (fieldname in response.value)) {
- // Use value from onchange if onchange executed
- value_ = response.value[fieldname];
- } else {
- // otherwise get form value for field
- value_ = self.fields[fieldname].get_value();
- }
- var condition = fieldname + '=' + value_;
-
- if (value_) {
- defs.push(self.alive(new Model('ir.values').call(
- 'get_defaults', [self.model, condition]
- )).then(function (results) {
- if (!results.length) {
- return response;
- }
- if (!response.value) {
- response.value = {};
- }
- for(var i=0; i<results.length; ++i) {
- // [whatever, key, value]
- var triplet = results[i];
- response.value[triplet[1]] = triplet[2];
- }
- return response;
- }));
- }
- }
- });
- return _.isEmpty(defs) ? response : $.when.apply(null, defs);
- }).then(function(response) {
- return self.on_processed_onchange(response);
- });
- });
- return this.onchanges_mutex.def;
- } catch(e) {
- console.error(e);
- crash_manager.show_message(e);
- return $.Deferred().reject();
- }
- },
- on_processed_onchange: function(result) {
- try {
- var fields = this.fields;
- _(result.domain).each(function (domain, fieldname) {
- var field = fields[fieldname];
- if (!field) { return; }
- field.node.attrs.domain = domain;
- });
-
- var def = $.when(!_.isEmpty(result.value) && this._internal_set_values(result.value));
-
- // FIXME XXX a list of warnings?
- if (!_.isEmpty(result.warning)) {
- this.warning_displayed = true;
- var dialog = new Dialog(this, {
- size: 'medium',
- title:result.warning.title,
- $content: QWeb.render("CrashManager.warning", result.warning)
- });
- dialog.open();
- dialog.on('closed', this, function () {
- this.warning_displayed = false;
- });
- }
-
- return def;
- } catch(e) {
- console.error(e);
- crash_manager.show_message(e);
- return $.Deferred().reject();
- }
- },
- _process_operations: function() {
- var self = this;
- return this.mutating_mutex.exec(function() {
- function onchanges_mutex () {return self.onchanges_mutex.def;}
- function iterate() {
- var mutex = new utils.Mutex();
- mutex.exec(onchanges_mutex);
- _.each(self.fields, function(field) {
- mutex.exec(function(){
- return field.commit_value();
- });
- mutex.exec(onchanges_mutex);
- });
-
- return mutex.def.then(function() {
- var save_obj = self.save_list.pop();
- if (save_obj) {
- return self._process_save(save_obj).then(function() {
- save_obj.ret = _.toArray(arguments);
- return iterate();
- }, function() {
- save_obj.error = true;
- });
- }
- return $.when();
- }).fail(function() {
- self.save_list.pop();
- return $.when();
- });
- }
- return iterate();
- });
- },
- _internal_set_values: function(values) {
- for (var f in values) {
- if (!values.hasOwnProperty(f)) { continue; }
- var field = this.fields[f];
- // If field is not defined in the view, just ignore it
- if (field) {
- var value_ = values[f];
- if (field.get_value() !== value_) {
- field._inhibit_on_change_flag = true;
- field.set_value(value_);
- field._inhibit_on_change_flag = false;
- field._dirty_flag = true;
- }
- }
- }
- this.on_form_changed();
- },
- set_values: function(values) {
- var self = this;
- return this.mutating_mutex.exec(function() {
- self._internal_set_values(values);
- });
- },
- /**
- * Ask the view to switch to view mode if possible. The view may not do it
- * if the current record is not yet saved. It will then stay in create mode.
- */
- to_view_mode: function() {
- this._actualize_mode("view");
- this.trigger('to_view_mode');
- },
- /**
- * Ask the view to switch to edit mode if possible. The view may not do it
- * if the current record is not yet saved. It will then stay in create mode.
- */
- to_edit_mode: function() {
- this.onchanges_mutex = new utils.Mutex();
- this._actualize_mode("edit");
- this.trigger('to_edit_mode');
- },
- /**
- * Ask the view to switch to a precise mode if possible. The view is free to
- * not respect this command if the state of the dataset is not compatible with
- * the new mode. For example, it is not possible to switch to edit mode if
- * the current record is not yet saved in database.
- *
- * @param {string} [new_mode] Can be "edit", "view", "create" or undefined. If
- * undefined the view will test the actual mode to check if it is still consistent
- * with the dataset state.
- */
- _actualize_mode: function(switch_to) {
- var mode = switch_to || this.get("actual_mode");
- if (! this.datarecord.id) {
- mode = "create";
- } else if (mode === "create") {
- mode = "edit";
- }
-
- var viewMode = (mode === "view");
- this.$el.toggleClass('o_form_readonly', viewMode).toggleClass('o_form_editable', !viewMode);
-
- this.render_value_defs = [];
- this.set({actual_mode: mode});
-
- if(!viewMode) {
- this.autofocus();
- }
- },
- autofocus: function() {
- if (this.get("actual_mode") !== "view" && !this.options.disable_autofocus) {
- var fields_order = this.fields_order.slice(0);
- if (this.default_focus_field) {
- fields_order.unshift(this.default_focus_field.name);
- }
- for (var i = 0; i < fields_order.length; i += 1) {
- var field = this.fields[fields_order[i]];
- if (!field.get('effective_invisible') && !field.get('effective_readonly') && field.$label) {
- if (field.focus() !== false) {
- break;
- }
- }
- }
- }
- },
- disable_button: function () {
- this.$('.oe_form_buttons,.o_statusbar_buttons').add(this.$buttons).find('button').addClass('o_disabled').prop('disabled', true);
- this.is_disabled = true;
- },
- enable_button: function () {
- this.$('.oe_form_buttons,.o_statusbar_buttons').add(this.$buttons).find('button.o_disabled').removeClass('o_disabled').prop('disabled', false);
- this.is_disabled = false;
- },
- on_button_save: function() {
- var self = this;
- if (this.is_disabled) {
- return;
- }
- this.disable_button();
- return this.save().then(function(result) {
- self.trigger("save", result);
- return self.reload().then(function() {
- self.to_view_mode();
- core.bus.trigger('form_view_saved', self);
- }).always(function() {
- self.enable_button();
- });
- }).fail(function(){
- self.enable_button();
- });
- },
- on_button_cancel: function() {
- var self = this;
- this.can_be_discarded().then(function() {
- if (self.get('actual_mode') === 'create') {
- self.trigger('history_back');
- } else {
- self.to_view_mode();
- $.when.apply(null, self.render_value_defs).then(function(){
- self.trigger('load_record', self.datarecord);
- });
- }
- });
- this.trigger('on_button_cancel');
- return false;
- },
- on_button_new: function() {
- return $.when(this.has_been_loaded)
- .then(this.can_be_discarded.bind(this))
- .then(this.load_defaults.bind(this));
- },
- on_button_edit: function() {
- return this.to_edit_mode();
- },
- on_button_create: function() {
- this.dataset.index = null;
- this.do_show();
- },
- on_button_duplicate: function() {
- var self = this;
- return this.has_been_loaded.then(function() {
- return self.dataset.call('copy', [self.datarecord.id, {}, self.dataset.context]).then(function(new_id) {
- self.record_created(new_id);
- self.to_edit_mode();
- });
- });
- },
- on_button_delete: function() {
- var self = this;
- var def = $.Deferred();
- this.has_been_loaded.done(function() {
- if (self.datarecord.id && confirm(_t("Do you really want to delete this record?"))) {
- self.dataset.unlink([self.datarecord.id]).done(function() {
- if (self.dataset.size()) {
- self.reload();
- self.update_pager();
- } else {
- self.do_action('history_back');
- }
- def.resolve();
- });
- } else {
- utils.async_when().done(function () {
- def.reject();
- });
- }
- });
- return def.promise();
- },
- can_be_discarded: function(message) {
- if (!this.$el.is('.oe_form_dirty')) {
- return $.Deferred().resolve();
- }
-
- message = message || _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?");
-
- var self = this;
- var def = $.Deferred();
- var options = {
- title: _t("Warning"),
- confirm_callback: function() {
- self.$el.removeClass('oe_form_dirty');
- this.on('closed', null, function() { // 'this' is the dialog widget
- def.resolve();
- });
- },
- cancel_callback: function() {
- def.reject();
- },
- };
- var dialog = Dialog.confirm(this, message, options);
- dialog.$modal.on('hidden.bs.modal', function() {
- def.reject();
- });
- return def;
- },
- /**
- * Triggers saving the form's record. Chooses between creating a new
- * record or saving an existing one depending on whether the record
- * already has an id property.
- *
- * @param {Boolean} [prepend_on_create=false] if ``save`` creates a new
- * record, should that record be inserted at the start of the dataset (by
- * default, records are added at the end)
- */
- save: function(prepend_on_create) {
- var self = this;
- var save_obj = {prepend_on_create: prepend_on_create, ret: null};
- this.save_list.push(save_obj);
- return self._process_operations().then(function() {
- if (save_obj.error)
- return $.Deferred().reject();
- return $.when.apply($, save_obj.ret);
- }).done(function(result) {
- self.$el.removeClass('oe_form_dirty');
- });
- },
- _process_save: function(save_obj) {
- var self = this;
- var prepend_on_create = save_obj.prepend_on_create;
- var def_process_save = $.Deferred();
- try {
- var form_invalid = false,
- values = {},
- first_invalid_field = null,
- readonly_values = {},
- deferred = [];
-
- $.when.apply($, deferred).always(function () {
-
- _.each(self.fields, function (f) {
- if (!f.is_valid()) {
- form_invalid = true;
- if (!first_invalid_field) {
- first_invalid_field = f;
- }
- } else if (f.name !== 'id' && (!self.datarecord.id || f._dirty_flag)) {
- // Special case 'id' field, do not save this field
- // on 'create' : save all non readonly fields
- // on 'edit' : save non readonly modified fields
- if (!f.get("readonly")) {
- values[f.name] = f.get_value(true);
- } else {
- readonly_values[f.name] = f.get_value(true);
- }
- }
-
- });
-
- // Heuristic to assign a proper sequence number for new records that
- // are added in a dataset containing other lines with existing sequence numbers
- if (!self.datarecord.id && self.fields.sequence &&
- !_.has(values, 'sequence') && !_.isEmpty(self.dataset.cache)) {
- // Find current max or min sequence (editable top/bottom)
- var current = _[prepend_on_create ? "min" : "max"](
- _.map(self.dataset.cache, function(o){return o.values.sequence})
- );
- values['sequence'] = prepend_on_create ? current - 1 : current + 1;
- }
- if (form_invalid) {
- self.set({'display_invalid_fields': true});
- first_invalid_field.focus();
- self.on_invalid();
- def_process_save.reject();
- } else {
- self.set({'display_invalid_fields': false});
- var save_deferral;
- if (!self.datarecord.id) {
- // Creation save
- save_deferral = self.dataset.create(values, {readonly_fields: readonly_values}).then(function(r) {
- self.display_translation_alert(values);
- return self.record_created(r, prepend_on_create);
- }, null);
- } else if (_.isEmpty(values)) {
- // Not dirty, noop save
- save_deferral = $.Deferred().resolve({}).promise();
- } else {
- // Write save
- save_deferral = self.dataset.write(self.datarecord.id, values, {readonly_fields: readonly_values}).then(function(r) {
- self.display_translation_alert(values);
- return self.record_saved(r);
- }, null);
- }
- save_deferral.then(function(result) {
- def_process_save.resolve(result);
- }).fail(function() {
- def_process_save.reject();
- });
- }
- });
- } catch (e) {
- console.error(e);
- return def_process_save.reject();
- }
- return def_process_save;
- },
- on_invalid: function() {
- var warnings = _(this.fields).chain()
- .filter(function (f) { return !f.is_valid(); })
- .map(function (f) {
- return _.str.sprintf('<li>%s</li>',
- _.escape(f.string));
- }).value();
- warnings.unshift('<ul>');
- warnings.push('</ul>');
- this.do_warn(_t("The following fields are invalid:"), warnings.join(''));
- },
- /**
- * Reload the form after saving
- *
- * @param {Object} r result of the write function.
- */
- record_saved: function(r) {
- this.trigger('record_saved', r);
- if (!r) {
- // should not happen in the server, but may happen for internal purpose
- return $.Deferred().reject();
- }
- return r;
- },
- /**
- * Updates the form' dataset to contain the new record:
- *
- * * Adds the newly created record to the current dataset (at the end by
- * default)
- * * Selects that record (sets the dataset's index to point to the new
- * record's id).
- * * Updates the pager and sidebar displays
- *
- * @param {Object} r
- * @param {Boolean} [prepend_on_create=false] adds the newly created record
- * at the beginning of the dataset instead of the end
- */
- record_created: function(r, prepend_on_create) {
- var self = this;
- if (!r) {
- // should not happen in the server, but may happen for internal purpose
- this.trigger('record_created', r);
- return $.Deferred().reject();
- } else {
- this.datarecord.id = r;
- if (!prepend_on_create) {
- this.dataset.alter_ids(this.dataset.ids.concat([this.datarecord.id]));
- this.dataset.index = this.dataset.ids.length - 1;
- } else {
- this.dataset.alter_ids([this.datarecord.id].concat(this.dataset.ids));
- this.dataset.index = 0;
- }
- this.update_pager();
- if (this.sidebar) {
- this.sidebar.do_attachement_update(this.dataset, this.datarecord.id);
- }
- //openerp.log("The record has been created with id #" + this.datarecord.id);
- return $.when(this.reload()).then(function () {
- self.trigger('record_created', r);
- return _.extend(r, {created: true});
- });
- }
- },
- on_action: function (action) {
- console.debug('Executing action', action);
- },
- reload: function() {
- var self = this;
- return this.reload_mutex.exec(function() {
- if (self.dataset.index === null || self.dataset.index === undefined) {
- self.trigger("previous_view");
- return $.Deferred().reject().promise();
- }
- if (self.dataset.index < 0) {
- return $.when(self.on_button_new());
- } else {
- var fields = _.keys(self.fields_view.fields);
- fields.push('display_name');
- fields.push('__last_update');
- return self.dataset.read_index(fields,
- {
- context: { 'bin_size': true },
- check_access_rule: true
- }).then(function(r) {
- self.trigger('load_record', r);
- }).fail(function (){
- self.do_action('history_back');
- });
- }
- });
- },
- get_widgets: function() {
- return _.filter(this.getChildren(), function(obj) {
- return obj instanceof common.FormWidget;
- });
- },
- get_fields_values: function() {
- var values = {};
- var ids = this.get_selected_ids();
- values["id"] = ids.length > 0 ? ids[0] : false;
- _.each(this.fields, function(value_, key) {
- values[key] = value_.get_value();
- });
- return values;
- },
- get_selected_ids: function() {
- var id = this.dataset.ids[this.dataset.index];
- return id ? [id] : [];
- },
- recursive_save: function() {
- var self = this;
- return $.when(this.save()).then(function(res) {
- if (self.dataset.parent_view)
- return self.dataset.parent_view.recursive_save();
- });
- },
- recursive_reload: function() {
- var self = this;
- var pre = $.when();
- if (self.dataset.parent_view)
- pre = self.dataset.parent_view.recursive_reload();
- return pre.then(function() {
- return self.reload();
- });
- },
- is_dirty: function() {
- return _.any(this.fields, function (value_) {
- return value_._dirty_flag;
- });
- },
- is_interactible_record: function() {
- var id = this.datarecord.id;
- if (!id) {
- if (this.options.not_interactible_on_create)
- return false;
- } else if (typeof(id) === "string") {
- if(data.BufferedDataSet.virtual_id_regex.test(id))
- return false;
- }
- return true;
- },
- sidebar_eval_context: function () {
- return $.when(this.build_eval_context());
- },
- /*
- * 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.
- */
- display_translation_alert: function(values) {
- if (_t.database.multi_lang) {
- var alert_fields = _.filter(this.translatable_fields, function(field) {
- return _.has(values, field.name)
- });
- if (alert_fields.length) {
- var $content = $(QWeb.render('translation-alert', {
- fields: alert_fields,
- lang: _t.database.parameters.name
- }));
- // bind click event on "Update translations" links
- $content.find('.oe_field_translate').click(function(ev) {
- ev.preventDefault();
- _.find(alert_fields, {'name': ev.target.name}).on_translate();
- });
- // show notification
- this.once('to_view_mode', this, function () {
- this.notify_in_form($content);
- });
- }
- }
- },
- /**
- * Add a notification box inside the form. The notification automatically
- * goes away (by default when switching to edit mode or changing the form
- * content.)
- *
- * @param {jQuery} $content
- * @param {Object} [options]
- * @param {String} [options.type], one of: "success", "info", "warning", "danger"
- * @param {String} [options.events], when to remove notification
- */
- notify_in_form: function ($content, options) {
- options = options || {};
- var type = options.type || 'info';
- var $box = $(QWeb.render('notification-box', {type: type}));
- $box.append($content);
- // create handler to remove notification box
- var events = options.events || 'to_edit_mode view_content_has_changed';
- this.once(events, null, function() {
- $box.remove();
- });
- // add content inside notification box on top of the sheet/form
- var $target = this.$('.o_form_sheet_bg').length ? this.$('.o_form_sheet_bg') : this.$el;
- $target.prepend($box);
- },
- open_defaults_dialog: function () {
- var self = this;
- var display = function (field, value) {
- var FieldSelection = core.form_widget_registry.get('selection');
- var FieldMany2One = core.form_widget_registry.get('many2one');
- if (!value) { return value; }
- if (field instanceof FieldSelection) {
- return _(field.get('values')).find(function (option) {
- return option[0] === value;
- })[1];
- } else if (field instanceof FieldMany2One) {
- return field.get_displayed();
- }
- return value;
- };
- var fields = _.chain(this.fields)
- .map(function (field) {
- var value = field.get_value();
- // ignore fields which are empty, invisible, readonly, o2m
- // or m2m
- if (!value
- || field.get('invisible')
- || field.get("readonly")
- || field.field.type === 'one2many'
- || field.field.type === 'many2many'
- || field.field.type === 'binary'
- || field.password) {
- return false;
- }
-
- return {
- name: field.name,
- string: field.string,
- value: value,
- displayed: display(field, value),
- };
- })
- .compact()
- .sortBy(function (field) { return field.string; })
- .value();
- var conditions = _.chain(self.fields)
- .filter(function (field) { return field.field.change_default; })
- .map(function (field) {
- var value = field.get_value();
- return {
- name: field.name,
- string: field.string,
- value: value,
- displayed: display(field, value),
- };
- })
- .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 field_to_set = $defaults.val();
- if (!field_to_set) {
- $defaults.parent().addClass('o_form_invalid');
- return;
- }
- var condition = d.$el.find('#formview_default_conditions').val(),
- all_users = d.$el.find('#formview_default_all').is(':checked');
- new data.DataSet(self, 'ir.values').call(
- 'set_default', [
- self.model,
- field_to_set,
- self.fields[field_to_set].get_value(),
- all_users,
- true,
- condition || false
- ]).done(function () { d.close(); });
- }}
- ]
- });
- d.args = {
- fields: fields,
- conditions: conditions
- };
- d.template = 'FormView.set_default';
- d.open();
- },
- register_field: function(field, name) {
- this.fields[name] = field;
- this.fields_order.push(name);
- if (JSON.parse(field.node.attrs.default_focus || "0")) {
- this.default_focus_field = field;
- }
-
- field.on('focused', null, this.proxy('widgetFocused'))
- .on('blurred', null, this.proxy('widgetBlurred'));
- if (this.get_field_desc(name).translate) {
- this.translatable_fields.push(field);
- }
- field.on('changed_value', this, function() {
- if (field.is_syntax_valid()) {
- this.trigger('field_changed:' + name);
- }
- if (field._inhibit_on_change_flag) {
- return;
- }
- field._dirty_flag = true;
- if (field.is_syntax_valid()) {
- this.do_onchange(field);
- this.on_form_changed(true);
- this.do_notify_change();
- }
- });
- },
- get_field_desc: function(field_name) {
- return this.fields_view.fields[field_name];
- },
- get_field_value: function(field_name) {
- return this.fields[field_name].get_value();
- },
- compute_domain: function(expression) {
- var fields = this.fields;
- if (!fields.id) {
- fields = _.extend({id: {value: this.datarecord.id || false}}, fields);
- }
- return data.compute_domain(expression, fields);
- },
- _build_view_fields_values: function() {
- var a_dataset = this.dataset;
- var fields_values = this.get_fields_values();
- var active_id = a_dataset.ids[a_dataset.index];
- _.extend(fields_values, {
- active_id: active_id || false,
- active_ids: active_id ? [active_id] : [],
- active_model: a_dataset.model,
- parent: {}
- });
- if (a_dataset.parent_view) {
- fields_values.parent = a_dataset.parent_view.get_fields_values();
- }
- return fields_values;
- },
- build_eval_context: function() {
- var a_dataset = this.dataset;
- return new data.CompoundContext(a_dataset.get_context(), this._build_view_fields_values());
- },
-});
-
-core.view_registry.add('form', FormView);
-
-return FormView;
-
-});
-
-
-odoo.define('web.FormRenderingEngine', function (require) {
-"use strict";
-
-var common = require('web.form_common');
-var core = require('web.core');
-var utils = require('web.utils');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-/**
- * Default rendering engine for the form view.
- *
- * It is necessary to set the view using set_view() before usage.
- */
-return core.Class.extend({
- init: function(view) {
- this.view = view;
- },
- set_fields_view: function(fvg) {
- this.fvg = fvg;
- this.version = parseFloat(this.fvg.arch.attrs.version);
- if (isNaN(this.version)) {
- this.version = 7.0;
- }
- },
- set_tags_registry: function(tags_registry) {
- this.tags_registry = tags_registry;
- },
- set_fields_registry: function(fields_registry) {
- this.fields_registry = fields_registry;
- },
- set_widgets_registry: function(widgets_registry) {
- this.widgets_registry = widgets_registry;
- },
- // Backward compatibility tools, current default version: v7
- process_version: function() {
- if (this.version < 7.0) {
- this.$form.find('form:first').wrapInner('<group col="4"/>');
- this.$form.find('page').each(function() {
- if (!$(this).parents('field').length) {
- $(this).wrapInner('<group col="4"/>');
- }
- });
- }
- },
- get_arch_fragment: function() {
- var doc = $.parseXML(utils.json_node_to_xml(this.fvg.arch)).documentElement;
- // IE won't allow custom button@type and will revert it to spec default : 'submit'
- $('button', doc).each(function() {
- $(this).attr('data-button-type', $(this).attr('type')).attr('type', 'button');
- });
- // IE's html parser is also a css parser. How convenient...
- $('board', doc).each(function() {
- $(this).attr('layout', $(this).attr('style'));
- });
- return $(utils.xml_to_str(doc));
- },
- render_to: function($target) {
- var self = this;
- this.$target = $target;
-
- this.$form = this.get_arch_fragment();
-
- this.process_version();
-
- this.fields_to_init = [];
- this.tags_to_init = [];
- this.widgets_to_init = [];
- this.labels = {};
- this.process(this.$form);
-
- this.$form.contents().appendTo(this.$target);
- this.handle_common_properties(this.$target, this.$form);
-
- this.to_replace = [];
-
- _.each(this.fields_to_init, function($elem) {
- var name = $elem.attr("name");
- if (!self.fvg.fields[name]) {
- throw new Error(_.str.sprintf(_t("Field '%s' specified in view could not be found."), name));
- }
- var obj = self.fields_registry.get_any([$elem.attr('widget'), self.fvg.fields[name].type]);
- if (!obj) {
- throw new Error(_.str.sprintf(_t("Widget type '%s' is not implemented"), $elem.attr('widget') || self.fvg.fields[name].type ));
- }
- var w = new (obj)(self.view, utils.xml_to_json($elem[0]));
- var $label = self.labels[$elem.attr("name")];
- if ($label) {
- w.set_input_id($label.attr("for"));
- }
- self.alter_field(w);
- self.view.register_field(w, $elem.attr("name"));
- self.to_replace.push([w, $elem]);
- });
- _.each(this.tags_to_init, function($elem) {
- var tag_name = $elem[0].tagName.toLowerCase();
- var obj = self.tags_registry.get(tag_name);
- var w = new (obj)(self.view, utils.xml_to_json($elem[0]));
- self.to_replace.push([w, $elem]);
- });
- _.each(this.widgets_to_init, function($elem) {
- var widget_type = $elem.attr("type");
- var obj = self.widgets_registry.get(widget_type);
- var w = new (obj)(self.view, utils.xml_to_json($elem[0]));
- self.to_replace.push([w, $elem]);
- });
- },
- init_fields: function() {
- var defs = [];
- _.each(this.to_replace, function(el) {
- defs.push(el[0].replace(el[1]));
- if (el[1].children().length) {
- el[0].$el.append(el[1].children());
- }
- });
- this.to_replace = [];
- return $.when.apply($, defs);
- },
- render_element: function(template /* dictionaries */) {
- var dicts = [].slice.call(arguments).slice(1);
- var dict = _.extend.apply(_, dicts);
- dict['classnames'] = dict['class'] || ''; // class is a reserved word and might caused problem to Safari when used from QWeb
- return $(QWeb.render(template, dict));
- },
- alter_field: function(field) {
- },
- toggle_layout_debugging: function() {
- if (!this.$target.has('.oe_layout_debug_cell:first').length) {
- this.$target.find('[title]').removeAttr('title');
- this.$target.find('.oe_form_group_cell').each(function() {
- var text = 'W:' + ($(this).attr('width') || '') + ' - C:' + $(this).attr('colspan');
- $(this).attr('title', text);
- });
- }
- this.$target.toggleClass('oe_layout_debugging');
- },
- process: function($tag) {
- var self = this;
- var tagname = $tag[0].nodeName.toLowerCase();
- if (this.tags_registry.contains(tagname)) {
- this.tags_to_init.push($tag);
- return (tagname === 'button') ? this.process_button($tag) : $tag;
- }
- var fn = self['process_' + tagname];
- if (fn) {
- var args = [].slice.call(arguments);
- args[0] = $tag;
- return fn.apply(self, args);
- } else {
- // generic tag handling, just process children
- $tag.children().each(function() {
- self.process($(this));
- });
- self.handle_common_properties($tag, $tag);
- $tag.removeAttr("modifiers");
- return $tag;
- }
- },
- process_button: function ($button) {
- var self = this;
- $button.children().each(function() {
- self.process($(this));
- });
- return $button;
- },
- process_widget: function($widget) {
- this.widgets_to_init.push($widget);
- return $widget;
- },
- process_sheet: function($sheet) {
- var $new_sheet = this.render_element('FormRenderingSheet', $sheet.getAttributes());
- this.handle_common_properties($new_sheet, $sheet);
- var $dst = $new_sheet.find('.o_form_sheet');
- $sheet.contents().appendTo($dst);
- $sheet.before($new_sheet).remove();
- this.process($new_sheet);
- },
- process_form: function($form) {
- if ($form.find('> sheet').length === 0) {
- $form.addClass('o_form_nosheet');
- }
- var $new_form = this.render_element('FormRenderingForm', $form.getAttributes());
- this.handle_common_properties($new_form, $form);
- $form.contents().appendTo($new_form);
- if ($form[0] === this.$form[0]) {
- // If root element, replace it
- this.$form = $new_form;
- } else {
- $form.before($new_form).remove();
- }
- this.process($new_form);
- },
- /*
- * Used by direct <field> children of a <group> tag only
- * This method will add the implicit <label...> for every field
- * in the <group>
- */
- preprocess_field: function($field) {
- var self = this;
- var name = $field.attr('name'),
- field_colspan = parseInt($field.attr('colspan'), 10),
- field_modifiers = JSON.parse($field.attr('modifiers') || '{}');
-
- if ($field.attr('nolabel') === '1')
- return;
- $field.attr('nolabel', '1');
- var found = false;
- this.$form.find('label[for="' + name + '"]').each(function(i ,el) {
- $(el).parents().each(function(unused, tag) {
- var name = tag.tagName.toLowerCase();
- if (name === "field" || name in self.tags_registry.map)
- found = true;
- });
- });
- if (found)
- return;
-
- var $label = $('<label/>').attr({
- 'for' : name,
- "modifiers": JSON.stringify({invisible: field_modifiers.invisible}),
- "string": $field.attr('string'),
- "help": $field.attr('help'),
- "class": $field.attr('class'),
- });
- $label.insertBefore($field);
- if (field_colspan > 1) {
- $field.attr('colspan', field_colspan - 1);
- }
- return $label;
- },
- process_field: function($field) {
- if ($field.parent().is('group')) {
- // No implicit labels for normal fields, only for <group> direct children
- var $label = this.preprocess_field($field);
- if ($label) {
- this.process($label);
- }
- }
- this.fields_to_init.push($field);
- return $field;
- },
- process_group: function($group) {
- var self = this;
-
- // Processes a group containing nested groups
- // Renders a div for each nested group, into a div that is the outer group
- // The nested groups's width is a percentage according to col and colspan attributes
- var _process_outer_group = function(col) {
- var $new_group = self.render_element('FormRenderingOuterGroup', $group.getAttributes());
- $group.children().each(function() {
- var $child = $(this);
- var tagName = $child[0].tagName.toLowerCase();
- var colspan = parseInt($child.attr('colspan') || 1, 10);
-
- if (tagName === 'newline') {
- $new_group.append($('<br>')); // Skip to the next line
- return;
- }
- // compute child's classname from col and colspan attributes
- $child.addClass('o_group_col_' + Math.min(Math.floor(colspan/col*12), 12));
- $new_group.append($child);
-
- children.push($child[0]);
- });
- return $new_group;
- };
- // Processes a group containing no nested group
- // Renders an HTML table
- var _process_inner_group = function(col) {
- var $new_group = self.render_element('FormRenderingInnerGroup', $group.getAttributes());
- $new_group.find('td').attr('colspan', col); // Group's title spans all column
- var row_col = col;
- var $row = null;
-
- $group.children().each(function() {
- var $child = $(this);
- var tagName = $child[0].tagName.toLowerCase();
- var colspan = parseInt($child.attr('colspan') || 1, 10);
-
- if (tagName === 'newline') {
- $row = null; // Start with a new row
- return;
- }
- row_col -= colspan;
- if (!$row || row_col < 0) { // Append a new row to the group
- $row = $('<tr>').appendTo($new_group);
- row_col = col-colspan;
- }
- $('<td>').attr('colspan', colspan).append($child).appendTo($row);
-
- children.push($child[0]);
- });
-
- // Some kind of an hack
- // Add width on table's cell to avoid automatic layout of table which
- // makes first col to take full width available (typically, first col contains label)
- $new_group.find('tr').each(function() {
- var $tr = $(this);
- var nonlabel_width = 100/(col - $tr.find('td > label:first-child').length);
- $tr.find('td').each(function() {
- var $td = $(this);
- var $child = $td.children(':first');
- if ($child.is("label")) {
- $td.addClass("o_td_label");
- } else {
- $td.css("width", (nonlabel_width*parseInt($td.attr('colspan')))+"%");
- }
- });
-
- });
- return $new_group;
- };
-
- // Preprocess field children
- $group.children('field').each(function() {
- self.preprocess_field($(this));
- });
-
- // Process group
- var nested_groups = $group.children('group').length; // Detect possible nested groups
- var children = [];
- var col = parseInt($group.attr('col') || 2, 10);
- var $new_group;
- if (nested_groups) {
- $new_group = _process_outer_group(col);
- } else {
- $new_group = _process_inner_group(col);
- }
- this.handle_common_properties($new_group, $group);
-
- // Process group's children
- $group.before($new_group).remove();
- _.each(children, function(el) {
- self.process($(el));
- });
-
- return $new_group;
- },
- process_notebook: function($notebook) {
- var self = this;
-
- // Extract useful info from the notebook xml declaration
- var pages = [];
- $notebook.find('> page').each(function() {
- var $page = $(this);
- var page_attrs = $page.getAttributes();
- page_attrs.id = _.uniqueId('notebook_page_');
- page_attrs.contents = $page.html();
- page_attrs.ref = $page; // reference to the current page node in the xml declaration
- pages.push(page_attrs);
- });
-
- // Render the notebook and replace $notebook with it
- var $new_notebook = self.render_element('FormRenderingNotebook', {'pages': pages});
- $notebook.before($new_notebook).remove();
-
- // Bind the invisibility changers and find the page to display
- var pageid_to_display;
- _.each(pages, function(page) {
- var $tab = $new_notebook.find('a[href=#' + page.id + ']').parent();
- var $content = $new_notebook.find('#' + page.id);
-
- // Case: <page autofocus="autofocus">;
- // We attach the autofocus attribute to the node because it can be useful during the
- // page execution
- self.attach_node_attr($tab, page.ref, 'autofocus');
- self.attach_node_attr($content, page.ref, 'autofocus');
- if (!pageid_to_display && page.autofocus) {
- // If multiple autofocus, keep the first one
- pageid_to_display = page.id;
- }
-
- // Case: <page attrs="{'invisible': domain}">;
- self.handle_common_properties($tab, page.ref, common.NotebookInvisibilityChanger);
- self.handle_common_properties($content, page.ref, common.NotebookInvisibilityChanger);
- });
- if (!pageid_to_display) {
- pageid_to_display = $new_notebook.find('div[role="tabpanel"]:not(.o_form_invisible):first').attr('id');
- }
-
- // Display page. Note: we can't use bootstrap's show function because it is looking for
- // document attached DOM, and the form view is only attached when everything is processed
- $new_notebook.find('a[href=#' + pageid_to_display + ']').parent().addClass('active');
- $new_notebook.find('#' + pageid_to_display).addClass('active');
-
- self.process($new_notebook.children());
- self.handle_common_properties($new_notebook, $notebook);
-
- return $new_notebook;
- },
- process_separator: function($separator) {
- var $new_separator = this.render_element('FormRenderingSeparator', $separator.getAttributes());
- $separator.before($new_separator).remove();
- this.handle_common_properties($new_separator, $separator);
- return $new_separator;
- },
- process_label: function($label) {
- var name = $label.attr("for"),
- field_orm = this.fvg.fields[name];
- var dict = {
- string: $label.attr('string') || (field_orm || {}).string || '',
- help: $label.attr('help') || (field_orm || {}).help || '',
- _for: name ? _.uniqueId('o_field_input_') : undefined,
- };
- var $new_label = this.render_element('FormRenderingLabel', dict);
- $label.before($new_label).remove();
- this.handle_common_properties($new_label, $label);
- if (name) {
- this.labels[name] = $new_label;
- }
- return $new_label;
- },
- handle_common_properties: function($new_element, $node, InvisibilityChanger) {
- var str_modifiers = $node.attr("modifiers") || "{}";
- var modifiers = JSON.parse(str_modifiers);
- var ic = null;
- if (modifiers.invisible !== undefined) {
- var InvisibilityChangerCls = InvisibilityChanger || common.InvisibilityChanger;
- ic = new InvisibilityChangerCls(this.view, this.view, modifiers.invisible, $new_element);
- }
- $new_element.addClass($node.attr("class") || "");
- $new_element.attr('style', $node.attr('style'));
- return {invisibility_changer: ic,};
- },
- /**
- * Attach a node attribute to an html element to be able to use it after
- * the processing of the page.
- */
- attach_node_attr: function($new_element, $node, attr) {
- $new_element.data(attr, $node.attr(attr));
- }
-});
-
-});
diff --git a/addons/web/static/src/js/views/form_widgets.js b/addons/web/static/src/js/views/form_widgets.js
deleted file mode 100644
index b644a79ba22..00000000000
--- a/addons/web/static/src/js/views/form_widgets.js
+++ /dev/null
@@ -1,1803 +0,0 @@
-odoo.define('web.form_widgets', function (require) {
-"use strict";
-
-var ajax = require('web.ajax');
-var core = require('web.core');
-var crash_manager = require('web.crash_manager');
-var data = require('web.data');
-var datepicker = require('web.datepicker');
-var dom_utils = require('web.dom_utils');
-var Priority = require('web.Priority');
-var ProgressBar = require('web.ProgressBar');
-var Dialog = require('web.Dialog');
-var DomainSelector = require("web.DomainSelector");
-var DomainSelectorDialog = require("web.DomainSelectorDialog");
-var common = require('web.form_common');
-var formats = require('web.formats');
-var framework = require('web.framework');
-var Model = require('web.DataModel');
-var pyeval = require('web.pyeval');
-var session = require('web.session');
-var utils = require('web.utils');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-var WidgetButton = common.FormWidget.extend({
- template: 'WidgetButton',
- init: function(field_manager, node) {
- node.attrs.type = node.attrs['data-button-type'];
- this._super(field_manager, node);
- this.force_disabled = false;
- this.string = (this.node.attrs.string || '').replace(/_/g, '');
- if (JSON.parse(this.node.attrs.default_focus || "0")) {
- // TODO fme: provide enter key binding to widgets
- this.view.default_focus_button = this;
- }
- if (this.node.attrs.icon) {
- this.fa_icon = this.node.attrs.icon.indexOf('fa-') === 0;
- }
- },
- start: function() {
- this._super.apply(this, arguments);
- this.view.on('view_content_has_changed', this, this.check_disable);
- this.check_disable();
- this.$el.click(this.on_click);
- if (this.node.attrs.help || session.debug) {
- this.do_attach_tooltip();
- }
- this.setupFocus(this.$el);
- },
- on_click: function() {
- var self = this;
- if (this.view.is_disabled) {
- return;
- }
- this.force_disabled = true;
- this.check_disable();
- this.view.disable_button();
- this.execute_action().always(function() {
- self.view.enable_button();
- self.force_disabled = false;
- self.check_disable();
- if (self.$el.hasClass('o_wow')) {
- self.show_wow();
- }
- });
- },
- execute_action: function() {
- var self = this;
- var exec_action = function() {
- if (self.node.attrs.confirm) {
- var def = $.Deferred();
- Dialog.confirm(self, self.node.attrs.confirm, { confirm_callback: self.on_confirmed })
- .on("closed", null, function() { def.resolve(); });
- return def.promise();
- } else {
- return self.on_confirmed();
- }
- };
- if (!this.node.attrs.special) {
- return this.view.recursive_save().then(exec_action);
- } else {
- return exec_action();
- }
- },
- on_confirmed: function() {
- var self = this;
- var context = this.build_context();
- return this.view.do_execute_action(
- _.extend({}, this.node.attrs, {context: context}),
- this.view.dataset, this.view.datarecord.id, function (reason) {
- if (!_.isObject(reason)) {
- self.view.recursive_reload();
- }
- }).fail(function () {
- self.view.recursive_reload();
- });
- },
- check_disable: function() {
- var disabled = (this.force_disabled || !this.view.is_interactible_record());
- this.$el.prop('disabled', disabled);
- this.$el.css('color', disabled ? 'grey' : '');
- },
- show_wow: function() {
- var class_to_add = 'o_wow_thumbs';
- if (Math.random() > 0.9) {
- var other_classes = ['o_wow_peace', 'o_wow_heart'];
- class_to_add = other_classes[Math.floor(Math.random()*other_classes.length)];
- }
-
- var $body = $('body');
- $body.addClass(class_to_add);
- setTimeout(function() {
- $body.removeClass(class_to_add);
- }, 1000);
- }
-});
-
-var FieldChar = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- template: 'FieldChar',
- events: {
- 'change': 'store_dom_value',
- },
- init: function (field_manager, node) {
- this._super(field_manager, node);
- this.password = this.node.attrs.password === 'True' || this.node.attrs.password === '1';
- },
- initialize_content: function() {
- if(!this.get('effective_readonly') && !this.$input) {
- this.$input = this.$el;
- }
- this.setupFocus(this.$el);
- },
- destroy_content: function() {
- this.$input = undefined;
- },
- store_dom_value: function () {
- if (this.$input && this.is_syntax_valid()) {
- this.internal_set_value(this.parse_value(this.$input.val()));
- }
- },
- commit_value: function () {
- this.store_dom_value();
- return this._super();
- },
- render_value: function() {
- var show_value = this.format_value(this.get('value'), '');
- if (this.$input) {
- this.$input.val(show_value);
- } else {
- if (this.password) {
- show_value = new Array(show_value.length + 1).join('*');
- }
- this.$el.text(show_value);
- }
- },
- is_syntax_valid: function() {
- if (this.$input) {
- try {
- this.parse_value(this.$input.val(), '');
- } catch(e) {
- return false;
- }
- }
- return true;
- },
- parse_value: function(val, def) {
- return formats.parse_value(val, this, def);
- },
- format_value: function(val, def) {
- return formats.format_value(val, this, def);
- },
- is_false: function() {
- return this.get('value') === '' || this._super();
- },
- focus: function() {
- if (this.$input) {
- return this.$input.focus();
- }
- return false;
- },
-});
-
-var KanbanSelection = common.AbstractField.extend({
- template: "FormSelection",
- events: {
- 'click a': function(e) {
- e.preventDefault();
- },
- 'mouseup a': function(e) {
- e.stopPropagation();
- },
- 'click li': 'set_kanban_selection'
- },
- start: function () {
- // hook on form view content changed: recompute the states, because it may be related to the current stage
- this.view.on('view_content_has_changed', this, function () {
- this.render_value();
- });
- return this._super();
- },
- prepare_dropdown_selection: function() {
- var self = this;
- var _data = [];
- var current_stage_id = self.view.datarecord.stage_id && self.view.datarecord.stage_id[0];
- var stage_data = {
- id: current_stage_id,
- legend_normal: self.view.datarecord.legend_normal || undefined,
- legend_blocked : self.view.datarecord.legend_blocked || undefined,
- legend_done: self.view.datarecord.legend_done || undefined,
- };
- _.map(self.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 = 'oe_kanban_status_green';
- value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1];
- } else {
- value.state_class = 'oe_kanban_status_red';
- value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1];
- }
- _data.push(value);
- });
- return _data;
- },
- render_value: function() {
- this._super();
- this.states = this.prepare_dropdown_selection();
- var self = this;
-
- // Adapt "FormSelection"
- var current_state = _.find(this.states, function(state) {
- return state.name === self.get('value');
- });
- this.$('.oe_kanban_status')
- .removeClass('oe_kanban_status_red oe_kanban_status_green')
- .addClass(current_state.state_class);
-
- // Render "FormSelection.Items" and move it into "FormSelection"
- var $items = $(QWeb.render('FormSelection.items', {
- states: _.without(this.states, current_state)
- }));
- var $dropdown = this.$el.find('.dropdown-menu');
- $dropdown.children().remove(); // remove old items
- $items.appendTo($dropdown);
- },
- /* setting the value: in view mode, perform an asynchronous call and reload
- the form view; in edit mode, use set_value to save the new value that will
- be written when saving the record. */
- set_kanban_selection: function (ev) {
- var self = this;
- ev.preventDefault();
- var li = $(ev.target).closest('li');
- if (li.length) {
- var value = String(li.data('value'));
- if (this.view.get('actual_mode') === 'view') {
- var write_values = {};
- write_values[self.name] = value;
- return this.view.dataset._model.call(
- 'write', [
- [this.view.datarecord.id],
- write_values,
- self.view.dataset.get_context()
- ]).done(self.reload_record.bind(self));
- }
- else {
- return this.set_value(value);
- }
- }
- },
- reload_record: function() {
- this.view.reload();
- },
-});
-
-var FieldPriority = common.AbstractField.extend({
- events: {
- 'mouseup': function(e) {
- e.stopPropagation();
- },
- },
- start: function() {
- this.priority = new Priority(this, {
- readonly: this.get('readonly'),
- value: this.get('value'),
- values: this.field.selection || [],
- });
-
- this.priority.on('update', this, function(update) {
- /* setting the value: in view mode, perform an asynchronous call and reload
- the form view; in edit mode, use set_value to save the new value that will
- be written when saving the record. */
- var view = this.view;
- if(view.get('actual_mode') === 'view') {
- var write_values = {};
- write_values[this.name] = update.value;
- view.dataset._model.call('write', [
- [view.datarecord.id],
- write_values,
- view.dataset.get_context()
- ]).done(function() {
- view.reload();
- });
- } else {
- this.set_value(update.value);
- }
- });
-
- this.on('change:readonly', this, function() {
- this.priority.readonly = this.get('readonly');
- var $div = $('<div/>').insertAfter(this.$el);
- this.priority.replace($div);
- this.setElement(this.priority.$el);
- });
-
- var self = this;
- return $.when(this._super(), this.priority.appendTo('<div>').then(function() {
- self.priority.$el.addClass(self.$el.attr('class'));
- self.replaceElement(self.priority.$el);
- }));
- },
- is_false: function() {
- return false;
- },
- render_value: function() {
- this.priority.set_value(this.get('value'));
- },
-});
-
-var FieldID = FieldChar.extend({
- process_modifiers: function () {
- this._super();
- this.set({ readonly: true });
- },
-});
-
-var FieldEmail = FieldChar.extend({
- template: 'FieldEmail',
- prefix: 'mailto',
- init: function() {
- this._super.apply(this, arguments);
- this.clickable = true;
- },
- render_value: function() {
- this._super();
- if (this.get("effective_readonly") && this.clickable) {
- this.$el.attr('href', this.prefix + ':' + this.get('value'));
- }
- }
-});
-
-var FieldUrl = FieldEmail.extend({
- render_value: function() {
- this._super();
- if(this.get("effective_readonly")) {
- var tmp = this.get('value');
- var s = /(\w+):(.+)|^\.{0,2}\//.exec(tmp);
- if (!s) {
- tmp = "http://" + this.get('value');
- }
- var text = this.get('value') ? this.node.attrs.text || tmp : '';
- this.$el.attr('href', tmp).text(text);
- }
- }
-});
-
-var FieldFloat = FieldChar.extend({
- init: function (field_manager, node) {
- this._super(field_manager, node);
- this.internal_set_value(0);
- if (this.node.attrs.digits) {
- this.digits = this.node.attrs.digits;
- } else {
- this.digits = this.field.digits;
- }
- },
- initialize_content: function() {
- this._super();
- this.$el.addClass('o_form_field_number');
- },
- set_value: function(value_) {
- if (value_ === false || value_ === undefined) {
- value_ = 0; // As in GTK client, floats default to 0
- }
- if (this.digits !== undefined && this.digits.length === 2) {
- value_ = utils.round_decimals(value_, this.digits[1]);
- }
- this._super(value_);
- }
-});
-
-/// 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 = common.AbstractField.extend(common.ReinitializeFieldMixin).extend({
- template: "FieldDomain",
- events: {
- "click .o_domain_show_selection_button": function (e) {
- e.preventDefault();
- this._showSelection();
- },
- "click .o_form_field_domain_dialog_button": function (e) {
- e.preventDefault();
- this.openDomainDialog();
- },
- },
- custom_events: {
- "domain_changed": function (e) {
- if (this.options.in_dialog) return;
- this.set_value(this.domainSelector.getDomain(), true);
- },
- "domain_selected": function (e) {
- this.set_value(e.data.domain);
- },
- },
- init: function () {
- this._super.apply(this, arguments);
-
- this.valid = true;
- this.debug = session.debug;
- this.options = _.defaults(this.options || {}, {
- in_dialog: false,
- model: undefined, // this option is mandatory !
- fs_filters: {}, // Field selector filters (to only show a subset of available fields @see FieldSelector)
- });
- },
- start: function() {
- this.model = _get_model.call(this); // TODO get the model another way ?
- this.field_manager.on("view_content_has_changed", this, function () {
- var currentModel = this.model;
- this.model = _get_model.call(this);
- if (currentModel !== this.model) {
- this.render_value();
- }
- });
-
- return this._super.apply(this, arguments);
-
- function _get_model() {
- if (this.field_manager.fields[this.options.model]) {
- return this.field_manager.get_field_value(this.options.model);
- }
- return this.options.model;
- }
- },
- initialize_content: function () {
- this._super.apply(this, arguments);
- this.$panel = this.$(".o_form_field_domain_panel");
- this.$showSelectionButton = this.$panel.find(".o_domain_show_selection_button");
- this.$recordsCountDisplay = this.$showSelectionButton.find(".o_domain_records_count");
- this.$errorMessage = this.$panel.find(".o_domain_error_message");
- this.$modelMissing = this.$(".o_domain_model_missing");
- },
- set_value: function (value, noDomainSelectorRender) {
- this._noDomainSelectorRender = !!noDomainSelectorRender;
- this._super.apply(this, arguments);
- this._noDomainSelectorRender = false;
- },
- render_value: function() {
- this._super.apply(this, arguments);
-
- // If there is no set model, the field should only display the corresponding error message
- this.$panel.toggleClass("o_hidden", !this.model);
- this.$modelMissing.toggleClass("o_hidden", !!this.model);
- if (!this.model) {
- if (this.domainSelector) {
- this.domainSelector.destroy();
- this.domainSelector = undefined;
- }
- return;
- }
-
- var domain = pyeval.eval("domain", this.get("value") || "[]");
-
- // Recreate domain widget with new domain value
- if (!this._noDomainSelectorRender) {
- if (this.domainSelector) {
- this.domainSelector.destroy();
- }
- this.domainSelector = new DomainSelector(this, this.model, domain, {
- readonly: this.get("effective_readonly") || this.options.in_dialog,
- fs_filters: this.options.fs_filters,
- debugMode: session.debug,
- });
- this.domainSelector.prependTo(this.$el);
- }
-
- // Show number of selected records
- new Model(this.model).call("search_count", [domain], {
- context: this.build_context(),
- }).then((function (data) {
- this.valid = true;
- return data;
- }).bind(this), (function (error, e) {
- e.preventDefault();
- this.valid = false;
- }).bind(this)).always((function (data) {
- this.$recordsCountDisplay.text(data || 0);
- this.$showSelectionButton.toggleClass("hidden", !this.valid);
- this.$errorMessage.toggleClass("hidden", this.valid);
- }).bind(this));
- },
- is_syntax_valid: function() {
- return this.field_manager.get("actual_mode") === "view" || this.valid;
- },
- _showSelection: function() {
- return new common.SelectCreateDialog(this, {
- title: _t("Selected records"),
- res_model: this.model,
- domain: this.get("value") || "[]",
- no_create: true,
- readonly: true,
- disable_multiple_selection: true,
- }).open();
- },
- openDomainDialog: function () {
- new DomainSelectorDialog(this, this.model, this.get("value") || "[]", {
- readonly: this.get("effective_readonly"),
- fs_filters: this.options.fs_filters,
- debugMode: session.debug,
- }).open();
- },
-});
-
-var FieldDate = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- tagName: "span",
- className: "o_form_field_date",
- build_widget: function() {
- return new datepicker.DateWidget(this);
- },
- initialize_content: function() {
- if (this.datewidget) {
- this.datewidget.destroy();
- this.datewidget = undefined;
- }
-
- if (!this.get("effective_readonly")) {
- this.datewidget = this.build_widget();
- this.datewidget.on('datetime_changed', this, function() {
- this.internal_set_value(this.datewidget.get_value());
- });
-
- var self = this;
- this.datewidget.appendTo('<div>').done(function() {
- self.datewidget.$el.addClass(self.$el.attr('class'));
- self.replaceElement(self.datewidget.$el);
- self.datewidget.$input.addClass('o_form_input');
- self.setupFocus(self.datewidget.$input);
- });
- }
- },
- render_value: function() {
- if (this.get("effective_readonly")) {
- this.$el.text(formats.format_value(this.get('value'), this, ''));
- } else {
- this.datewidget.set_value(this.get('value'));
- }
- },
- is_syntax_valid: function() {
- return this.get("effective_readonly") || !this.datewidget || this.datewidget.is_valid();
- },
- is_false: function() {
- return this.get('value') === '' || this._super();
- },
- focus: function() {
- if (!this.get("effective_readonly")) {
- return this.datewidget.$input.focus();
- }
- return false;
- },
-});
-
-var FieldDatetime = FieldDate.extend({
- build_widget: function() {
- return new datepicker.DateTimeWidget(this);
- },
-});
-
-var FieldText = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- template: 'FieldText',
- events: {
- 'keyup': function (e) {
- if (e.which === $.ui.keyCode.ENTER) {
- e.stopPropagation();
- }
- },
- 'keypress': function (e) {
- if (e.which === $.ui.keyCode.ENTER) {
- e.stopPropagation();
- }
- },
- 'change': 'store_dom_value',
- },
- initialize_content: function() {
- if (!this.get("effective_readonly")) {
- this.auto_sized = false;
- this.setupFocus(this.$el);
- }
- },
- commit_value: function () {
- if (!this.get("effective_readonly")) {
- this.store_dom_value();
- }
- return this._super();
- },
- store_dom_value: function () {
- this.internal_set_value(formats.parse_value(this.$el.val(), this));
- },
- render_value: function() {
- if (this.get("effective_readonly")) {
- var txt = this.get("value") || '';
- this.$el.text(txt);
- } else {
- var show_value = formats.format_value(this.get('value'), this, '');
- this.$el.val(show_value);
- dom_utils.autoresize(this.$el, {parent: this});
- }
- },
- is_syntax_valid: function() {
- if (!this.get("effective_readonly")) {
- try {
- formats.parse_value(this.$el.val(), this, '');
- } catch(e) {
- return false;
- }
- }
- return true;
- },
- is_false: function() {
- return this.get('value') === '' || this._super();
- },
- focus: function($el) {
- if(!this.get("effective_readonly")) {
- return this.$el.focus();
- }
- return false;
- },
- set_dimensions: function(height, width) {
- this.$el.css({
- width: width,
- minHeight: height,
- });
- },
-});
-
-var FieldBoolean = common.AbstractField.extend({
- template: 'FieldBoolean',
- events: {
- 'click': function() {
- this.internal_set_value(this.$checkbox.prop('checked'));
- }
- },
- start: function() {
- this.$checkbox = this.$('input');
-
- this.$checkbox.prop('disabled', this.get("effective_readonly"));
- this.on("change:effective_readonly", this, function() {
- this.$checkbox.prop('disabled', this.get("effective_readonly"));
- });
-
- this.setupFocus(this.$checkbox);
-
- return this._super();
- },
- render_value: function() {
- this.$checkbox.prop('checked', this.get('value'));
- },
- focus: function() {
- return this.$checkbox.focus();
- },
- set_dimensions: function(height, width) {}, // Checkboxes have a fixed height and width (even in list editable)
- is_false: function() {
- return false;
- },
-});
-
-/**
- This widget is intended to be used on stat button boolean fields.
- It is a read-only field that will display a simple string "<label of value>".
-*/
-var FieldBooleanButton = common.AbstractField.extend({
- className: 'o_stat_info',
- init: function() {
- this._super.apply(this, arguments);
- switch (this.options["terminology"]) {
- case "active":
- this.string_true = _t("Active");
- this.hover_true = _t("Deactivate");
- this.string_false = _t("Inactive");
- this.hover_false = _t("Activate");
- break;
- case "archive":
- this.string_true = _t("Active");
- this.hover_true = _t("Archive");
- this.string_false = _t("Archived");
- this.hover_false = _t("Restore");
- break;
- case "close":
- this.string_true = _t("Active");
- this.string_false = _t("Closed");
- this.hover_true = _t("Close");
- this.hover_false = _t("Open");
- break;
- default:
- var opt_terms = this.options["terminology"] || {};
- if (typeof opt_terms === 'string') {
- opt_terms = {}; //unsupported terminology
- }
- this.string_true = _t(opt_terms.string_true) || _t("On");
- this.hover_true = _t(opt_terms.hover_true) || _t("Switch Off");
- this.string_false = _t(opt_terms.string_false) || _t("Off");
- this.hover_false = _t(opt_terms.hover_false) || _t("Switch On");
- }
- },
- render_value: function() {
- this._super();
- this.$el.html(QWeb.render("BooleanButton", {widget: this}));
- },
- is_false: function() {
- return false;
- },
-});
-
-// The progressbar field expects a float from 0 to 100.
-var FieldProgressBar = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- initialize_content: function() {
- if(this.progressbar) {
- this.progressbar.destroy();
- }
-
- this.progressbar = new ProgressBar(this, {
- readonly: this.get('effective_readonly'),
- edit_on_click: true,
- value: this.get('value') || 0,
- });
-
- var self = this;
- this.progressbar.appendTo('<div>').done(function() {
- self.progressbar.$el.addClass(self.$el.attr('class'));
- self.replaceElement(self.progressbar.$el);
-
- self.progressbar.on('update', self, function(update) {
- self.set('value', update.changed_value);
- });
- });
- },
- render_value: function() {
- this.progressbar.set_value(this.get('value'));
- },
- is_false: function() {
- return false;
- },
-});
-
-// The PercentPie field expects a float from 0 to 100.
-var FieldPercentPie = common.AbstractField.extend({
- template: 'FieldPercentPie',
- start: function() {
- this.$left_mask = this.$('.o_mask').first();
- this.$right_mask = this.$('.o_mask').last();
- this.$pie_value = this.$('.o_pie_value');
- return this._super();
- },
- render_value: function() {
- var value = this.get('value') || 0, degValue = 360*value/100;
-
- this.$right_mask.toggleClass('o_full', degValue >= 180);
-
- var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)';
- var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)';
- this.$left_mask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg});
- this.$right_mask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg});
-
- this.$pie_value.html(Math.round(value) + '%');
- },
- is_false: function() {
- return false;
- }
-});
-
-var FieldSelection = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- template: 'FieldSelection',
- events: {
- 'change': 'store_dom_value',
- },
- init: function(field_manager, node) {
- this._super(field_manager, node);
- this.set("value", false);
- this.set("values", []);
- this.records_orderer = new utils.DropMisordered();
- this.field_manager.on("view_content_has_changed", this, function() {
- var domain = new data.CompoundDomain(this.build_domain()).eval();
- if (!_.isEqual(domain, this.get("domain"))) {
- this.set("domain", domain);
- }
- });
- },
- initialize_field: function() {
- common.ReinitializeFieldMixin.initialize_field.call(this);
- this.on("change:domain", this, this.query_values);
- this.set("domain", new data.CompoundDomain(this.build_domain()).eval());
- this.on("change:values", this, this.render_value);
- },
- query_values: function() {
- var self = this;
- var def;
- if (this.field.type === "many2one") {
- var model = new Model(this.field.relation);
- def = model.call("name_search", ['', this.get("domain")], {"context": this.build_context()});
- } else {
- var values = _.reject(this.field.selection, function (v) { return v[0] === false && v[1] === ''; });
- def = $.when(values);
- }
- this.records_orderer.add(def).then(function(values) {
- if (! _.isEqual(values, self.get("values"))) {
- self.set("values", values);
- }
- });
- },
- initialize_content: function() {
- // Flag indicating whether we're in an event chain containing a change
- // event on the select, in order to know what to do on keyup[RETURN]:
- // * If the user presses [RETURN] as part of changing the value of a
- // selection, we should just let the value change and not let the
- // event broadcast further (e.g. to validating the current state of
- // the form in editable list view, which would lead to saving the
- // current row or switching to the next one)
- // * If the user presses [RETURN] with a select closed (side-effect:
- // also if the user opened the select and pressed [RETURN] without
- // changing the selected value), takes the action as validating the row
- if(!this.get('effective_readonly')) {
- var ischanging = false;
- this.$el
- .change(function () { ischanging = true; })
- .click(function () { ischanging = false; })
- .keyup(function (e) {
- if (e.which !== 13 || !ischanging) { return; }
- e.stopPropagation();
- ischanging = false;
- });
- this.setupFocus(this.$el);
- }
- },
- commit_value: function () {
- this.store_dom_value();
- return this._super();
- },
- store_dom_value: function () {
- if (!this.get('effective_readonly')) {
- this.internal_set_value(JSON.parse(this.$el.val()));
- }
- },
- set_value: function(value_) {
- value_ = value_ === null ? false : value_;
- value_ = value_ instanceof Array ? value_[0] : value_;
- this._super(value_);
- },
- render_value: function() {
- var values = this.get("values");
- values = [[false, this.node.attrs.placeholder || '']].concat(values);
- var found = _.find(values, function(el) { return el[0] === this.get("value"); }, this);
- if (!found) {
- found = [this.get("value"), _t('Unknown')];
- values = [found].concat(values);
- }
- if (!this.get("effective_readonly")) {
- this.$el.empty();
- for(var i = 0 ; i < values.length ; i++) {
- this.$el.append($('<option/>', {
- value: JSON.stringify(values[i][0]),
- html: values[i][1]
- }))
- }
- this.$el.val(JSON.stringify(found[0]));
- } else {
- this.$el.text(found[1]);
- }
- },
- focus: function() {
- if (!this.get("effective_readonly")) {
- return this.$el.focus();
- }
- return false;
- },
-});
-
-/**
- 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 TimezoneMismatch = FieldSelection.extend({
- initialize_content: function() {
- this._super.apply(this, arguments);
- this.tz_offset_field = (this.options && this.options.tz_offset_field) || this.tz_offset_field || 'tz_offset';
- this.set({"tz_offset": this.field_manager.get_field_value(this.tz_offset_field)});
- this.on("change:tz_offset", this, this.render_value);
- },
- start: function() {
- this._super.apply(this, arguments);
- // trigger a render_value when tz_offset field change
- this.field_manager.on("field_changed:" + this.tz_offset_field, this, function() {
- this.set({"tz_offset": this.field_manager.get_field_value(this.tz_offset_field)});
- });
- },
- check_timezone: function() {
- var user_offset = this.get('tz_offset');
- if (user_offset) {
- var offset = -(new Date().getTimezoneOffset());
- var browser_offset = (offset < 0) ? "-" : "+";
- browser_offset += _.str.sprintf("%02d", Math.abs(offset / 60));
- browser_offset += _.str.sprintf("%02d", Math.abs(offset % 60));
- return (browser_offset !== user_offset);
- }
- return false;
- },
- render_value: function() {
- this._super.apply(this, arguments);
- this.$label.next('.o_tz_warning').remove();
- if(this.check_timezone()){
- var options = _.extend({
- delay: { show: 501, hide: 0 },
- title: _t("Timezone Mismatch : The timezone of your browser doesn't match the selected one. The time in Odoo is displayed according to your field timezone."),
- });
- $('<span/>').addClass('fa fa-exclamation-triangle o_tz_warning').insertAfter(this.$label).tooltip(options);
- }
- }
-});
-
-var LabelSelection = FieldSelection.extend({
- init: function(field_manager, node) {
- this._super(field_manager, node);
- this.classes = this.options && this.options.classes || {};
- },
- render_value: function() {
- this._super.apply(this, arguments);
- if (this.get("effective_readonly")) {
- var bt_class = this.classes[this.get('value')] || 'default';
- this.$el.html($('<span/>', {html: this.$el.html()}).addClass('label label-' + bt_class));
- }
- },
-});
-
-var FieldRadio = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- template: 'FieldRadio',
- events: {
- 'click input': 'click_change_value'
- },
- init: function(field_manager, node) {
- /* Radio button widget: Attributes options:
- * - "horizontal" to display in column
- * - "no_radiolabel" don't display text values
- */
- this._super(field_manager, node);
- this.selection = _.clone(this.field.selection) || [];
- this.domain = false;
- this.uniqueId = _.uniqueId("radio");
- },
- initialize_content: function () {
- this.field_manager.on("view_content_has_changed", this, this.get_selection);
- this.get_selection();
- },
- click_change_value: function (event) {
- var val = $(event.target).val();
- val = this.field.type == "selection" ? val : +val;
- if (val !== this.get_value()) {
- this.set_value(val);
- }
- },
- /** Get the selection and render it
- * selection: [[identifier, value_to_display], ...]
- * For selection fields: this is directly given by this.field.selection
- * For many2one fields: perform a search on the relation of the many2one field
- */
- get_selection: function() {
- var self = this;
- var selection = [];
- var def = $.Deferred();
- if (self.field.type == "many2one") {
- var domain = pyeval.eval('domain', this.build_domain()) || [];
- if (! _.isEqual(self.domain, domain)) {
- self.domain = domain;
- var ds = new data.DataSetStatic(self, self.field.relation, self.build_context());
- ds.call('search', [self.domain])
- .then(function (records) {
- ds.name_get(records).then(function (records) {
- selection = records;
- def.resolve();
- });
- });
- } else {
- selection = self.selection;
- def.resolve();
- }
- }
- else if (self.field.type == "selection") {
- selection = self.field.selection || [];
- def.resolve();
- }
- return def.then(function () {
- if (!_.isEqual(selection, self.selection)) {
- self.selection = _.clone(selection);
- self.renderElement();
- self.render_value();
- }
- });
- },
- set_value: function (value_) {
- if (this.field.type == "selection") {
- value_ = _.find(this.field.selection, function (sel) { return sel[0] == value_;});
- }
- else if (!this.selection.length) {
- this.selection = [value_];
- }
-
- this._super(value_);
- },
- get_value: function () {
- var value = this.get('value');
- value = ((value instanceof Array)? value[0] : value);
- return _.isUndefined(value) ? false : value;
- },
- render_value: function () {
- var self = this;
- if(this.get('effective_readonly')) {
- this.$el.html(this.get('value')? this.get('value')[1] : "");
- } else {
- this.$("input").prop("checked", false).filter(function () {return this.value == self.get_value();}).prop("checked", true);
- }
- }
-});
-
-var FieldReference = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- className: 'o_row',
- init: function(field_manager, node) {
- this._super(field_manager, node);
- this.reference_ready = true;
- },
- destroy_content: function() {
- if (this.fm) {
- this.fm.destroy();
- this.fm = undefined;
- }
- },
- initialize_content: function() {
- var self = this;
- this.fm = new common.DefaultFieldManager(this);
- this.fm.extend_field_desc({
- "selection": {
- selection: this.field_manager.get_field_desc(this.name).selection,
- type: "selection",
- },
- "m2o": {
- relation: null,
- type: "many2one",
- },
- });
- this.selection = new FieldSelection(this.fm, { attrs: {
- name: 'selection',
- modifiers: JSON.stringify({readonly: this.get('effective_readonly')}),
- }});
- this.selection.on("change:value", this, this.on_selection_changed);
- this.selection.appendTo(this.$el);
- this.selection
- .on('focused', null, function () {self.trigger('focused');})
- .on('blurred', null, function () {self.trigger('blurred');});
-
- var FieldMany2One = core.form_widget_registry.get('many2one');
- this.m2o = new FieldMany2One(this.fm, { attrs: {
- name: 'Referenced Document',
- modifiers: JSON.stringify({readonly: this.get('effective_readonly')}),
- context: this.build_context().eval(),
- }});
- this.m2o.on("change:value", this, this.data_changed);
- this.m2o.appendTo(this.$el);
- this.m2o
- .on('focused', null, function () {self.trigger('focused');})
- .on('blurred', null, function () {self.trigger('blurred');});
- },
- on_selection_changed: function() {
- if (this.reference_ready) {
- this.internal_set_value([this.selection.get_value(), false]);
- this.render_value();
- }
- },
- data_changed: function() {
- if (this.reference_ready) {
- this.internal_set_value([this.selection.get_value(), this.m2o.get_value()]);
- }
- },
- set_value: function(val) {
- if (val) {
- val = val.split(',');
- val[0] = val[0] || false;
- val[1] = val[0] ? (val[1] ? parseInt(val[1], 10) : val[1]) : false;
- }
- this._super(val || [false, false]);
- },
- get_value: function() {
- return this.get('value')[0] && this.get('value')[1] ? (this.get('value')[0] + ',' + this.get('value')[1]) : false;
- },
- render_value: function() {
- this.reference_ready = false;
- if (!this.get("effective_readonly")) {
- this.selection.set_value(this.get('value')[0]);
- }
- this.m2o.field.relation = this.get('value')[0];
- this.m2o.set_value(this.get('value')[1]);
- this.m2o.do_toggle(!!this.get('value')[0]);
- this.reference_ready = true;
- },
- is_false: function() {
- return !this.get_value();
- },
-});
-
-var FieldBinary = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- init: function(field_manager, node) {
- var self = this;
- this._super(field_manager, node);
- this.binary_value = false;
- this.useFileAPI = !!window.FileReader;
- try {
- this.max_file_size = utils.parse_human_size(this.options.max_file_size);
- } catch(e) {
- this.max_file_size = 0;
- }
- if (!this.useFileAPI) {
- 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);
- });
- }
- },
- stop: function() {
- if (!this.useFileAPI) {
- $(window).off(this.fileupload_id);
- }
- this._super.apply(this, arguments);
- },
- initialize_content: function() {
- this.$inputFile = this.$('.o_form_input_file');
- this.$inputFile.change(this.on_file_change);
- var self = this;
- this.$('.o_select_file_button').click(function() {
- self.$inputFile.click();
- });
- this.$('.o_clear_file_button').click(this.on_clear);
- },
- 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 (this.max_file_size && file.size > this.max_file_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_file_size)));
- return false;
- }
- var filereader = new FileReader();
- filereader.readAsDataURL(file);
- filereader.onloadend = function(upload) {
- var data = upload.target.result;
- data = data.split(',')[1];
- self.on_file_uploaded(file.size, file.name, file.type, data);
- };
- } else {
- this.$('form.o_form_binary_form input[name=session_id]').val(this.session.session_id);
- 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(_t("File Upload"), _t("There was a problem while uploading your file"));
- // TODO: use openerp web 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.binary_value = true;
- this.set_filename(name);
- this.set_value(file_base64);
- },
- on_save_as: function(ev) {
- var value = this.get('value');
- if (!value) {
- this.do_warn(_t("Save As..."), _t("The field is empty, there's nothing to save !"));
- ev.stopPropagation();
- } else {
- framework.blockUI();
- var c = crash_manager;
- var filename_fieldname = this.node.attrs.filename;
- var filename_field = this.view.fields && this.view.fields[filename_fieldname];
- this.session.get_file({
- 'url': '/web/content',
- 'data': {
- 'model': this.view.dataset.model,
- 'id': this.view.datarecord.id,
- 'field': this.name,
- 'filename_field': filename_fieldname,
- 'filename': filename_field ? filename_field.get('value') : null,
- 'download': true,
- 'data': utils.is_bin_size(value) ? null : value,
- },
- 'complete': framework.unblockUI,
- 'error': c.rpc_error.bind(c)
- });
- ev.stopPropagation();
- }
- },
- set_filename: function(value) {
- var filename = this.node.attrs.filename;
- if (filename) {
- var field = this.field_manager.fields[filename];
- if (field) {
- field.set_value(value);
- field._dirty_flag = true;
- }
- }
- },
- on_clear: function() {
- this.binary_value = false;
- this.set_filename('');
- this.set_value(false); // FIXME do not really remove the value
- }
-});
-
-var FieldBinaryFile = FieldBinary.extend({
- template: 'FieldBinaryFile',
- initialize_content: function() {
- var self = this;
- this._super();
- if (this.get("effective_readonly")) {
- this.$el.click(function(ev) {
- if (self.get('value')) {
- self.on_save_as(ev);
- }
- return false;
- });
- } else {
- this.$input = this.$('.o_form_input').eq(0);
- this.$input.on('click', function() {
- self.$inputFile.click();
- });
- }
- },
- render_value: function() {
- var filename = this.view.datarecord[this.node.attrs.filename];
- if (this.get("effective_readonly")) {
- this.do_toggle(!!this.get('value'));
- if (this.get('value')) {
- this.$el.empty().append($("<span/>").addClass('fa fa-download'));
- if (filename) {
- this.$el.append(" " + filename);
- }
- }
- } else {
- if(this.get('value')) {
- this.$el.children().removeClass('o_hidden');
- this.$('.o_select_file_button').first().addClass('o_hidden');
- this.$input.val(filename || this.get('value'));
- } else {
- this.$el.children().addClass('o_hidden');
- this.$('.o_select_file_button').first().removeClass('o_hidden');
- }
- }
- }
-});
-
-var FieldBinaryImage = FieldBinary.extend({
- template: 'FieldBinaryImage',
- placeholder: "/web/static/src/img/placeholder.png",
- render_value: function() {
- var url = this.placeholder;
- if(this.get('value')) {
- if(!utils.is_bin_size(this.get('value'))) {
- url = 'data:image/png;base64,' + this.get('value');
- } else {
- url = session.url('/web/image', {
- model: this.view.dataset.model,
- id: JSON.stringify(this.view.datarecord.id || null),
- field: (this.options.preview_image)? this.options.preview_image : this.name,
- unique: (this.view.datarecord.__last_update || '').replace(/[^0-9]/g, ''),
- });
- }
- }
-
- var $img = $(QWeb.render("FieldBinaryImage-img", {widget: this, url: url}));
-
- var self = this;
- $img.click(function(e) {
- if(self.view.get("actual_mode") == "view") {
- var $button = $(".o_form_button_edit");
- $button.openerpBounce();
- e.stopPropagation();
- }
- });
- this.$('> img').remove();
- if (self.options.size) {
- $img.css("width", "" + self.options.size[0] + "px");
- $img.css("height", "" + self.options.size[1] + "px");
- }
- this.$el.prepend($img);
- $img.on('error', function() {
- self.on_clear();
- $img.attr('src', self.placeholder);
- self.do_warn(_t("Image"), _t("Could not display the selected image."));
- });
- },
- set_value: function(value_) {
- var changed = value_ !== this.get_value();
- this._super.apply(this, arguments);
- // By default, on binary images read, the server returns the binary size
- // This is possible that two images have the exact same size
- // Therefore we trigger the change in case the image value hasn't changed
- // So the image is re-rendered correctly
- if (!changed){
- this.trigger("change:value", this, {
- oldValue: value_,
- newValue: value_
- });
- }
- },
- is_false: function() {
- return false;
- },
- set_dimensions: function(height, width) {
- this.$el.css({
- maxWidth: width,
- minHeight: height,
- });
- },
-});
-
-var FieldStatus = common.AbstractField.extend({
- template: "FieldStatus",
- init: function(field_manager, node) {
- this._super(field_manager, node);
- this.options.clickable = this.options.clickable || (this.node.attrs || {}).clickable || false;
- this.options.visible = this.options.visible || (this.node.attrs || {}).statusbar_visible || false;
- this.set({value: false});
- this.selection = {'unfolded': [], 'folded': []};
- this.set("selection", {'unfolded': [], 'folded': []});
- this.selection_dm = new utils.DropMisordered();
- this.dataset = new data.DataSetStatic(this, this.field.relation, this.build_context());
- },
- start: function() {
- this.field_manager.on("view_content_has_changed", this, this.calc_domain);
- this.calc_domain();
- this.on("change:value", this, this.get_selection);
- this.on("change:evaluated_selection_domain", this, this.get_selection);
- this.on("change:selection", this, function() {
- this.selection = this.get("selection");
- this.render_value();
- });
- this.get_selection();
- if (this.options.clickable) {
- this.bind_stage_click();
- }
- this._super();
- },
- bind_stage_click: function () {
- // This function is overriden in the enterprise webclient
- this.$el.on('click','li[data-id]',this.on_click_stage);
- },
- set_value: function(value_) {
- if (value_ instanceof Array) {
- value_ = value_[0];
- }
- this._super(value_);
- },
- render_value: function() {
- var self = this;
- var content = QWeb.render("FieldStatus.content", {
- 'widget': self,
- 'value_folded': _.find(self.selection.folded, function(i){return i[0] === self.get('value');})
- });
- self.$el.html(content);
- },
- calc_domain: function() {
- var d = pyeval.eval('domain', this.build_domain());
- var domain = []; //if there is no domain defined, fetch all the records
-
- if (d.length) {
- domain = ['|',['id', '=', this.get('value')]].concat(d);
- }
-
- if (! _.isEqual(domain, this.get("evaluated_selection_domain"))) {
- this.set("evaluated_selection_domain", domain);
- }
- },
- /** Get the selection and render it
- * selection: [[identifier, value_to_display], ...]
- * For selection fields: this is directly given by this.field.selection
- * For many2one fields: perform a search on the relation of the many2one field
- */
- get_selection: function() {
- var self = this;
- var selection_unfolded = [];
- var selection_folded = [];
- var fold_field = this.options.fold_field;
-
- var calculation = _.bind(function() {
- if (this.field.type === "many2one") {
- return self.get_distant_fields().then(function () {
- return new data.DataSetSearch(self, self.field.relation, self.build_context(), self.get("evaluated_selection_domain"))
- .read_slice(_.union(_.keys(self.distant_fields), ['id']), {}).then(function (records) {
- var ids = _.pluck(records, 'id');
- return self.dataset.name_get(ids).then(function (records_name) {
- _.each(records, function (record) {
- var name = _.find(records_name, function (val) {return val[0] === record.id;})[1];
- if (fold_field && record[fold_field] && record.id !== self.get('value')) {
- selection_folded.push([record.id, name]);
- } else {
- selection_unfolded.push([record.id, name]);
- }
- });
- });
- });
- });
- } else {
- // For field type selection filter values according to
- // statusbar_visible attribute of the field. For example:
- // statusbar_visible="draft,open".
- var select = this.field.selection;
- for(var i=0; i < select.length; i++) {
- var key = select[i][0];
- if(key === this.get('value') || !this.options.visible || this.options.visible.indexOf(key) !== -1) {
- selection_unfolded.push(select[i]);
- }
- }
- return $.when();
- }
- }, this);
- this.selection_dm.add(calculation()).then(function () {
- var selection = {'unfolded': selection_unfolded, 'folded': selection_folded};
- if (! _.isEqual(selection, self.get("selection"))) {
- self.set("selection", selection);
- }
- });
- },
- /*
- * :deprecated: this feature will probably be removed with OpenERP v8
- */
- get_distant_fields: function() {
- var self = this;
- if (! this.options.fold_field) {
- this.distant_fields = {};
- }
- if (this.distant_fields) {
- return $.when(this.distant_fields);
- }
- return new Model(self.field.relation).call("fields_get", [[this.options.fold_field]]).then(function(fields) {
- self.distant_fields = fields;
- return fields;
- });
- },
- on_click_stage: _.debounce(function (ev) {
- var self = this;
- var $li = $(ev.currentTarget);
- var ul = $li.closest('.oe_form_field_status');
- if (this.view.is_disabled) {
- return;
- }
- var val;
- if (ul.attr('disabled')) {
- return;
- }
- if (this.field.type === "many2one") {
- val = parseInt($li.data("id"), 10);
- } else {
- val = $li.data("id");
- }
- if (val !== self.get('value')) {
- if (!this.view.datarecord.id ||
- this.view.datarecord.id.toString().match(data.BufferedDataSet.virtual_id_regex)) {
- // don't save, only set value for not-yet-saved many2ones
- self.set_value(val);
- }
- else {
- this.view.recursive_save().done(function() {
- var change = {};
- change[self.name] = val;
- ul.attr('disabled', true);
- self.view.dataset.write(self.view.datarecord.id, change).done(function() {
- self.view.reload();
- }).always(function() {
- ul.removeAttr('disabled');
- });
- });
- }
- }
- }, 300, true),
-});
-
-var FieldMonetary = FieldFloat.extend({
- template: 'FieldMonetary',
- init: function() {
- this._super.apply(this, arguments);
- this.set({"currency": false});
- var currency_field = (this.options && this.options.currency_field) || this.field.currency_field || 'currency_id';
- if (currency_field) {
- this.field_manager.on("field_changed:" + currency_field, this, function() {
- this.set({"currency": this.field_manager.get_field_value(currency_field)});
- });
- }
- this.on("change:currency", this, this.get_currency_info);
- this.get_currency_info();
- },
- start: function() {
- var tmp = this._super();
- this.on("change:currency_info", this, this.update);
- return tmp;
- },
- initialize_content: function() {
- if(!this.get('effective_readonly')) {
- this.$input = this.$('input');
- this.add_symbol();
- } else {
- this.$input = undefined;
- }
-
- this._super();
- },
- add_symbol: function() {
- var currency = this.get('currency_info');
- if(currency) {
- var before = (currency.position === 'before');
- this.$el[(before)? 'prepend' : 'append']($('<span/>', {html: currency.symbol}));
- }
- },
- get_currency_info: function() {
- var self = this;
- if (this.get("currency") === false) {
- this.set({"currency_info": null});
- return;
- }
- return self.set({"currency_info": session.get_currency(self.get("currency"))});
- },
- update: function() {
- if (this.view.options.is_list_editable) {
- return;
- } else {
- return this.reinitialize();
- }
- },
- render_value: function() {
- this._super();
- if(this.get('effective_readonly')) {
- this.add_symbol();
- }
- },
- get_digits_precision: function() {
- return this.node.attrs.digits || this.field.digits || (this.get('currency_info') && this.get('currency_info').digits);
- },
- parse_value: function(val, def) {
- return formats.parse_value(val, {type: "float", digits: this.get_digits_precision()}, def);
- },
- format_value: function(val, def) {
- return formats.format_value(val, {type: "float", digits: this.get_digits_precision()}, def);
- },
-});
-
-/**
- This widget is intended to be used on stat button numeric fields. It will display
- the value many2many and one2many. It is a read-only field that will
- display a simple string "<value of field> <label of the field>"
-*/
-var StatInfo = common.AbstractField.extend({
- is_field_number: true,
- init: function() {
- this._super.apply(this, arguments);
- this.internal_set_value(0);
- },
- set_value: function(value_) {
- if (value_ === false || value_ === undefined) {
- value_ = 0;
- }
- this._super.apply(this, [value_]);
- },
- render_value: function() {
- var options = {
- value: this.get("value") || 0,
- };
- if (! this.node.attrs.nolabel) {
- if(this.options.label_field && this.view.datarecord[this.options.label_field]) {
- options.text = this.view.datarecord[this.options.label_field];
- }
- else {
- options.text = this.string;
- }
- }
- this.$el.html(QWeb.render("StatInfo", options));
- this.$el.addClass('o_stat_info');
- },
-});
-
-/**
- This widget is intended to be used on boolean fields. It toggles a button
- switching between a green bullet / gray bullet.
-*/
-var FieldToggleBoolean = common.AbstractField.extend({
- template: "toggle_button",
- events: {
- 'click': 'set_toggle_button'
- },
- render_value: function () {
- var class_name = this.get_value() ? 'o_toggle_button_success' : 'text-muted';
- this.$('i').attr('class', ('fa fa-circle ' + class_name));
- },
- set_toggle_button: function () {
- var self = this;
- var toggle_value = !this.get_value();
- if (this.view.get('actual_mode') == 'view') {
- var rec_values = {};
- rec_values[self.node.attrs.name] = toggle_value;
- return this.view.dataset._model.call(
- 'write', [
- [this.view.datarecord.id],
- rec_values,
- self.view.dataset.get_context()
- ]).done(function () { self.reload_record(); });
- }
- else {
- this.set_value(toggle_value);
- }
- },
- reload_record: function () {
- this.view.reload();
- },
- is_false: function() {
- return false;
- },
-});
-
-/**
- This widget is intended to be used on Text fields. It will provide Ace Editor for editing XML and Python.
-*/
-
-var AceEditor = common.AbstractField.extend(common.ReinitializeFieldMixin, {
- template: "AceEditor",
- willStart: function() {
- if (!window.ace && !this.loadJS_def) {
- this.loadJS_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () {
- return $.when(ajax.loadJS('/web/static/lib/ace/mode-python.js'),
- ajax.loadJS('/web/static/lib/ace/mode-xml.js')
- );
- });
- }
- return $.when(this._super(), this.loadJS_def);
- },
- initialize_content: function () {
- if (! this.get("effective_readonly")) {
- var self = this;
-
- this.aceEditor = ace.edit(this.$('.ace-view-editor')[0]);
- this.aceEditor.setOptions({"maxLines": Infinity});
- this.aceEditor.$blockScrolling = true;
-
- var scrollIntoViewIfNeeded = _.throttle(function () {
- var node = self.aceEditor.renderer.textarea;
- if (node.scrollIntoViewIfNeeded) {
- node.scrollIntoViewIfNeeded(false);
- } else {
- var offsetParent = node.offsetParent;
- while (offsetParent) {
- var elY = 0;
- var elH = node.offsetHeight+20;
- var parent = node;
- while (offsetParent && parent) {
- elY += node.offsetTop;
- // get if a parent have a scrollbar
- parent = node.parentNode;
- while (parent != offsetParent &&
- (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(window.getComputedStyle(parent).overflowY) === -1)) {
- parent = parent.parentNode;
- }
- node = parent;
- if (parent !== offsetParent) {
- elY -= parent.offsetTop;
- parent = null;
- }
- offsetParent = node.offsetParent;
- }
-
- if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(window.getComputedStyle(node).overflowY) !== -1) &&
- (node.scrollTop + node.clientHeight) < (elY + elH)) {
- node.scrollTop = (elY + elH) - node.clientHeight;
- }
- }
- }
- });
- var $moveTextAreaToCursor = this.aceEditor.renderer.$moveTextAreaToCursor;
- self.aceEditor.renderer.$moveTextAreaToCursor = function() {
- $moveTextAreaToCursor.call(this);
- if (parseInt($(self.aceEditor.renderer.textarea).css('top'), 10) >= 0) {
- scrollIntoViewIfNeeded();
- }
- };
-
- this.aceSession = this.aceEditor.getSession();
- this.aceSession.setUseWorker(false);
- this.aceSession.setMode("ace/mode/"+(this.options.mode || 'xml'));
-
- this.aceEditor.on("blur", function() {
- if (self.aceSession.getUndoManager().hasUndo()) {
- self.set_value(self.aceSession.getValue());
- }
- });
- }
- },
- destroy_content: function() {
- if (this.aceEditor) {
- this.aceEditor.destroy();
- }
- },
- render_value: function() {
- if (! this.get("effective_readonly")) {
- var value = formats.format_value(this.get('value'), this);
- this.aceSession.setValue(value);
-
- } else {
- var txt = this.get("value") || '';
- this.$(".oe_form_text_content").text(txt);
- }
- },
- focus: function() {
- return this.aceEditor.focus();
- },
-});
-
-/**
- * Registry of form fields, called by :js:`instance.web.FormView`.
- *
- * All referenced classes must implement FieldInterface. Those represent the classes whose instances
- * will substitute to the <field> tags as defined in OpenERP's views.
- */
-core.form_widget_registry
- .add('char', FieldChar)
- .add('id', FieldID)
- .add('email', FieldEmail)
- .add('url', FieldUrl)
- .add('text',FieldText)
- .add('domain', FieldDomain)
- .add('date', FieldDate)
- .add('datetime', FieldDatetime)
- .add('selection', FieldSelection)
- .add('radio', FieldRadio)
- .add('reference', FieldReference)
- .add('boolean', FieldBoolean)
- .add('boolean_button', FieldBooleanButton)
- .add('toggle_button', FieldToggleBoolean)
- .add('float', FieldFloat)
- .add('percentpie', FieldPercentPie)
- .add('integer', FieldFloat)
- .add('float_time', FieldFloat)
- .add('progressbar', FieldProgressBar)
- .add('image', FieldBinaryImage)
- .add('binary', FieldBinaryFile)
- .add('statusbar', FieldStatus)
- .add('monetary', FieldMonetary)
- .add('priority', FieldPriority)
- .add('kanban_state_selection', KanbanSelection)
- .add('statinfo', StatInfo)
- .add('timezone_mismatch', TimezoneMismatch)
- .add('label_selection', LabelSelection)
- .add('ace', AceEditor);
-
-/**
- * Registry of widgets usable in the form view that can substitute to any possible
- * tags defined in OpenERP's form views.
- *
- * Every referenced class should extend FormWidget.
- */
-core.form_tag_registry.add('button', WidgetButton);
-
-return {
- FieldBoolean: FieldBoolean,
- FieldChar: FieldChar,
- FieldEmail: FieldEmail,
- FieldFloat: FieldFloat,
- FieldRadio: FieldRadio,
- FieldStatus: FieldStatus,
- FieldMonetary: FieldMonetary,
- WidgetButton: WidgetButton
-};
-
-});
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 00000000000..040311a3919
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -0,0 +1,128 @@
+odoo.define('web.GraphController', function (require) {
+"use strict";
+/*---------------------------------------------------------
+ * Odoo Graph view
+ *---------------------------------------------------------*/
+
+var AbstractController = require('web.AbstractController');
+var core = require('web.core');
+
+var qweb = core.qweb;
+
+var GraphController = AbstractController.extend({
+ className: 'o_graph',
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {GraphModel} model
+ * @param {GraphRenderer} renderer
+ * @param {Object} params
+ * @param {string[]} params.measures
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.measures = params.measures;
+ },
+ /**
+ * @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();
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * 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) {
+ if ($node) {
+ var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
+ this.$buttons = $(qweb.render('GraphView.buttons', context));
+ this.$measureList = this.$buttons.find('.o_graph_measures_list');
+ this.$buttons.find('button').tooltip();
+ this.$buttons.click(this._onButtonClick.bind(this));
+ this._updateButtons();
+ this.$buttons.appendTo($node);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @todo remove this and directly calls update. Update should be overridden
+ * and modified to call _updateButtons
+ *
+ * @param {string} mode one of 'pie', 'line' or 'bar'
+ */
+ _setMode: function (mode) {
+ this.update({mode: mode});
+ this._updateButtons();
+ },
+ /**
+ * @todo same as _setMode
+ *
+ * @param {string} measure should be a valid (and aggregatable) field name
+ */
+ _setMeasure: function (measure) {
+ var self = this;
+ this.update({measure: measure}).then(function () {
+ self._updateButtons();
+ });
+ },
+ /**
+ * makes sure that the buttons in the control panel matches the current
+ * state (so, correct active buttons and stuff like that)
+ */
+ _updateButtons: function () {
+ 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.$measureList.find('li').each(function (index, li) {
+ $(li).toggleClass('selected', $(li).data('field') === state.measure);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Do what need to be done when a button from the control panel is clicked.
+ *
+ * @param {MouseEvent} event
+ */
+ _onButtonClick: function (event) {
+ var $target = $(event.target);
+ if ($target.hasClass('o_graph_button')) {
+ this._setMode($target.data('mode'));
+ } else if ($target.parents('.o_graph_measures_list').length) {
+ event.preventDefault();
+ event.stopPropagation();
+ var parent = $target.parent();
+ var field = parent.data('field');
+ this._setMeasure(field);
+ }
+ },
+});
+
+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 00000000000..73578f5c3bc
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -0,0 +1,188 @@
+odoo.define('web.GraphModel', function (require) {
+"use strict";
+
+/**
+ * The graph model is responsible for fetching and processing data from the
+ * server. It basically just do a read_group and format/normalize data.
+ */
+
+var core = require('web.core');
+var AbstractModel = require('web.AbstractModel');
+
+var _t = core._t;
+
+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.
+ *
+ * @todo Adding the fields parameter looks wrong. If the renderer or the
+ * controller need the fields, they should get it via their init method.
+ *
+ * @returns {Object}
+ */
+ get: function () {
+ return _.extend({}, this.chart, {
+ fields: this.fields
+ });
+ },
+ /**
+ * 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 {string[]} params.groupedBy
+ * @param {string} params.initialMode one of 'pie', 'bar', 'line
+ * @param {string} params.initialMeasure a valid field name
+ * @param {string[]} params.initialGroupBys a list of valid field names
+ * @param {Object} params.context
+ * @param {string[]} params.domain
+ * @returns {Deferred} The deferred does not return a handle, we don't need
+ * to keep track of various entities.
+ */
+ load: function (params) {
+ this.initialGroupBys = params.initialGroupBys;
+ this.fields = params.fields;
+ this.modelName = params.modelName;
+ if (!params.groupedBy.length) {
+ var groupedBy = params.context.graph_groupbys ||
+ (params.groupedBy.length && params.groupedBy) ||
+ this.initialGroupBys;
+ params.groupedBy = groupedBy.slice(0);
+ }
+ this.chart = {
+ data: [],
+ groupedBy: params.groupedBy,
+ measure: params.initialMeasure,
+ mode: params.initialMode,
+ domain: params.domain,
+ context: params.context,
+ };
+ 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 {string[]} [params.domain]
+ * @param {string[]} [params.groupBy]
+ * @param {string} [params.mode] one of 'bar', 'pie', 'line'
+ * @param {string} [params.measure] a valid field name
+ * @returns {Deferred}
+ */
+ reload: function (handle, params) {
+ if ('domain' in params) {
+ this.chart.domain = params.domain;
+ }
+ if ('groupBy' in params) {
+ if (!params.groupBy.length) {
+ this.chart.groupedBy = this.initialGroupBys;
+ } else {
+ this.chart.groupedBy = params.groupBy;
+ }
+ }
+ if ('measure' in params) {
+ this.chart.measure = params.measure;
+ }
+ if ('mode' in params) {
+ this.chart.mode = params.mode;
+ return $.when();
+ }
+ return this._loadGraph();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetch and process graph data. It is basically a read_group with correct
+ * fields. 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
+ *
+ * @returns {Deferred}
+ */
+ _loadGraph: function () {
+ var fields = _.map(this.chart.groupedBy, function (groupBy) {
+ return groupBy.split(':')[0];
+ });
+ if (this.chart.measure !== '__count__') {
+ fields = fields.concat(this.chart.measure);
+ }
+ return this.performModelRPC(this.modelName, 'read_group', [], {
+ context: this.chart.context,
+ domain: this.chart.domain,
+ fields: fields,
+ groupby: this.chart.groupedBy,
+ lazy: false,
+ }).then(this._processData.bind(this));
+ },
+ /**
+ * Since read_group is insane and returns its result on different keys
+ * depending of some input, we have to normalize the result.
+ * The final chart data is added to this.chart object.
+ *
+ * @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
+ *
+ * @param {any} raw_data result from the read_group
+ */
+ _processData: function (raw_data) {
+ var self = this;
+ var is_count = this.chart.measure === '__count__';
+ var data_pt, labels;
+
+ this.chart.data = [];
+ for (var i = 0; i < raw_data.length; i++) {
+ data_pt = raw_data[i];
+ labels = _.map(this.chart.groupedBy, function (field) {
+ return self._sanitizeValue(data_pt[field], field);
+ });
+ this.chart.data.push({
+ value: is_count ? data_pt.__count : data_pt[this.chart.measure],
+ labels: labels
+ });
+ }
+ },
+ /**
+ * Helper function (for _processData), turns various values in a usable
+ * string form, that we can display in the interface.
+ *
+ * @param {any} value some value received by the read_group rpc
+ * @param {string} field the name of the corresponding field
+ * @returns {string}
+ */
+ _sanitizeValue: function (value, field) {
+ if (value === false) return _t("Undefined");
+ if (value instanceof Array) return value[1];
+ if (field && this.fields[field] && (this.fields[field].type === 'selection')) {
+ var selected = _.where(this.fields[field].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 00000000000..e5fe00cc473
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -0,0 +1,334 @@
+odoo.define('web.GraphRenderer', function (require) {
+"use strict";
+
+/**
+ * The graph renderer turns the data from the graph model into a nice looking
+ * svg chart. This code uses the nvd3 library.
+ *
+ * Note that we use a custom build for the nvd3, with only the model we actually
+ * use.
+ */
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var formats = require('web.formats');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var CHART_TYPES = ['pie', 'bar', 'line'];
+
+// hide top legend when too many items for device size
+var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class);
+
+return AbstractRenderer.extend({
+ className: "o_graph_svg_container",
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} state
+ * @param {Object} params
+ * @param {boolean} params.stacked
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.stacked = params.stacked;
+ this.$el.css({minWidth: '100px', minHeight: '100px'});
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ nv.utils.offWindowResize(this.to_remove);
+ this._super();
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Render the chart.
+ *
+ * Note that This method is synchronous, but the actual rendering is done
+ * asynchronously (in a setTimeout). The reason for that is that nvd3/d3
+ * needs to be in the DOM to correctly render itself. So, we trick Odoo by
+ * returning immediately, then wait a tiny interval before actually
+ * displaying the data.
+ *
+ * @returns {Deferred} The _super deferred is actually resolved immediately
+ */
+ _render: function () {
+ if (this.to_remove) {
+ nv.utils.offWindowResize(this.to_remove);
+ }
+ if (!_.contains(CHART_TYPES, this.state.mode)) {
+ this.$el.empty();
+ this.trigger_up('warning', {
+ title: _t('Invalid mode for chart'),
+ message: _t('Cannot render chart with mode : ') + this.state.mode
+ });
+ } else if (!this.state.data.length) {
+ this.$el.empty();
+ this.$el.append(qweb.render('GraphView.error', {
+ title: _t("No data to display"),
+ description: _t("No data available for this chart. " +
+ "Try to add some records, or make sure that " +
+ "there is no active filter in the search bar."),
+ }));
+ } else {
+ var self = this;
+ setTimeout(function () {
+ self.$el.empty();
+ var chart = self['_render' + _.str.capitalize(self.state.mode) + 'Chart']();
+ if (chart) {
+ self.to_remove = chart.update;
+ nv.utils.onWindowResize(chart.update);
+ chart.tooltip.chartContainer(self.el);
+ }
+ }, 0);
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ /**
+ * Helper function to set up data properly for the multiBarChart model in
+ * nvd3.
+ *
+ * @returns {nvd3 chart}
+ */
+ _renderBarChart: function () {
+ // prepare data for bar chart
+ var self = this;
+ var data, values;
+ var measure = this.state.fields[this.state.measure].string;
+
+ // zero groupbys
+ if (this.state.groupedBy.length === 0) {
+ data = [{
+ values: [{
+ x: measure,
+ y: this.state.data[0].value}],
+ key: measure
+ }];
+ }
+ // one groupby
+ if (this.state.groupedBy.length === 1) {
+ values = this.state.data.map(function (datapt) {
+ return {x: datapt.labels, y: datapt.value};
+ });
+ data = [
+ {
+ values: values,
+ key: measure,
+ }
+ ];
+ }
+ if (this.state.groupedBy.length > 1) {
+ var xlabels = [],
+ series = [],
+ label, serie, value;
+ values = {};
+ for (var i = 0; i < this.state.data.length; i++) {
+ label = this.state.data[i].labels[0];
+ serie = this.state.data[i].labels[1];
+ value = this.state.data[i].value;
+ if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) {
+ xlabels.push(label);
+ }
+ series.push(this.state.data[i].labels[1]);
+ if (!(serie in values)) {values[serie] = {};}
+ values[serie][label] = this.state.data[i].value;
+ }
+ series = _.uniq(series);
+ data = [];
+ var current_serie, j;
+ for (i = 0; i < series.length; i++) {
+ current_serie = {values: [], key: series[i]};
+ for (j = 0; j < xlabels.length; j++) {
+ current_serie.values.push({
+ x: xlabels[j],
+ y: values[series[i]][xlabels[j]] || 0,
+ });
+ }
+ data.push(current_serie);
+ }
+ }
+ var svg = d3.select(this.$el[0]).append('svg');
+ svg.datum(data);
+
+ svg.transition().duration(0);
+
+ var chart = nv.models.multiBarChart();
+ chart.options({
+ margin: {left: 120, bottom: 60},
+ delay: 250,
+ transition: 10,
+ showLegend: _.size(data) <= MAX_LEGEND_LENGTH,
+ showXAxis: true,
+ showYAxis: true,
+ rightAlignYAxis: false,
+ stacked: this.stacked,
+ reduceXTicks: false,
+ rotateLabels: -20,
+ showControls: (this.state.groupedBy.length > 1)
+ });
+ chart.yAxis.tickFormat(function (d) {
+ var measure_field = self.state.fields[self.measure];
+ return formats.format_value(d, {
+ type: 'float',
+ digits: measure_field && measure_field.digits || [69, 2],
+ });
+ });
+
+ chart(svg);
+ return chart;
+ },
+ /**
+ * Helper function to set up data properly for the pieChart model in
+ * nvd3.
+ *
+ * @returns {nvd3 chart}
+ */
+ _renderPieChart: function () {
+ var data = [];
+ var all_negative = true;
+ var some_negative = false;
+ var all_zero = true;
+
+ this.state.data.forEach(function (datapt) {
+ all_negative = all_negative && (datapt.value < 0);
+ some_negative = some_negative || (datapt.value < 0);
+ all_zero = all_zero && (datapt.value === 0);
+ });
+ if (some_negative && !all_negative) {
+ this.$el.append(qweb.render('GraphView.error', {
+ title: _t("Invalid data"),
+ description: _t("Pie chart cannot mix positive and negative numbers. " +
+ "Try to change your domain to only display positive results"),
+ }));
+ return;
+ }
+ if (all_zero) {
+ this.$el.append(qweb.render('GraphView.error', {
+ title: _t("Invalid data"),
+ description: _t("Pie chart cannot display all zero numbers.. " +
+ "Try to change your domain to display positive results"),
+ }));
+ return;
+ }
+ if (this.state.groupedBy.length) {
+ data = this.state.data.map(function (datapt) {
+ return {x:datapt.labels.join("/"), y: datapt.value};
+ });
+ }
+ var svg = d3.select(this.$el[0]).append('svg');
+ svg.datum(data);
+
+ svg.transition().duration(100);
+
+ var legend_right = config.device.size_class > config.device.SIZES.XS;
+
+ var chart = nv.models.pieChart().labelType('percent');
+ chart.options({
+ delay: 250,
+ showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH,
+ legendPosition: legend_right ? 'right' : 'top',
+ transition: 100,
+ color: d3.scale.category10().range(),
+ });
+
+ chart(svg);
+ return chart;
+ },
+ /**
+ * Helper function to set up data properly for the line model in
+ * nvd3.
+ *
+ * @returns {nvd3 chart}
+ */
+ _renderLineChart: function () {
+ if (this.state.data.length < 2) {
+ this.$el.append(qweb.render('GraphView.error', {
+ title: _t("Not enough data points"),
+ description: "You need at least two data points to display a line chart."
+ }));
+ return;
+ }
+ var self = this;
+ var data = [];
+ var tickValues;
+ var tickFormat;
+ var measure = this.state.fields[this.state.measure].string;
+
+ if (this.state.groupedBy.length === 1) {
+ var values = this.state.data.map(function (datapt, index) {
+ return {x: index, y: datapt.value};
+ });
+ data = [
+ {
+ values: values,
+ key: measure,
+ }
+ ];
+ tickValues = this.state.data.map(function (d, i) { return i;});
+ tickFormat = function (d) {return self.state.data[d].labels;};
+ }
+ if (this.state.groupedBy.length > 1) {
+ data = [];
+ var data_dict = {};
+ var tick = 0;
+ var tickLabels = [];
+ var serie, tickLabel;
+ var identity = function (p) {return p;};
+ tickValues = [];
+ for (var i = 0; i < this.state.data.length; i++) {
+ if (this.state.data[i].labels[0] !== tickLabel) {
+ tickLabel = this.state.data[i].labels[0];
+ tickValues.push(tick);
+ tickLabels.push(tickLabel);
+ tick++;
+ }
+ serie = this.state.data[i].labels[1];
+ if (!data_dict[serie]) {
+ data_dict[serie] = {
+ values: [],
+ key: serie,
+ };
+ }
+ data_dict[serie].values.push({
+ x: tick, y: this.state.data[i].value,
+ });
+ data = _.map(data_dict, identity);
+ }
+ tickFormat = function (d) {return tickLabels[d];};
+ }
+
+ var svg = d3.select(this.$el[0]).append('svg');
+ svg.datum(data);
+
+ svg.transition().duration(0);
+
+ var chart = nv.models.lineChart();
+ chart.options({
+ margin: {left: 120, bottom: 60},
+ useInteractiveGuideline: true,
+ showLegend: _.size(data) <= MAX_LEGEND_LENGTH,
+ showXAxis: true,
+ showYAxis: true,
+ });
+ chart.xAxis.tickValues(tickValues)
+ .tickFormat(tickFormat);
+ chart.yAxis.tickFormat(function (d) {
+ return formats.format_value(d, {
+ type : 'float',
+ digits : self.state.fields[self.state.measure] && self.state.fields[self.state.measure].digits || [69, 2],
+ });
+ });
+
+ chart(svg);
+ return chart;
+ },
+});
+
+});
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 00000000000..3422a0922d3
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -0,0 +1,69 @@
+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 GraphView = AbstractView.extend({
+ display_name: _lt('Graph'),
+ icon: 'fa-bar-chart',
+ config: {
+ Model: GraphModel,
+ Controller: Controller,
+ Renderer: GraphRenderer,
+ },
+ /**
+ * @override
+ */
+ init: function (arch, fields) {
+ this._super.apply(this, arguments);
+
+ var initialMeasure = '__count__';
+ var initialGroupBys = [];
+ fields.__count__ = {string: _t("Count"), type: "integer"};
+ arch.children.forEach(function (field) {
+ var name = field.attrs.name;
+ if (field.attrs.interval) {
+ name += ':' + field.attrs.interval;
+ }
+ if (field.attrs.type === 'measure') {
+ initialMeasure = name;
+ } else {
+ initialGroupBys.push(name);
+ }
+ });
+
+ var measures = {__count__: {string: _t("Count"), type: "integer"}};
+ _.each(fields, function (field, name) {
+ if (name !== 'id' && field.store === true) {
+ if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') {
+ measures[name] = field;
+ }
+ }
+ });
+
+ this.controllerParams.measures = measures;
+ this.rendererParams.stacked = arch.attrs.stacked !== "False";
+
+ this.loadParams.initialMode = arch.attrs.type || 'bar';
+ this.loadParams.initialMeasure = initialMeasure;
+ this.loadParams.initialGroupBys = initialGroupBys;
+ this.loadParams.fields = fields;
+ },
+});
+
+return GraphView;
+
+});
diff --git a/addons/web/static/src/js/views/graph_view.js b/addons/web/static/src/js/views/graph_view.js
deleted file mode 100644
index 308c1359248..00000000000
--- a/addons/web/static/src/js/views/graph_view.js
+++ /dev/null
@@ -1,144 +0,0 @@
-odoo.define('web.GraphView', function (require) {
-"use strict";
-/*---------------------------------------------------------
- * Odoo Graph view
- *---------------------------------------------------------*/
-
-var core = require('web.core');
-var data_manager = require('web.data_manager');
-var GraphWidget = require('web.GraphWidget');
-var View = require('web.View');
-
-var _lt = core._lt;
-var _t = core._t;
-var QWeb = core.qweb;
-
-var GraphView = View.extend({
- className: 'o_graph',
- display_name: _lt('Graph'),
- icon: 'fa-bar-chart',
- require_fields: true,
-
- init: function () {
- this._super.apply(this, arguments);
-
- this.measures = [];
- this.active_measure = '__count__';
- this.initial_groupbys = [];
- this.widget = undefined;
- },
- willStart: function () {
- var self = this;
- var fields_def = data_manager.load_fields(this.dataset).then(this.prepare_fields.bind(this));
- this.fields_view.arch.children.forEach(function (field) {
- var name = field.attrs.name;
- if (field.attrs.interval) {
- name += ':' + field.attrs.interval;
- }
- if (field.attrs.type === 'measure') {
- self.active_measure = name;
- } else {
- self.initial_groupbys.push(name);
- }
- });
- return $.when(this._super(), fields_def);
- },
- /**
- * 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
- */
- render_buttons: function ($node) {
- if ($node) {
- var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
- this.$buttons = $(QWeb.render('GraphView.buttons', context));
- this.$measure_list = this.$buttons.find('.o_graph_measures_list');
- this.update_measure();
- this.$buttons.find('button').tooltip();
- this.$buttons.click(this.on_button_click.bind(this));
-
- this.$buttons.find('.o_graph_button[data-mode="' + this.widget.mode + '"]').addClass('active');
-
- this.$buttons.appendTo($node);
- }
- },
- update_measure: function () {
- var self = this;
- this.$measure_list.find('li').each(function (index, li) {
- $(li).toggleClass('selected', $(li).data('field') === self.active_measure);
- });
- },
- do_show: function () {
- this.do_push_state({});
- return this._super();
- },
- prepare_fields: function (fields) {
- var self = this;
- this.fields = fields;
- _.each(fields, function (field, name) {
- if ((name !== 'id') && (field.store === true)) {
- if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') {
- self.measures[name] = field;
- }
- }
- });
- this.measures.__count__ = {string: _t("Count"), type: "integer"};
- },
- do_search: function (domain, context, group_by) {
- if (!this.widget) {
- this.initial_groupbys = context.graph_groupbys || (group_by.length ? group_by : this.initial_groupbys);
- this.widget = new GraphWidget(this, this.model, {
- measure: context.graph_measure || this.active_measure,
- mode: context.graph_mode || this.active_mode,
- domain: domain,
- groupbys: this.initial_groupbys,
- context: context,
- fields: this.fields,
- stacked: this.fields_view.arch.attrs.stacked !== "False"
- });
- // append widget
- this.widget.appendTo(this.$el);
- } else {
- var groupbys = group_by.length ? group_by : this.initial_groupbys.slice(0);
- this.widget.update_data(domain, groupbys);
- }
- },
- get_context: function () {
- return !this.widget ? {} : {
- graph_mode: this.widget.mode,
- graph_measure: this.widget.measure,
- graph_groupbys: this.widget.groupbys
- };
- },
- on_button_click: function (event) {
- var $target = $(event.target);
- if ($target.hasClass('o_graph_button')) {
- this.widget.set_mode($target.data('mode'));
- this.$buttons.find('.o_graph_button.active').removeClass('active');
- $target.addClass('active');
- }
- else if ($target.parents('.o_graph_measures_list').length) {
- var parent = $target.parent();
- var field = parent.data('field');
- this.active_measure = field;
- event.preventDefault();
- event.stopPropagation();
- this.update_measure();
- this.widget.set_measure(this.active_measure);
- }
- },
- destroy: function () {
- if (this.$buttons) {
- this.$buttons.find('button').off(); // remove jquery's tooltip() handlers
- }
- return this._super.apply(this, arguments);
- },
-});
-
-core.view_registry.add('graph', GraphView);
-
-return GraphView;
-
-});
diff --git a/addons/web/static/src/js/views/graph_widget.js b/addons/web/static/src/js/views/graph_widget.js
deleted file mode 100644
index f778c7201ec..00000000000
--- a/addons/web/static/src/js/views/graph_widget.js
+++ /dev/null
@@ -1,340 +0,0 @@
-odoo.define('web.GraphWidget', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var Model = require('web.DataModel');
-var formats = require('web.formats');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-// hide top legend when too many item for device size
-var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class);
-
-return Widget.extend({
- className: "o_graph_svg_container",
- init: function (parent, model, options) {
- this._super(parent);
- this.context = options.context;
- this.fields = options.fields;
- this.fields.__count__ = {string: _t("Count"), type: "integer"};
- this.model = new Model(model, {group_by_no_leaf: true});
-
- this.domain = options.domain || [];
- this.groupbys = options.groupbys || [];
- this.mode = options.mode || "bar";
- this.measure = options.measure || "__count__";
- this.stacked = options.stacked;
- },
- start: function () {
- return this.load_data().then(this.proxy('display_graph'));
- },
- update_data: function (domain, groupbys) {
- this.domain = domain;
- this.groupbys = groupbys;
- return this.load_data().then(this.proxy('display_graph'));
- },
- set_mode: function (mode) {
- this.mode = mode;
- this.display_graph();
- },
- set_measure: function (measure) {
- this.measure = measure;
- return this.load_data().then(this.proxy('display_graph'));
- },
- load_data: function () {
- var fields = this.groupbys.slice(0);
- if (this.measure !== '__count__'.slice(0))
- fields = fields.concat(this.measure);
- return this.model
- .query(fields)
- .filter(this.domain)
- .context(this.context)
- .lazy(false)
- .group_by(this.groupbys.slice(0,2))
- .then(this.proxy('prepare_data'));
- },
- prepare_data: function () {
- var raw_data = arguments[0],
- is_count = this.measure === '__count__';
- var data_pt, j, values, value;
-
- this.data = [];
- for (var i = 0; i < raw_data.length; i++) {
- data_pt = raw_data[i].attributes;
- values = [];
- if (this.groupbys.length === 1) data_pt.value = [data_pt.value];
- for (j = 0; j < data_pt.value.length; j++) {
- values[j] = this.sanitize_value(data_pt.value[j], data_pt.grouped_on[j]);
- }
- value = is_count ? data_pt.length : data_pt.aggregates[this.measure];
- this.data.push({
- labels: values,
- value: value
- });
- }
- },
- sanitize_value: function (value, field) {
- if (value === false) return _t("Undefined");
- if (value instanceof Array) return value[1];
- if (field && this.fields[field] && (this.fields[field].type === 'selection')) {
- var selected = _.where(this.fields[field].selection, {0: value})[0];
- return selected ? selected[1] : value;
- }
- return value;
- },
- display_graph: function () {
- if (this.to_remove) {
- nv.utils.offWindowResize(this.to_remove);
- }
- this.$el.empty();
- if (!this.data.length) {
- this.$el.append(QWeb.render('GraphView.error', {
- title: _t("No data to display"),
- description: _t("No data available for this chart. " +
- "Try to add some records, or make sure that " +
- "there is no active filter in the search bar."),
- }));
- } else {
- var chart = this['display_' + this.mode]();
- if (chart) {
- chart.tooltip.chartContainer(this.$el[0]);
- }
- }
- },
- display_bar: function () {
- // prepare data for bar chart
- var data, values,
- measure = this.fields[this.measure].string,
- self = this;
-
- // zero groupbys
- if (this.groupbys.length === 0) {
- data = [{
- values: [{
- x: measure,
- y: this.data[0].value}],
- key: measure
- }];
- }
- // one groupby
- if (this.groupbys.length === 1) {
- values = this.data.map(function (datapt) {
- return {x: datapt.labels, y: datapt.value};
- });
- data = [
- {
- values: values,
- key: measure,
- }
- ];
- }
- if (this.groupbys.length > 1) {
- var xlabels = [],
- series = [],
- label, serie, value;
- values = {};
- for (var i = 0; i < this.data.length; i++) {
- label = this.data[i].labels[0];
- serie = this.data[i].labels[1];
- value = this.data[i].value;
- if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) {
- xlabels.push(label);
- }
- series.push(this.data[i].labels[1]);
- if (!(serie in values)) {values[serie] = {};}
- values[serie][label] = this.data[i].value;
- }
- series = _.uniq(series);
- data = [];
- var current_serie, j;
- for (i = 0; i < series.length; i++) {
- current_serie = {values: [], key: series[i]};
- for (j = 0; j < xlabels.length; j++) {
- current_serie.values.push({
- x: xlabels[j],
- y: values[series[i]][xlabels[j]] || 0,
- });
- }
- data.push(current_serie);
- }
- }
- var svg = d3.select(this.$el[0]).append('svg');
- svg.datum(data);
-
- svg.transition().duration(0);
-
- var chart = nv.models.multiBarChart();
- chart.options({
- margin: {left: 120, bottom: 60},
- delay: 250,
- transition: 10,
- showLegend: _.size(data) <= MAX_LEGEND_LENGTH,
- showXAxis: true,
- showYAxis: true,
- rightAlignYAxis: false,
- stacked: this.stacked,
- reduceXTicks: false,
- rotateLabels: -20,
- showControls: (this.groupbys.length > 1)
- });
- chart.yAxis.tickFormat(function(d) {
- return formats.format_value(d, {
- type : 'float',
- digits : self.fields[self.measure] && self.fields[self.measure].digits || [69, 2],
- });
- });
-
- chart(svg);
- this.to_remove = chart.update;
- nv.utils.onWindowResize(chart.update);
-
- return chart;
- },
- display_pie: function () {
- var data = [],
- all_negative = true,
- some_negative = false,
- all_zero = true;
-
- this.data.forEach(function (datapt) {
- all_negative = all_negative && (datapt.value < 0);
- some_negative = some_negative || (datapt.value < 0);
- all_zero = all_zero && (datapt.value === 0);
- });
- if (some_negative && !all_negative) {
- this.$el.append(QWeb.render('GraphView.error', {
- title: _t("Invalid data"),
- description: _t("Pie chart cannot mix positive and negative numbers. " +
- "Try to change your domain to only display positive results"),
- }));
- return;
- }
- if (all_zero) {
- this.$el.append(QWeb.render('GraphView.error', {
- title: _t("Invalid data"),
- description: _t("Pie chart cannot display all zero numbers.. " +
- "Try to change your domain to display positive results"),
- }));
- return;
- }
- if (this.groupbys.length) {
- data = this.data.map(function (datapt) {
- return {x:datapt.labels.join("/"), y: datapt.value};
- });
- }
- var svg = d3.select(this.$el[0]).append('svg');
- svg.datum(data);
-
- svg.transition().duration(100);
-
- var legend_right = config.device.size_class > config.device.SIZES.XS;
-
- var chart = nv.models.pieChart().labelType('percent');
- chart.options({
- delay: 250,
- showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH,
- legendPosition: legend_right ? 'right' : 'top',
- transition: 100,
- color: d3.scale.category10().range(),
- });
-
- chart(svg);
- this.to_remove = chart.update;
- nv.utils.onWindowResize(chart.update);
-
- return chart;
- },
- display_line: function () {
- if (this.data.length < 2) {
- this.$el.append(QWeb.render('GraphView.error', {
- title: _t("Not enough data points"),
- description: "You need at least two data points to display a line chart."
- }));
- return;
- }
- var self = this,
- data = [],
- tickValues,
- tickFormat,
- measure = this.fields[this.measure].string;
- if (this.groupbys.length === 1) {
- var values = this.data.map(function (datapt, index) {
- return {x: index, y: datapt.value};
- });
- data = [
- {
- values: values,
- key: measure,
- }
- ];
- tickValues = this.data.map(function (d, i) { return i;});
- tickFormat = function (d) {return self.data[d].labels;};
- }
- if (this.groupbys.length > 1) {
- data = [];
- var data_dict = {},
- tick = 0,
- tickLabels = [],
- serie, tickLabel,
- identity = function (p) {return p;};
- tickValues = [];
- for (var i = 0; i < this.data.length; i++) {
- if (this.data[i].labels[0] !== tickLabel) {
- tickLabel = this.data[i].labels[0];
- tickValues.push(tick);
- tickLabels.push(tickLabel);
- tick++;
- }
- serie = this.data[i].labels[1];
- if (!data_dict[serie]) {
- data_dict[serie] = {
- values: [],
- key: serie,
- };
- }
- data_dict[serie].values.push({
- x: tick, y: this.data[i].value,
- });
- data = _.map(data_dict, identity);
- }
- tickFormat = function (d) {return tickLabels[d];};
- }
-
- var svg = d3.select(this.$el[0]).append('svg');
- svg.datum(data);
-
- svg.transition().duration(0);
-
- var chart = nv.models.lineChart();
- chart.options({
- margin: {left: 120, bottom: 60},
- useInteractiveGuideline: true,
- showLegend: _.size(data) <= MAX_LEGEND_LENGTH,
- showXAxis: true,
- showYAxis: true,
- });
- chart.xAxis.tickValues(tickValues)
- .tickFormat(tickFormat);
- chart.yAxis.tickFormat(function(d) {
- return formats.format_value(d, {
- type : 'float',
- digits : self.fields[self.measure] && self.fields[self.measure].digits || [69, 2],
- });
- });
-
- chart(svg);
- this.to_remove = chart.update;
- nv.utils.onWindowResize(chart.update);
-
- return chart;
- },
- destroy: function () {
- nv.utils.offWindowResize(this.to_remove);
- return this._super();
- }
-});
-
-});
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 00000000000..1e010e1ab6d
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -0,0 +1,268 @@
+odoo.define('web.KanbanColumn', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var view_dialogs = require('web.view_dialogs');
+var quick_create = require('web.kanban_quick_create');
+var KanbanRecord = require('web.KanbanRecord');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var QWeb = core.qweb;
+var RecordQuickCreate = quick_create.RecordQuickCreate;
+
+var KanbanColumn = Widget.extend({
+ template: "KanbanView.Group",
+
+ events: {
+ 'click .o_column_edit': 'edit_column',
+ 'click .o_column_delete': 'delete_column',
+ 'click .o_column_archive': 'archive_records',
+ 'click .o_column_unarchive': 'unarchive_records',
+ 'click .o_kanban_quick_add': 'add_quick_create',
+ 'click .o_kanban_load_more': 'load_more',
+ 'click .o_kanban_toggle_fold': 'toggle_fold',
+ },
+
+ custom_events: {
+ 'kanban_record_delete': 'delete_record',
+ 'cancel_quick_create': 'cancel_quick_create',
+ 'quick_create_add_record': 'quick_create_add_record',
+ },
+
+ init: function (parent, data, options, record_options) {
+ this._super(parent);
+ this.db_id = data.id;
+ this.data_records = data.data;
+
+ var value = data.value;
+ this.id = data.res_id || value;
+ var field = data.fields[data.groupedBy[0]]; // fixme: grouped by field might not be in the fvg
+ if (field && field.type === "selection") {
+ value = _.find(field.selection, function (s) { return s[0] === data.value; })[1]; // fixme: same process done in list_renderer
+ }
+ // todo: handle group_by_m2o (nameget)
+ this.title = value || _t('Undefined');
+ this.folded = !data.isOpen;
+ this.has_active_field = _.contains(data.fieldNames, 'active');
+ this.size = data.count;
+ this.values = data.values;
+ this.fields = data.fields;
+ this.records = [];
+
+ this.quick_create = options.quick_create;
+ this.grouped_by_m2o = options.grouped_by_m2o;
+ this.editable = options.editable;
+ this.deletable = options.deletable;
+ this.draggable = record_options.draggable;
+ this.records_editable = options.records_editable;
+ this.records_deletable = options.records_deletable;
+ this.relation = options.relation;
+ this.offset = 0;
+ this.remaining = this.size - this.data_records.length;
+
+ this.record_options = _.extend(_.clone(record_options), {
+ group_info: this.values,
+ });
+
+ if (data.options && data.options.group_by_tooltip) {
+ var self = this;
+ this.tooltip_info = _.map(data.options.group_by_tooltip, function (key, value) {
+ return (self.values && self.values[value] && "<div>" +key + "<br>" + self.values[value] + "</div>") || '';
+ }).join('');
+ } else {
+ this.tooltip_info = "";
+ }
+ },
+
+ start: function () {
+ var self = this;
+ this.$header = this.$('.o_kanban_header');
+
+ for (var i = 0; i < this.data_records.length; i++) {
+ this.add_record(this.data_records[i], {no_update: true});
+ }
+ this.$header.tooltip();
+
+ if (config.device.size_class > config.device.SIZES.XS && this.draggable !== false) {
+ // deactivate sortable in mobile mode. It does not work anyway,
+ // and it breaks horizontal scrolling in kanban views. Someday, we
+ // should find a way to use the touch events to make sortable work.
+ this.$el.sortable({
+ connectWith: '.o_kanban_group',
+ revert: 0,
+ delay: 0,
+ items: '> .o_kanban_record:not(.o_updating)',
+ helper: 'clone',
+ cursor: 'move',
+ over: function () {
+ self.$el.addClass('o_kanban_hover');
+ self.update_column();
+ },
+ out: function () {
+ self.$el.removeClass('o_kanban_hover');
+ },
+ 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
+ ui.item.addClass('o_updating');
+ if (index >= 0) {
+ if ($.contains(self.$el[0], record.$el[0])) {
+ // resequencing records
+ self.trigger_up('kanban_column_resequence', {ids: self.get_ids()});
+ }
+ } else {
+ // adding record to this column
+ self.trigger_up('kanban_column_add_record', {record: record, ids: self.get_ids()});
+ }
+ }
+ });
+ }
+ this.$el.click(function (event) {
+ if (self.folded) {
+ self.toggle_fold(event);
+ }
+ });
+ this.update_column();
+ },
+
+ is_empty: function () {
+ return !this.records.length;
+ },
+
+ add_record: function (data, options) {
+ var record = new KanbanRecord(this, data, this.record_options);
+ this.records.push(record);
+ if (options.position === 'before') {
+ record.insertAfter(this.quick_create_widget ? this.quick_create_widget.$el : this.$header);
+ } else {
+ var $load_more = this.$('.o_kanban_load_more');
+ if ($load_more.length) {
+ record.insertBefore($load_more);
+ } else {
+ record.appendTo(this.$el);
+ }
+ }
+ if (!options.no_update) {
+ this.update_column();
+ }
+ },
+
+ get_ids: function () {
+ var ids = [];
+ this.$('.o_kanban_record').each(function (index, r) {
+ ids.push($(r).data('record').id);
+ });
+ return ids;
+ },
+
+ update_column: function () {
+ var title = this.folded ? this.title + ' (' + this.size + ')' : this.title;
+ this.$header.find('.o_column_title').text(title);
+ this.$header.find('.o-kanban-count').text(this.records.length);
+
+ this.$el.toggleClass('o_column_folded', this.folded);
+ var tooltip = this.size + _t(' records');
+ tooltip = '<p>' + tooltip + '</p>' + this.tooltip_info;
+ this.$header.tooltip({html: true}).attr('data-original-title', tooltip);
+ if (!this.remaining) {
+ this.$('.o_kanban_load_more').remove();
+ } else {
+ this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this}));
+ }
+ },
+
+ archive_records: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_column_archive_records', {archive: true});
+ },
+
+ unarchive_records: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_column_archive_records', {archive: false});
+ },
+
+ toggle_fold: function (event) {
+ event.preventDefault();
+ this.trigger_up('column_toggle_fold');
+ },
+
+ delete_column: 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();
+ },
+
+ edit_column: function (event) {
+ event.preventDefault();
+ var dialog = new view_dialogs.FormViewDialog(this, {
+ res_model: this.relation,
+ res_id: this.id,
+ title: _t("Edit Column"),
+ }).open();
+
+ dialog.on('saved', null, this.trigger_up.bind(this, 'reload'));
+ },
+
+ delete_record: function (event) {
+ var self = this;
+ event.data.parent_id = this.db_id;
+ event.data.after = function cleanup() {
+ var index = self.records.indexOf(event.data.record);
+ self.records.splice(index, 1);
+ self.update_column();
+ };
+ },
+
+ add_quick_create: function () {
+ if (this.quick_create_widget) {
+ return;
+ }
+ var self = this;
+ var width = this.records.length ? this.records[0].$el.innerWidth() : this.$el.width() - 8;
+ this.quick_create_widget = new RecordQuickCreate(this, width);
+ this.quick_create_widget.insertAfter(this.$header);
+ this.quick_create_widget.$el.focusout(function () {
+ var hasFocus = (self.quick_create_widget.$(':focus').length > 0);
+ if (! hasFocus && self.quick_create_widget) {
+ self.cancel_quick_create();
+ }
+ });
+ },
+
+ cancel_quick_create: function () {
+ this.quick_create_widget.destroy();
+ this.quick_create_widget = undefined;
+ },
+
+ quick_create_add_record: function (event) {
+ this.trigger_up('quick_create_record', event.data);
+ },
+
+ load_more: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_load_more');
+ },
+
+});
+
+return KanbanColumn;
+
+});
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 00000000000..df944c2bd03
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -0,0 +1,358 @@
+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 core = require('web.core');
+var data = require('web.data');
+var form_common = require('web.view_dialogs');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var KanbanController = BasicController.extend({
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ quick_create_add_column: '_onAddColumn',
+ quick_create_record: '_onQuickCreateRecord',
+ resequence_columns: '_onResequenceColumn',
+ kanban_do_action: '_onOpenAction',
+ kanban_record_delete: '_onRecordDelete',
+ kanban_record_update: '_onUpdateRecord',
+ kanban_column_delete: '_onDeleteColumn',
+ kanban_column_add_record: '_onAddRecordToColumn',
+ kanban_column_resequence: '_onColumnResequence',
+ kanban_column_archive_records: '_onArchiveRecords',
+ kanban_load_more: '_onLoadMore',
+ column_toggle_fold: '_onToggleColumn',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+
+ this.on_create = params.on_create;
+ this.hasButtons = params.hasButtons;
+
+ // true iff grouped by an m2o field and group_create action enabled
+ this.create_column_enabled = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {jQueryElement} $node
+ */
+ renderButtons: function ($node) {
+ var self = this;
+ if (this.hasButtons && this.is_action_enabled('create')) {
+ this.$buttons = $(qweb.render("KanbanView.buttons", {}));
+ this.$buttons.on('click', 'button.o-kanban-button-new', function () {
+ var data = self.model.get(self.handle, {raw: true});
+ if (data.groupedBy.length > 0 && data.count > 0 && self.on_create === 'quick_create') {
+ // Activate the quick create in the first column
+ self.renderer.add_quick_create();
+ } else if (self.on_create && self.on_create !== 'quick_create') {
+ // Execute the given action
+ self.do_action(self.on_create, {
+ on_close: self.reload.bind(self),
+ additional_context: data.context,
+ });
+ } else {
+ // Open the form view
+ self.trigger_up('switch_view', {
+ view_type: 'form',
+ res_id: undefined
+ });
+ }
+ });
+ this._updateButtons();
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * Override update method to recompute create_column_enabled.
+ *
+ * @returns {Deferred}
+ */
+ update: function () {
+ var state = this.model.get(this.handle, {raw: true});
+ var group_by_field = state.fields[state.groupedBy[0]];
+ var grouped_by_m2o = group_by_field && (group_by_field.type === 'many2one');
+ var groupCreate = this.is_action_enabled('group_create');
+ this.create_column_enabled = grouped_by_m2o && groupCreate;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override method comes from field manager mixin
+ * @param {string} id local id from the basic record data
+ */
+ _confirmSave: function (id) {
+ this.renderer.update_record(this.model.get(id));
+ },
+ /**
+ * @todo this is dead code right now. resurrect this?
+ *
+ * @returns {boolean}
+ */
+ _hasContent: function () {
+ return this._super.apply(this, arguments) || this.create_column_enabled;
+ },
+ /**
+ * 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.
+ *
+ * @param {string} column_id
+ * @param {string[]} ids
+ * @returns {Deferred}
+ */
+ _resequenceRecords: function (column_id, ids) {
+ return this.model.resequence(this.modelName, ids, column_id);
+ },
+ /**
+ * In grouped mode, set 'Create' button as btn-default if there is no column
+ * (except if we can't create new columns)
+ *
+ * @override from abstract controller
+ */
+ _updateButtons: function () {
+ if (this.$buttons) {
+ var data = this.model.get(this.handle, {raw: true});
+ var create_muted = data.count === 0 && this.create_column_enabled;
+ this.$buttons.find('.o-kanban-button-new')
+ .toggleClass('btn-primary', !create_muted)
+ .toggleClass('btn-default', create_muted);
+ }
+ },
+ /**
+ * @override
+ * @param {Object} state
+ * @returns {Deferred}
+ */
+ _update: function (state) {
+ var hasNoContent = state.count === 0 && !this.create_column_enabled;
+ this.$el.toggleClass('o_kanban_nocontent', hasNoContent);
+ this._toggleNoContentHelper(hasNoContent);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // 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
+ *
+ * @param {OdooEvent} event
+ */
+ _onAddColumn: function (event) {
+ var self = this;
+ this.model.createGroup(event.data.value, this.handle).then(function () {
+ self.update({}, {reload: false});
+ self.trigger_up('scrollTo', {selector: '.o_column_quick_create'});
+ });
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onAddRecordToColumn: function (event) {
+ var self = this;
+ var record = event.data.record;
+ var column = event.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, event.data.ids)
+ .then(function () {
+ _.each(column_db_ids, function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.update_column(db_id, data);
+ });
+ });
+ }).fail(this.reload.bind(this));
+ },
+ /**
+ * The interface allows in some case the user to archive a column. This is
+ * what this handler is for.
+ *
+ * @param {OdooEvent} event
+ */
+ _onArchiveRecords: function (event) {
+ var self = this;
+ var active_value = !event.data.archive;
+ var column = event.target;
+ var record_ids = [];
+ _.each(column.records, function (kanban_record) {
+ if (kanban_record.record.active.value !== active_value) {
+ record_ids.push(kanban_record.db_id);
+ }
+ });
+ if (record_ids.length) {
+ this.model
+ .toggleActive(record_ids, active_value, column.db_id)
+ .then(function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.update_column(db_id, data);
+ });
+ }
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onColumnResequence: function (event) {
+ this._resequenceRecords(event.target.db_id, event.data.ids);
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onDeleteColumn: function (event) {
+ var self = this;
+ var column = event.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, this.handle)
+ .done(function () {
+ if (column.is_empty()) {
+ self.renderer.remove_widget(column);
+ self._updateButtons();
+ } else {
+ self.reload();
+ }
+ });
+ },
+ /**
+ * This method comes from the field manager mixin. Since there is no
+ * onchange for kanban, we force a save for every change
+ *
+ * @override
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ event.data.force_save = true;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @param {OdooRevent} event
+ */
+ _onLoadMore: function (event) {
+ var self = this;
+ var column = event.target;
+ this.model.load_more(column.db_id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.update_column(db_id, data);
+ });
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onOpenAction: function (event) {
+ var self = this;
+ var record = event.target;
+ if (event.data.context) {
+ event.data.context = new data.CompoundContext(event.data.context)
+ .set_eval_context({
+ active_id: event.target.id,
+ active_ids: [event.target.id],
+ active_model: this.modelName,
+ });
+ }
+ this.trigger_up('execute_action', {
+ action_data: event.data,
+ model: this.modelName,
+ record_id: event.target.id,
+ on_close: function () {
+ self.model.reload(record.db_id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ record.update(data);
+ });
+ },
+ });
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onQuickCreateRecord: function (event) {
+ var self = this;
+ var column = event.target;
+ var data = this.model.get(column.db_id);
+ function add_record (records) {
+ return self.model
+ .addRecordToGroup(data.id, records[0])
+ .then(function (db_id) {
+ column.add_record(self.model.get(db_id), {position: 'before'});
+ });
+ }
+ data.context['default_' + data.groupedBy[0]] = column.id;
+ this.model
+ .nameCreate(data.model, event.data.value, data.context)
+ .then(add_record)
+ .fail(function (event) {
+ event.preventDefault();
+ var popup = new form_common.SelectCreatePopup(this);
+ popup.select_element(
+ data.model,
+ {
+ title: _t("Create: "),
+ initial_view: "form",
+ disable_multiple_selection: true,
+ },
+ [],
+ { default_name: event.data.value }
+ );
+ popup.on("elements_selected", null, add_record);
+ });
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onRecordDelete: function (event) {
+ this._deleteRecords([event.data.id]);
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onResequenceColumn: function (event) {
+ var grouped_by = this.model.get(this.handle).groupedBy[0];
+ var model = this.fields_view.fields[grouped_by].relation;
+ this.model.resequence(model, event.data.ids, this.handle);
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onToggleColumn: function (event) {
+ var self = this;
+ var column = event.target;
+ this.model.toggleGroup(column.db_id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.update_column(db_id, data);
+ });
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ _onUpdateRecord: function (event) {
+ var record = event.target;
+ this.alive(this.model.save(record.db_id, event.data))
+ .then(record.update.bind(record));
+ },
+});
+
+return KanbanController;
+
+});
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 00000000000..8970343c3a9
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -0,0 +1,126 @@
+odoo.define('web.KanbanModel', function (require) {
+"use strict";
+
+var BasicModel = require('web.BasicModel');
+
+return BasicModel.extend({
+ load: function (params) {
+ this.defaultGroupedBy = params.groupBy;
+ params.groupedBy = params.groupedBy.length ? params.groupedBy : this.defaultGroupedBy;
+ return this._super(params);
+ },
+ 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);
+ },
+ addRecordToGroup: function (group_id, res_id) {
+ var group = this.localData[group_id];
+ var new_record = this._makeDataPoint({
+ res_id: res_id,
+ modelName: group.model,
+ fields: group.fields,
+ fieldNames: group.fieldNames,
+ fieldAttrs: group.fieldAttrs,
+ });
+ group.data.unshift(new_record);
+ group.count++;
+ return this._fetchRecord(new_record)
+ // .then(this._fetch_relational_data.bind(this))
+ .then(function (result) {
+ return result.id;
+ });
+ },
+ moveRecord: function (record_id, group_id, parent_id) {
+ var self = this;
+ var parent = this.localData[parent_id];
+ var new_group = this.localData[group_id];
+ 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 {
+ changes[groupedFieldName] = new_group.value;
+ }
+ return this.notifyChanges(record_id, changes).then(function () {
+ return self.save(record_id);
+ }).then(function (result) {
+ // Remove record from its current group
+ var old_group;
+ for (var i = 0; i < parent.count; i++) {
+ old_group = self.localData[parent.data[i]];
+ var index = _.indexOf(old_group.data, record_id);
+ if (index >= 0) {
+ old_group.data.splice(index, 1);
+ old_group.count--;
+ break;
+ }
+ }
+ // Add record to its new group
+ new_group.data.push(result.id);
+ new_group.count++;
+ return [old_group.id, new_group.id];
+ });
+ },
+ resequence: function (modelName, res_ids, parent_id) {
+ if ((res_ids.length <= 1)) {
+ return $.when(parent_id); // there is nothing to sort
+ }
+ var self = this;
+ var data = self.localData[parent_id];
+ var def;
+ if (data.fields.sequence) {
+ def = this.performRPC('/web/dataset/resequence', {
+ model: modelName,
+ ids: res_ids,
+ // fixme: correctly handle context
+ });
+ }
+ return $.when(def).then(function () {
+ data.data = _.sortBy(data.data, function (d) {
+ return _.indexOf(res_ids, d.res_id);
+ });
+ return parent_id;
+ });
+ },
+ 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 $.Deferred().reject(); // only supported when grouped on m2o
+ }
+ return this.performModelRPC(groupByField.relation, 'name_create', [name], {
+ context: parent.context, // todo: combine with view context
+ }).then(function (result) {
+ var newGroup = self._makeDataPoint({
+ modelName: parent.model,
+ context: parent.context,
+ domain: parent.domain.concat([[groupBy,"=",result[0]]]),
+ fields: parent.fields,
+ fieldNames: parent.fieldNames,
+ groupedBy: parent.groupedBy,
+ isOpen: true,
+ limit: parent.limit,
+ openGroupByDefault: true,
+ orderedBy: parent.orderedBy,
+ value: result,
+ });
+
+ // newGroup.is_open = true;
+ parent.data.push(newGroup.id);
+ parent.count++;
+ return newGroup.id;
+ });
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_quick_create.js
new file mode 100644
index 00000000000..961ad1bfac3
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_quick_create.js
@@ -0,0 +1,140 @@
+odoo.define('web.kanban_quick_create', function (require) {
+"use strict";
+
+var Widget = require('web.Widget');
+
+var RecordQuickCreate = Widget.extend({
+ template: "KanbanView.QuickCreate",
+
+ events: {
+ 'click .o_kanban_cancel': function () {
+ this.cancel();
+ },
+ 'click .o_kanban_add': function () {
+ this.add_record();
+ },
+ 'keypress input': function (event) {
+ if (event.keyCode === 13) {
+ this.add_record();
+ }
+ },
+ 'keydown': function (event) {
+ if (event.keyCode === 27) {
+ this.cancel();
+ }
+ },
+ 'mousedown .o_kanban_add': 'suppress',
+ 'mousedown .o_kanban_cancel': 'suppress',
+ },
+
+ init: function (parent, width) {
+ this._super.apply(this, arguments);
+ this.width = width;
+ },
+
+ start: function () {
+ this.$el.css({width: this.width});
+ this.$input = this.$('input');
+ this.$input.focus();
+ return this._super.apply(this, arguments);
+ },
+
+ cancel: function () {
+ this.trigger_up('cancel_quick_create');
+ },
+
+ add_record: function () {
+ var value = this.$input.val();
+ this.$input.val('');
+ if (/^\s*$/.test(value)) { return; }
+ this.trigger_up('quick_create_add_record', {value: value});
+ this.$input.focus();
+ },
+ suppress: function (e) {
+ e.preventDefault();
+ },
+});
+
+var ColumnQuickCreate = Widget.extend({
+ template: 'KanbanView.ColumnQuickCreate',
+
+ events: {
+ 'click': 'toggle',
+ 'click .o_kanban_add': function (event) {
+ event.stopPropagation();
+ this.add_column();
+ },
+ 'click .o_kanban_cancel': function () {
+ this.folded = true;
+ this.update();
+ },
+ 'click .o_kanban_quick_create': function (event) {
+ event.stopPropagation();
+ },
+ 'keypress input': function (event) {
+ if (event.keyCode === 13) {
+ this.add_column();
+ }
+ },
+ 'keydown': function (event) {
+ if (event.keyCode === 27) {
+ this.folded = true;
+ this.update();
+ }
+ },
+ 'focusout': function () {
+ var hasFocus = this.$(':focus').length > 0;
+ if (hasFocus) { return; }
+
+ this.folded = true;
+ this.$input.val('');
+ this.update();
+ },
+ 'mousedown .o_kanban_add': 'suppress',
+ 'mousedown .o_kanban_cancel': 'suppress',
+ },
+
+ init: function () {
+ this._super.apply(this, arguments);
+ this.folded = true;
+ },
+
+ start: function () {
+ this.$header = this.$('.o_column_header');
+ this.$quick_create = this.$('.o_kanban_quick_create');
+ this.$input = this.$('input');
+ return this._super.apply(this, arguments);
+ },
+
+ toggle: function () {
+ this.folded = !this.folded;
+ this.update();
+ if (!this.folded) {
+ this.$input.focus();
+ this.trigger_up('scrollTo', {selector: '.o_column_quick_create'});
+ }
+ },
+
+ update: function () {
+ this.$header.toggle(this.folded);
+ this.$quick_create.toggle(!this.folded);
+ },
+
+ add_column: function () {
+ var name = this.$input.val();
+ this.$input.val('');
+ if (/^\s*$/.test(name)) { return; }
+ this.trigger_up('quick_create_add_column', {value: name});
+ this.$input.focus();
+ },
+ suppress: function (e) {
+ e.preventDefault();
+ },
+});
+
+return {
+ RecordQuickCreate: RecordQuickCreate,
+ ColumnQuickCreate: ColumnQuickCreate,
+};
+
+});
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 00000000000..b6713fdfc39
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_record.js
@@ -0,0 +1,403 @@
+odoo.define('web.KanbanRecord', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Domain = require('web.Domain');
+var field_registry = require('web.field_registry');
+var field_utils = require('web.field_utils');
+var formats = require('web.formats');
+var framework = require('web.framework');
+var session = require('web.session');
+var time = require('web.time');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+var KanbanRecord = Widget.extend({
+ template: 'KanbanView.record',
+
+ events: {
+ 'click .oe_kanban_action': 'on_kanban_action_clicked',
+ 'click .o_kanban_manage_toggle_button': 'toggle_manage_pane',
+ },
+
+ custom_events: {
+ 'kanban_update_record': 'update_record',
+ },
+
+ init: function (parent, state, options) {
+ this._super(parent);
+
+ this.fields = state.fields;
+ this.fieldAttrs = state.fieldAttrs;
+ this.recordData = state.data;
+ this.options = options;
+ this.editable = options.editable;
+ this.deletable = options.deletable;
+ this.draggable = options.draggable;
+ this.read_only_mode = options.read_only_mode;
+ this.model = options.model;
+ this.group_info = options.group_info;
+ this.qweb = options.qweb;
+ this.sub_widgets = {};
+
+ this.init_content(state);
+ },
+
+ renderElement: function () {
+ this._super();
+ this.setup_color();
+ this.setup_color_picker();
+ this.$el.addClass('o_kanban_record');
+ this.$el.data('record', this);
+ if (this.$el.hasClass('oe_kanban_global_click') || this.$el.hasClass('oe_kanban_global_click_edit')) {
+ this.$el.on('click', this.proxy('on_global_click'));
+ }
+ },
+
+ start: function () {
+ this.add_widgets();
+ this.render_m2m_tags();
+ this.attach_tooltip();
+ return this._super.apply(this, arguments);
+ },
+
+ init_content: function (state) {
+ var self = this;
+ this.state = state;
+ this.id = state.res_id;
+ this.db_id = state.id;
+ this.values = {};
+ _.each(state.data, function (v, k) {
+ self.values[k] = {
+ value: v
+ };
+ });
+ this.record = this.transform_record(state.data);
+ var qweb_context = {
+ record: this.record,
+ widget: this,
+ read_only_mode: this.read_only_mode,
+ user_context: session.user_context,
+ formats: formats,
+ };
+ for (var p in this) {
+ if (_.str.startsWith(p, 'kanban_')) {
+ qweb_context[p] = _.bind(this[p], this);
+ }
+ }
+ this.qweb_context = qweb_context;
+ this.content = this.qweb.render('kanban-box', qweb_context);
+ },
+
+ add_widgets: function () {
+ var self = this;
+ this.$("field").each(function () {
+ var $field = $(this);
+ var field_name = $field.attr("name");
+ var field_widget = $field.attr("widget");
+ if (field_widget) {
+ // a widget is specified for that field
+ var widget = self.sub_widgets[field_name];
+ if (!widget) {
+ // the widget doesn't exist yet, so instanciate it
+ var Widget = field_registry.getAny(['kanban.' + field_widget, field_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 attrs = Object.create(null);
+ _.each(self.state.fieldAttrs[field_name], function (value, key) {
+ if (_.str.startsWith(key, 't-att-')) {
+ key = key.slice(6);
+ value = $field.attr(key);
+ }
+ attrs[key] = value;
+ });
+ self.state.fieldAttrs[field_name] = attrs;
+
+ widget = new Widget(self, field_name, self.state, self.options);
+ self.sub_widgets[field_name] = widget;
+ self._set_field_display(widget, field_name);
+ widget.replace($field);
+ } else {
+ // a widget already exists for that field, so reset it with the new state
+ widget.reset(self.state);
+ $field.replaceWith(widget.$el);
+ }
+ } else {
+ // 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 = self.state.fields[field_name];
+ var recordData = self.state.data;
+ var value = recordData[field_name];
+ var options = { data: recordData };
+ var formatted_value = field_utils.format_field(value, field, options);
+ $field.replaceWith(formatted_value);
+ }
+ });
+ },
+
+ _set_field_display: function (widget, field_name) {
+ // attribute display
+ if (this.fieldAttrs[field_name].display === 'right') {
+ widget.$el.addClass('pull-right');
+ } else if (this.fieldAttrs[field_name].display === 'full') {
+ widget.$el.addClass('o_text_block');
+ }
+
+ // attribute bold
+ if (this.fieldAttrs[field_name].bold) {
+ widget.$el.addClass('o_text_bold');
+ }
+ },
+
+ render_m2m_tags: function () {
+ var self = this;
+ _.each(this.recordData, function (values, field_name) {
+ if (self.fields[field_name].type !== 'many2many') { return; }
+ var rel_ids = self.record[field_name].raw_value;
+ var $m2m_tags = self.$('.o_form_field_many2manytags[name=' + field_name + ']');
+ _.each(rel_ids, function (id) {
+ var m2m = _.findWhere(values.data, {res_id: id}).data;
+ if (typeof m2m.color !== 'undefined' && m2m.color !== 10) { // 10th color is invisible
+ $('<span>')
+ .addClass('o_tag o_tag_color_' + m2m.color)
+ .attr('title', _.str.escapeHTML(m2m.name))
+ .appendTo($m2m_tags);
+ }
+ });
+ });
+ // We use boostrap tooltips for better and faster display
+ this.$('span.o_tag').tooltip({delay: {'show': 50}});
+ },
+
+ transform_record: function (record) {
+ var self = this;
+ var new_record = {};
+ _.each(this.state.fieldNames, function (name) {
+ var value = record[name];
+ var r = _.clone(self.fields[name] || {});
+
+ if ((r.type === 'date' || r.type === 'datetime') && value) {
+ r.raw_value = time.auto_str_to_date(value);
+ } else if (r.type === 'one2many' || r.type === 'many2one' || r.type === 'many2many') {
+ r.raw_value = value ? value.res_ids : [];
+ } else {
+ r.raw_value = value;
+ }
+
+ if (r.type) {
+ var formatter = field_utils['format_' + r.type];
+ r.value = formatter(value, self.fields[name], record, self.state);
+ } else {
+ r.value = value;
+ }
+
+ new_record[name] = r;
+ });
+ return new_record;
+ },
+
+ update: function (record) {
+ // 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.sub_widgets, '$el'), 'detach');
+ this.init_content(record);
+ this.renderElement();
+ this.add_widgets();
+ this.render_m2m_tags();
+ this.attach_tooltip();
+ },
+
+ attach_tooltip: function () {
+ var self = this;
+ this.$('[tooltip]').each(function () {
+ var $el = $(this);
+ var tooltip = $el.attr('tooltip');
+ if (tooltip) {
+ $el.tooltip({
+ 'html': true,
+ 'title': self.qweb.render(tooltip, self.qweb_context)
+ });
+ }
+ });
+ },
+
+ kanban_image: function (model, field, id, cache, options) {
+ options = options || {};
+ var url;
+ if (this.record[field] && this.record[field].value && !utils.is_bin_size(this.record[field].value)) {
+ url = 'data:image/png;base64,' + this.record[field].value;
+ } else if (this.record[field] && ! this.record[field].value) {
+ url = "/web/static/src/img/placeholder.png";
+ } else {
+ if (_.isArray(id)) { id = id[0]; }
+ if (!id) { id = undefined; }
+ if (options.preview_image)
+ field = options.preview_image;
+ var unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, '');
+ url = session.url('/web/image', {model: model, field: field, id: id, unique: unique});
+ if (cache !== undefined) {
+ // Set the cache duration in seconds.
+ url += '&cache=' + parseInt(cache, 10);
+ }
+ }
+ return url;
+ },
+
+ kanban_getcolor: function (variable) {
+ if (typeof(variable) === 'number') {
+ return Math.round(variable) % 10;
+ }
+ if (typeof(variable) === 'string') {
+ var index = 0;
+ for (var i=0; i<variable.length; i++) {
+ index += variable.charCodeAt(i);
+ }
+ return index % 10;
+ }
+ return 0;
+ },
+
+ kanban_color: function (variable) {
+ var color = this.kanban_getcolor(variable);
+ return 'oe_kanban_color_' + color;
+ },
+
+ on_global_click: function (ev) {
+ if ($(ev.target).parents('.o_dropdown_kanban').length) {
+ return;
+ }
+ var trigger = true;
+ var elem = ev.target;
+ var ischild = true;
+ var children = [];
+ while (elem) {
+ var events = $._data(elem, 'events');
+ if (elem === ev.currentTarget) {
+ ischild = false;
+ }
+ var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== "tooltip");
+ if (ischild) {
+ children.push(elem);
+ if (test_event) {
+ // do not trigger global click if one child has a click event registered
+ 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.on_card_clicked(ev);
+ }
+ },
+
+ /* actions when user click on the block with a specific class
+ * open on normal view : oe_kanban_global_click
+ * open on form/edit view : oe_kanban_global_click_edit
+ */
+ on_card_clicked: function () {
+ if (this.$el.hasClass('oe_kanban_global_click_edit') && this.$el.data('routing')) {
+ framework.redirect(this.$el.data('routing') + "/" + this.id);
+ } else if (this.$el.hasClass('oe_kanban_global_click_edit')) {
+ this.trigger_up('edit_record', {id: this.db_id});
+ } else {
+ this.trigger_up('open_record', {id: this.db_id});
+ }
+ },
+
+ toggle_manage_pane: function (event){
+ event.preventDefault();
+ this.$('.o_kanban_card_content').toggleClass('o_visible o_invisible');
+ this.$('.o_kanban_card_manage_pane').toggleClass('o_visible o_invisible');
+ this.$('.o_kanban_manage_button_section').toggleClass(this.kanban_color((this.values['color'] && this.values['color'].value) || 0));
+ },
+
+ on_kanban_action_clicked: function (ev) {
+ ev.preventDefault();
+
+ var $action = $(ev.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':
+ this.trigger_up('kanban_do_action', $action.data());
+ break;
+ default:
+ this.do_warn("Kanban: no action for type : " + type);
+ }
+ },
+
+ kanban_compute_domain: function (d) {
+ return new Domain(d).compute(this.values);
+ },
+
+ update_record: function (event) {
+ this.trigger_up('kanban_record_update', event.data);
+ },
+
+ /*
+ * If an attribute `color` is set on the kanban record,
+ * this will add the corresponding color class.
+ */
+ setup_color: function () {
+ var color_field = this.$el.attr('color');
+ if (color_field && color_field in this.fields) {
+ this.$el.addClass(this.kanban_color(this.values[color_field].value));
+ }
+ },
+
+ setup_color_picker: function () {
+ var self = this;
+ var $colorpicker = this.$('ul.oe_kanban_colorpicker');
+ if (!$colorpicker.length) {
+ return;
+ }
+ $colorpicker.html(QWeb.render('KanbanColorPicker', {
+ widget: this
+ }));
+ $colorpicker.on('click', 'a', function (ev) {
+ ev.preventDefault();
+ var color_field = $colorpicker.data('field') || 'color';
+ var data = {};
+ data[color_field] = $(this).data('color');
+ self.trigger_up('kanban_record_update', data);
+ });
+ },
+
+});
+
+return KanbanRecord;
+
+});
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 00000000000..4259e35d88a
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -0,0 +1,255 @@
+odoo.define('web.KanbanRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+var core = require('web.core');
+var field_registry = require('web.field_registry');
+var KanbanColumn = require('web.KanbanColumn');
+var KanbanRecord = require('web.KanbanRecord');
+var quick_create = require('web.kanban_quick_create');
+var QWeb = require('web.QWeb');
+var session = require('web.session');
+var utils = require('web.utils');
+
+var ColumnQuickCreate = quick_create.ColumnQuickCreate;
+
+var qweb = core.qweb;
+
+function find_in_node(node, predicate) {
+ if (predicate(node)) {
+ return node;
+ }
+ if (!node.children) {
+ return undefined;
+ }
+ for (var i = 0; i < node.children.length; i++) {
+ if (find_in_node(node.children[i], predicate)) {
+ return node.children[i];
+ }
+ }
+}
+
+function qweb_add_if(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 transform_qweb_template(node, fields) {
+ // Process modifiers
+ if (node.tag && node.attrs.modifiers) {
+ var modifiers = JSON.parse(node.attrs.modifiers || "{}");
+ if (modifiers.invisible) {
+ qweb_add_if(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible)));
+ }
+ }
+ switch (node.tag) {
+ case 'field':
+ var ftype = fields[node.attrs.name].type;
+ ftype = node.attrs.widget ? node.attrs.widget : ftype;
+ if (ftype === 'many2many') {
+ node.tag = 'div';
+ node.attrs['class'] = (node.attrs['class'] || '') + ' oe_form_field o_form_field_many2manytags o_kanban_tags';
+ } else if (field_registry.contains(ftype)) {
+ // do nothing, the kanban record will handle it
+ } else {
+ node.tag = 'span';
+ var child_node = {
+ attrs: {},
+ children: [],
+ tag: qweb.prefix,
+ };
+ child_node.attrs[qweb.prefix + '-esc'] = 'record.' + node.attrs.name + '.value';
+ node.children.push(child_node);
+
+ // set class according to field attribute
+ var child_classes = [];
+ if (node.attrs.display === 'right') {
+ child_classes.push('pull-right');
+ } else if (node.attrs.display === 'full') {
+ child_classes.push('o_text_block');
+ }
+ if (node.attrs.bold) {
+ child_classes.push('o_text_bold');
+ }
+ node.attrs['class'] = child_classes.join(' ');
+ }
+ break;
+ 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++) {
+ transform_qweb_template(node.children[i], fields);
+ }
+ }
+}
+
+return BasicRenderer.extend({
+ className: "o_kanban_view",
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+
+ this.qweb = new QWeb(session.debug, {_s: session.origin});
+ var templates = find_in_node(this.arch, function (n) { return n.tag === 'templates';});
+ transform_qweb_template(templates, state.fields);
+ this.qweb.add_template(utils.json_node_to_xml(templates));
+
+ this.record_options = _.extend({}, params.record_options, { qweb: this.qweb });
+ this.column_options = _.extend({}, params.column_options, { qweb: this.qweb });
+ },
+ update: function (state, fields) {
+ this.fields = fields;
+ return this._super(state);
+ },
+ update_record: function (record_state) {
+ var is_grouped = !!this.state.groupedBy.length;
+ var record;
+
+ if (is_grouped) {
+ // 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: record_state.id});
+ });
+ } else {
+ record = _.findWhere(this.widgets, {db_id: record_state.id});
+ }
+
+ if (record) {
+ record.update(record_state);
+ }
+ },
+ update_column: function (column_db_id, data) {
+ var column = _.findWhere(this.widgets, {db_id: column_db_id});
+ this.widgets.splice(_.indexOf(this.widgets, column), 1); // remove column from widgets' list
+ var new_column = new KanbanColumn(this, data, this.column_options, this.record_options);
+ this.widgets.push(new_column);
+ return new_column.insertAfter(column.$el).then(column.destroy.bind(column));
+ },
+ _renderView: function () {
+ var is_grouped = !!this.state.groupedBy.length;
+ this.$el.toggleClass('o_kanban_grouped', is_grouped);
+ this.$el.toggleClass('o_kanban_ungrouped', !is_grouped);
+ this.$el.empty();
+
+ var fragment = document.createDocumentFragment();
+ if (is_grouped) {
+ this._renderGrouped(fragment);
+ } else {
+ this._render_ungrouped(fragment);
+ }
+ this.$el.append(fragment);
+ return this._super.apply(this, arguments);
+ },
+ _render_ungrouped: function (fragment) {
+ var self = this;
+ _.each(this.state.data, function (record) {
+ var kanban_record = new KanbanRecord(self, record, self.record_options);
+ self.widgets.push(kanban_record);
+ kanban_record.appendTo(fragment);
+ });
+
+ // add empty invisible divs to make sure that all kanban records are left aligned
+ for (var i = 0, ghost_div; i < 6; i++) {
+ ghost_div = $("<div>").addClass("o_kanban_record o_kanban_ghost");
+ ghost_div.appendTo(fragment);
+ }
+ },
+ _renderGrouped: function (fragment) {
+ var self = this;
+ var group_by_field_attrs = this.state.fields[this.state.groupedBy[0]];
+ // Deactivate the drag'n'drop if the groupedBy field:
+ // - is a date or datetime since we group by month or
+ // - is readonly
+ var draggable = true;
+ if (group_by_field_attrs) {
+ if (group_by_field_attrs.type === "date" || group_by_field_attrs.type === "datetime") {
+ draggable = false;
+ } else if (group_by_field_attrs.readonly !== undefined) {
+ draggable = !(group_by_field_attrs.readonly);
+ }
+ }
+ var grouped_by_m2o = group_by_field_attrs && (group_by_field_attrs.type === 'many2one');
+ var grouped_by_field = grouped_by_m2o && group_by_field_attrs.relation;
+ this.column_options = _.extend(this.column_options, {
+ draggable: draggable,
+ grouped_by_m2o: grouped_by_m2o,
+ relation: grouped_by_field,
+ });
+
+ // Render columns
+ _.each(this.state.data, function (group) {
+ var column = new KanbanColumn(self, group, self.column_options, self.record_options);
+ if (!group.value) {
+ column.prependTo(fragment); // display the 'Undefined' group first
+ self.widgets.unshift(column);
+ } else {
+ column.appendTo(fragment);
+ self.widgets.push(column);
+ }
+ });
+
+ // Enable column sorting
+ this.$el.sortable({
+ axis: 'x',
+ items: '> .o_kanban_group',
+ handle: '.o_kanban_header',
+ cursor: 'move',
+ revert: 150,
+ delay: 100,
+ tolerance: 'pointer',
+ forcePlaceholderSize: true,
+ stop: function () {
+ var ids = [];
+ self.$('.o_kanban_group').each(function (index, u) {
+ ids.push($(u).data('id'));
+ });
+ self.trigger_up('resequence_columns', {ids: ids});
+ },
+ });
+
+ // Column quick create
+ if (this.column_options.group_creatable && grouped_by_m2o) {
+ this.column_quick_create = new ColumnQuickCreate(this);
+ this.column_quick_create.appendTo(fragment);
+ }
+ },
+ add_quick_create: function () {
+ this.widgets[0].add_quick_create();
+ },
+ remove_widget: function (widget) {
+ this.widgets.splice(this.widgets.indexOf(widget), 1);
+ widget.destroy();
+ }
+});
+
+});
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 00000000000..5ba06b08dad
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -0,0 +1,83 @@
+odoo.define('web.KanbanView', function (require) {
+"use strict";
+
+var BasicView = require('web.BasicView');
+var core = require('web.core');
+var KanbanModel = require('web.KanbanModel');
+var KanbanRenderer = require('web.KanbanRenderer');
+var KanbanController = require('web.KanbanController');
+
+var _lt = core._lt;
+
+var KanbanView = BasicView.extend({
+ accesskey: "k",
+ className: "o_kanban",
+ display_name: _lt("Kanban"),
+ icon: 'fa-th-large',
+ mobile_friendly: true,
+ config: {
+ Model: KanbanModel,
+ Controller: KanbanController,
+ Renderer: KanbanRenderer,
+ },
+ init: function (arch, fields, params) {
+ this._super.apply(this, arguments);
+
+ var activeActions = this.controllerParams.activeActions;
+ activeActions = _.extend(activeActions, {
+ group_edit: arch.attrs.group_edit ? JSON.parse(arch.attrs.group_edit) : true,
+ group_delete: arch.attrs.group_delete ? JSON.parse(arch.attrs.group_delete) : true,
+ });
+
+ this.rendererParams.column_options = {
+ editable: activeActions.group_edit,
+ deletable: activeActions.group_delete,
+ group_creatable: true,
+ quick_create: params.isQuickCreateEnabled || this._isQuickCreateEnabled(arch),
+ };
+ this.rendererParams.record_options = {
+ editable: activeActions.edit,
+ deletable: activeActions.delete,
+ model: this.model, // required by includes of KanbanRecord
+ read_only_mode: params.readOnlyMode,
+ };
+
+ this.controllerParams.on_create = arch.attrs.on_create;
+
+ this.controllerParams.readOnlyMode = false;
+ this.controllerParams.hasButtons = true;
+
+ this.loadParams.limit = this.loadParams.limit || 40;
+ this.loadParams.openGroupByDefault = true;
+ this.loadParams.type = 'list';
+
+ this.loadParams.groupBy = arch.attrs.default_group_by ? [arch.attrs.default_group_by] : (params.groupBy || []);
+ },
+ _isQuickCreateEnabled: function (arch) {
+ if (!this.controllerParams.activeActions.create) {
+ return false;
+ }
+ if (arch.attrs.quick_create !== undefined) {
+ return JSON.parse(arch.attrs.quick_create);
+ }
+ return true;
+ }
+
+
+});
+
+return KanbanView;
+
+});
+
+ // config: _.extend({}, AbstractView.prototype.config, {
+ // openGroupByDefault: true,
+ // }),
+ // FIXME: adapt those options
+ // defaults: _.extend(BasicController.prototype.defaults, {
+ // quick_creatable: true,
+ // creatable: true,
+ // read_only_mode: false,
+ // confirm_on_delete: true,
+ // }),
+
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 00000000000..881a1eb24ec
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -0,0 +1,353 @@
+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 pyeval = require('web.pyeval');
+var Sidebar = require('web.Sidebar');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var ListController = BasicController.extend({
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ list_button_clicked: '_onListButtonClicked',
+ change_mode: '_onChangeMode',
+ selection_changed: '_onSelectionChanged',
+ toggle_column_order: '_onToggleColumnOrder',
+ toggle_group: '_onToggleGroup',
+ }),
+ /**
+ * @constructor
+ * @override
+ * @param {Object} params
+ * @param {Object} params
+ * @param {boolean} params.editable
+ * @param {boolean} params.hasSidebar
+ * @param {boolean} params.hasToolbar
+ * @param {boolean} params.noLeaf
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.hasSidebar = params.hasSidebar;
+ this.hasToolbar = params.hasToolbar;
+ this.editable = params.editable;
+ this.noLeaf = params.noLeaf;
+ this.selectedRecords = []; // there is no selected record by default
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Calculate the active domain of the list view. This should be done only
+ * if the header checkbox has been checked. This is done by evaluating the
+ * search results, and then adding the dataset domain (i.e. action domain).
+ *
+ * @todo This is done only for the sidebar. The full mechanism is wrong,
+ * this method should be private, most of the code in the sidebar should be
+ * moved to the controller, and we should not use the getParent method...
+ *
+ * @returns {Deferred<array[]>} a deferred that resolve to the active domain
+ */
+ getActiveDomain: function () {
+ // TODO: this method should be synchronous...
+ var self = this;
+ if (this.$('thead .o_list_record_selector input').prop('checked')) {
+ var searchView = this.getParent().searchview; // fixme
+ var searchData = searchView.build_search_data();
+ var userContext = this.getSession().user_context;
+ var results = pyeval.eval_domains_and_contexts({
+ domains: searchData.domains,
+ contexts: [userContext].concat(searchData.contexts),
+ group_by_seq: searchData.groupbys || []
+ });
+ return $.when(self.dataset.domain.concat(results.domain || []));
+ } else {
+ return $.Deferred().resolve();
+ }
+ },
+ /**
+ * Returns the list of currently selected res_ids (with the check boxes on
+ * the left)
+ *
+ * @todo This should be private. Need to change sidebar code
+ *
+ * @returns {number[]} list of res_ids
+ */
+ getSelectedIds: function () {
+ var self = this;
+ return _.map(this.selectedRecords, function (db_id) {
+ return self.model.get(db_id, {raw: true}).res_id;
+ });
+ },
+ /**
+ * Display and bind all buttons in the control panel
+ *
+ * @override
+ * @param {jQuery Node} $node
+ */
+ renderButtons: function ($node) {
+ if (!this.noLeaf && this.hasButtons) {
+ this.$buttons = $(qweb.render("ListView.buttons", {'widget': this}));
+ this.$buttons.find('.o_list_button_add')
+ .click(this._onCreateRecord.bind(this));
+ this.$buttons.find('.o_list_button_save')
+ .click(this._onSave.bind(this));
+ this.$buttons.find('.o_list_button_discard')
+ .click(this._onDiscard.bind(this));
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * Render the sidebar (the 'action' menu in the control panel, right of the
+ * main buttons)
+ *
+ * @param {jQuery Node} $node
+ */
+ renderSidebar: function ($node) {
+ if (this.hasSidebar && !this.sidebar) {
+ this.sidebar = new Sidebar(this, {
+ editable: this.is_action_enabled('edit')
+ });
+ if (this.hasToolbar) {
+ this.sidebar.add_toolbar(this.fields_view.toolbar);
+ }
+ var other = [{
+ label: _t("Export"),
+ callback: this._onExportData.bind(this)
+ }];
+ if (this.archiveEnabled) {
+ other.push({
+ label: _t("Archive"),
+ callback: this._onToggleArchiveState.bind(this, true)
+ });
+ other.push({
+ label: _t("Unarchive"),
+ callback: this._onToggleArchiveState.bind(this, false)
+ });
+ }
+ if (this.is_action_enabled('delete')) {
+ other.push({
+ label: _t('Delete'),
+ callback: this._onDeleteSelectedRecords.bind(this)
+ });
+ }
+ this.sidebar.add_items('other', other);
+ this.sidebar.appendTo($node);
+
+ this._toggleSidebar();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _archive: function (ids, archive) {
+ if (ids.length === 0) {
+ return $.when();
+ }
+ return this.model
+ .toggleActive(ids, !archive, this.handle)
+ .then(this.update_state.bind(this));
+ },
+ /**
+ * This function is the hook called by the field manager mixin to confirm
+ * that a change just occured (after the eventual onchange have been
+ * applied).
+ *
+ * @param {string} id a basicmodel id, which is a record in the list view
+ * @param {string[]} fields the changed fields
+ */
+ _confirmChange: function (id, fields) {
+ var state = this.model.get(this.handle);
+ this.renderer.confirmChange(state, id, fields);
+ },
+ /**
+ * 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.
+ */
+ _confirmSave: function (id) {
+ var state = this.model.get(this.handle);
+ this.renderer.confirmSave(state, id);
+ },
+ /**
+ * Display the sidebar (the 'action' menu in the control panel) if we have
+ * some selected records.
+ */
+ _toggleSidebar: function () {
+ if (this.sidebar) {
+ this.sidebar.do_toggle(this.selectedRecords.length > 0);
+ }
+ },
+ /**
+ * @override
+ * @param {Object} state
+ * @returns {Deferred}
+ */
+ _update: function (state) {
+ this._toggleNoContentHelper(state.count === 0);
+ this._toggleSidebar();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * This helper simply makes sure that the control panel buttons matches the
+ * current mode.
+ *
+ * @param {string} mode either 'readonly' or 'edit'
+ */
+ _updateButtons: function (mode) {
+ if (this.$buttons) {
+ this.$buttons.toggleClass('o-editing', mode === 'edit');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles a click on a button by performing its action.
+ *
+ * @param {OdooEvent} event
+ */
+ _onListButtonClicked: function (event) {
+ this.trigger_up('execute_action', {
+ action_data: event.data.attrs,
+ model: this.modelName,
+ record_id: event.data.record.res_id,
+ });
+ },
+ /**
+ * This event is triggered when a list renderer goes from mode = readonly to
+ * edit, and vice versa. In that case, we need to make sure that the buttons
+ * displayed in the control panel are correct.
+ *
+ * @param {OdooEvent} event
+ */
+ _onChangeMode: function (event) {
+ this._updateButtons(event.data.mode);
+ },
+ /**
+ * 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.
+ *
+ * @param {MouseEvent} event
+ */
+ _onCreateRecord: function (event) {
+ // 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
+ event.stopPropagation();
+
+ var self = this;
+ if (this.editable) {
+ this.model.addDefaultRecord(this.handle).then(function (recordID) {
+ self._toggleNoContentHelper(false);
+ var state = self.model.get(self.handle);
+ self.renderer.updateState(state);
+ self.renderer.editRecord(recordID);
+ });
+ } else {
+ this.trigger_up('switch_view', {view_type: 'form', res_id: undefined});
+ }
+ },
+ /**
+ * Called when the 'delete' action is clicked on in the side bar.
+ */
+ _onDeleteSelectedRecords: function () {
+ this._deleteRecords(this.selectedRecords);
+ },
+ /**
+ * Handler called when the user clicked on the 'Discard' button.
+ */
+ _onDiscard: function () {
+ this.model.discardChanges(this.handle);
+ this.update(this.handle, {reload: false});
+ },
+ /**
+ * Opens the Export Dialog
+ */
+ _onExportData: function () {
+ new DataExport(this, this.dataset).open();
+ },
+ /**
+ * changes of the list editable are automatically saved when unselecting the
+ * row, which is done when clicking on 'Save' (anywhere outside the row
+ * actually), so this function should only switch back to readonly mode
+ *
+ * @param {MouseEvent} event
+ */
+ _onSave: function (event) {
+ // 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
+ // event.stopPropagation();
+ this._updateButtons('readonly');
+ },
+ /**
+ * When the current selection changes (by clicking on the checkboxes on the
+ * left), we need to display (or hide) the 'sidebar'.
+ *
+ * @param {OdooEvent} event
+ */
+ _onSelectionChanged: function (event) {
+ this.selectedRecords = event.data.selection;
+ this._toggleSidebar();
+ },
+ /**
+ * Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
+ *
+ * @param {boolean} archive
+ */
+ _onToggleArchiveState: function (archive) {
+ this._archive(this.selectedRecords, archive);
+ },
+ /**
+ * 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.
+ *
+ * @param {OdooEvent} event
+ */
+ _onToggleColumnOrder: function (event) {
+ var data = this.model.get(this.handle);
+ if (!data.groupedBy) {
+ this.pager.updateState({current_min: 1});
+ }
+ this.model
+ .setSort(data.id, event.data.name)
+ .reload(data.id)
+ .then(this.update.bind(this));
+ },
+ /**
+ * 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.
+ *
+ * @param {OdooEvent} event
+ */
+ _onToggleGroup: function (event) {
+ this.model
+ .toggleGroup(event.data.group.id)
+ .then(this.update.bind(this, {}, {reload: false}));
+ },
+});
+
+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 00000000000..85efc1b6d52
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -0,0 +1,649 @@
+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
+ * behavior 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 Domain = require('web.Domain');
+var ListRenderer = require('web.ListRenderer');
+var field_registry = require('web.field_registry');
+
+var _t = core._t;
+
+ListRenderer.include({
+ custom_events: {
+ field_changed: '_onFieldChanged',
+ move_down: '_onMoveDown',
+ move_up: '_onMoveUp',
+ move_left: '_onMoveLeft',
+ move_right: '_onMoveRight',
+ move_next: '_onMoveNext',
+ move_next_line: '_onMoveNextLine',
+ },
+ events: _.extend({}, ListRenderer.prototype.events, {
+ 'click tbody td': '_onCellClick',
+ 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
+ 'click tfoot': '_onFooterClick',
+ 'click tr .o_list_record_delete': '_onTrashIconClick',
+ 'click .o_form_field_x2many_list_row_add a': '_onAddRecord',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {string} params.mode either 'readonly' or 'edit'
+ * @param {boolean} params.addCreateLine
+ * @param {boolean} params.addTrashIcon
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+
+ this.mode = params.mode;
+
+ // if addCreateLine is true, the renderer will add a 'Add an item' link
+ // at the bottom of the list view
+ this.addCreateLine = params.addCreateLine;
+
+ // 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;
+
+ this.currentRow = null;
+ this.currentCol = null;
+ },
+ /**
+ * @override
+ * @returns {Deferred}
+ */
+ start: function () {
+ if (this.mode === 'edit') {
+ this.$el.css({height: '100%'});
+ core.bus.on('click', this, this._onWindowClicked.bind(this));
+ }
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is called by the controller, when a change has been confirmed
+ * by the model. In that situation, we need to properly update the cell
+ * widgets. (it is necessary, because the value could have been changed by
+ * an onchange).
+ *
+ * Note that it could happen that the changedRecordID is not found in the
+ * current state. This is possible if a change in a record caused an
+ * onchange which erased the current record. In that case, we simply
+ * rerender the list.
+ *
+ * @param {Object} state a record resource (the new full state)
+ * @param {string} changedRecordID the local id for the changed record
+ * @param {string[]} changedFields list of modified fields
+ */
+ confirmChange: function (state, changedRecordID, changedFields) {
+ this.state = state;
+ var rowIndex = _.findIndex(this.state.data, {id: changedRecordID});
+ if (rowIndex > -1) {
+ this._updateRow(rowIndex, changedFields);
+ } else {
+ this._render();
+ }
+ },
+ /**
+ * This method is called by the controller when the user has clicked 'save',
+ * and the model as actually saved the current changes. We then need to
+ * set the correct values for each cell. The difference with confirmChange
+ * is that the edited line is now in readonly mode, so we can just set its
+ * new value.
+ *
+ * @param {Object} state the new full state for the list
+ * @param {string} savedRecordID the id for the saved record
+ */
+ confirmSave: function (state, savedRecordID) {
+ this.state = state;
+ var index = _.findIndex(this.state.data, {id: savedRecordID});
+ for (var j = 0; j < this.columns.length; j++) {
+ this._setCellValue(index, j, false);
+ }
+ },
+ /**
+ * Edit a given record in the list
+ *
+ * @param {string} recordID
+ */
+ editRecord: function (recordID) {
+ var index = _.findIndex(this.state.data, function (record) {
+ return record.id === recordID;
+ });
+ this._selectCell(index, 0);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Find the td in a row corresponding the a field with a given index. The
+ * main problem is that in some cases, we have a 'selector' checkbox, so the
+ * correct td is sometimes at position i, sometimes at i+1
+ *
+ * @param {jQueryElement} $row the $tr for the row we are interested in
+ * @param {number} index the index of the field/column
+ * @returns {jQueryElement} the $td for the index-th field
+ */
+ _findTd: function ($row, index) {
+ return $row.find('td').eq(index + (this.hasSelectors ? 1 : 0));
+ },
+ /**
+ * 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;
+ },
+ /**
+ * Determine if a given cell is readonly. A cell is considered readonly if
+ * the field is readonly, or the widget is statically readonly (like the
+ * sequence widget) of if there are a 'readonly' attr, with a matching
+ * domain.
+ *
+ * @param {Object} record a basic model record
+ * @param {Object} field a field description
+ * @param {Object} node a node object (from the arch)
+ * @returns {boolean}
+ */
+ _isReadonly: function (record, field, node) {
+ if (field.readonly || (node.Widget && node.Widget.prototype.readonly)) {
+ return true;
+ }
+ if ('readonly' in node.modifiers) {
+ var fieldValues = this._getFieldValues(record);
+ return new Domain(node.modifiers.readonly).compute(fieldValues);
+ }
+ return false;
+ },
+ /**
+ * A cell is editable if its field isn't invisible and if it isn't readonly.
+ *
+ * @param {Object} record a basic model record
+ * @param {Object} field a field description
+ * @param {Object} node a node object (from the arch)
+ * @returns {boolean}
+ */
+ _isEditable: function (record, field, node) {
+ return !this._isInvisible(record, node) && !this._isReadonly(record, field, node);
+ },
+ /**
+ * The renderer needs to support reordering lines. This is only active in
+ * edit mode. The hasHandle attribute is used when there is a sequence
+ * widget.
+ *
+ * @override
+ * @returns {jQueryElement}
+ */
+ _renderBody: function () {
+ var $body = this._super();
+ if (this.mode === 'edit' && this.hasHandle) {
+ $body.sortable({
+ axis: 'y',
+ items: '> tr.o_data_row',
+ helper: 'clone',
+ handle: '.o_row_handle',
+ });
+ }
+ return $body;
+ },
+ /**
+ * Add all the necessary styling classes to displayed cells. Also, we need
+ * to add the current index to each cell data attribute, so we can get it
+ * back when we need to select a cell.
+ *
+ * @override
+ * @param {Object} record
+ * @param {Object} node
+ * @param {integer} index
+ * @returns {jQueryElement}
+ */
+ _renderBodyCell: function (record, node, index) {
+ if (this.mode === 'readonly') {
+ return this._super.apply(this, arguments);
+ }
+ var $cell = this._super(record, node);
+ if (node.tag === 'field') {
+ var field = this.state.fields[node.attrs.name];
+ if (field.required) {
+ $cell.addClass('o_required_field');
+ }
+ if (this._isReadonly(record, field, node)) {
+ $cell.addClass('o_readonly');
+ }
+ }
+ return $cell.data('index', index);
+ },
+ /**
+ * Helper method, to instantiate and render a field widget. It is only
+ * called by _selectCell, when editing a row. It kind of looks like the
+ * _renderBodyCell method of ListRenderer, but it has to properly tag the
+ * widget and to do other subtly different tasks, so it is awkward to try to
+ * merge them.
+ *
+ * @private
+ * @param {jQueryElement} $row
+ * @param {Object} node
+ * @param {integer} rowIndex
+ * @param {integer} colIndex
+ * @returns {AbstractField} the instance of the desired widget
+ */
+ _renderFieldWidget: function ($row, node, rowIndex, colIndex) {
+ if (node.tag === 'button') {
+ return;
+ }
+ var record = this.state.data[rowIndex];
+ var field = this.state.fields[node.attrs.name];
+ if (!this._isEditable(record, field, node)) {
+ return;
+ }
+ var $td = this._findTd($row, colIndex);
+ // force the width of the cell to its current value so that it won't
+ // change if its content changes (to prevent the list view from
+ // flickering).
+ $td.width($td.width());
+ var widget_keys = ['list.' + field.type, field.type];
+ if (node.attrs.widget) {
+ widget_keys.unshift(node.attrs.widget);
+ widget_keys.unshift('list.' + node.attrs.widget);
+ }
+ var Widget = field_registry.getAny(widget_keys);
+ var widget = new Widget(this, node.attrs.name, record, {
+ mode: 'edit'
+ });
+ if (widget.replace_element) {
+ $td.empty();
+ }
+ $td.addClass('o_edit_mode');
+ widget.appendTo($td);
+ widget.__rowIndex = rowIndex;
+ widget.__colIndex = colIndex;
+ this.widgets.push(widget);
+ return widget;
+ },
+ /**
+ * Editable rows are extended with their index, and possibly a trash icon on
+ * their right, to allow deleting the corresponding record.
+ *
+ * @override
+ * @param {any} record
+ * @param {any} index
+ * @returns {jQueryElement}
+ */
+ _renderRow: function (record, index) {
+ var $row = this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ $row.data('index', index);
+ }
+ if (this.addTrashIcon) {
+ var $icon = $('<span>').addClass('fa fa-trash-o').attr('name', 'delete');
+ var $td = $('<td>').addClass('o_list_record_delete').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.
+ *
+ * @override
+ * @returns {jQueryElement}
+ */
+ _renderRows: function () {
+ var $rows = this._super();
+ if (this.addCreateLine) {
+ var $a = $('<a href="#">').text(_t("Add an item"));
+ var $td = $('<td>')
+ .attr('colspan', this._getNumberOfCols())
+ .addClass('o_form_field_x2many_list_row_add')
+ .append($a);
+ var $tr = $('<tr>').append($td);
+ $rows.push($tr);
+ }
+ return $rows;
+ },
+ /**
+ * 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 terget 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} colIndex
+ */
+ _selectCell: function (rowIndex, colIndex) {
+ var self = this;
+
+ // do nothing if user tries to select current cell
+ if (rowIndex === this.currentRow && colIndex === this.currentCol) {
+ return;
+ }
+
+ // make sure the colIndex is on an editable field. otherwise, find
+ // next editable field, or do nothing if no such field exists
+ var data = this.state.data;
+ var fields = this.state.fields;
+ var col, field, isEditable, record;
+ for (var i = 0; i < this.columns.length; i++) {
+ col = this.columns[colIndex];
+ record = data[rowIndex];
+ field = fields[col.attrs.name];
+ isEditable = this._isEditable(record, field, col);
+ if (isEditable) {
+ break;
+ }
+ colIndex = (colIndex + 1) % this.columns.length;
+ }
+ if (!isEditable) {
+ return;
+ }
+
+ // if we are just changing active cell in the same row, activate the
+ // corresponding widget and return
+ if (rowIndex === this.currentRow && colIndex !== this.currentCol) {
+ var w = _.findWhere(this.widgets, {
+ __rowIndex: rowIndex,
+ __colIndex: colIndex
+ });
+ this.currentCol = colIndex;
+ w.activate();
+ return;
+ }
+
+ // if this is a different column, then we need to prepare for it by
+ // unselecting the current row. This will trigger the save.
+ if (rowIndex !== this.currentRow) {
+ this._unselectRow();
+ }
+
+ // instantiate column widgets
+ var $row = this.$('tbody tr').eq(rowIndex);
+ $row.addClass('o_selected_row');
+
+ _.each(this.columns, function (node, index) {
+ var widget = self._renderFieldWidget($row, node, rowIndex, index);
+ if (widget && index === colIndex) {
+ widget.activate();
+ }
+ });
+
+ this.currentRow = rowIndex;
+ this.currentCol = colIndex;
+ this.trigger_up('change_mode', {mode: 'edit'});
+ },
+ /**
+ * Set the value of a cell. This method can be called when the value of a
+ * cell was modified, for example afte an onchange.
+ *
+ * @param {integer} rowIndex
+ * @param {integer} colIndex
+ * @param {boolean} keepDirtyFlag
+ * @param {boolean} forceDirtyFlag
+ */
+ _setCellValue: function (rowIndex, colIndex, keepDirtyFlag, forceDirtyFlag) {
+ var record = this.state.data[rowIndex];
+ var node = this.columns[colIndex];
+ var $td = this._findTd(this.$('tbody tr').eq(rowIndex), colIndex);
+ var $new_td = this._renderBodyCell(record, node, colIndex);
+ if (keepDirtyFlag && $td.hasClass('o_field_dirty') || forceDirtyFlag) {
+ $new_td.addClass('o_field_dirty');
+ }
+ $td.replaceWith($new_td);
+ },
+ _updateRow: function (index, changed_fields) {
+ var self = this;
+ var record = this.state.data[index];
+ _.each(changed_fields, function (field_name) {
+ var widget = _.findWhere(self.widgets, {name: field_name});
+ if (widget && widget.__rowIndex === index) {
+ widget.reset(record);
+ widget.$el.parent().addClass('o_field_dirty');
+ } else {
+ var column_index = _.findIndex(self.columns, function (column) {
+ return column.attrs.name === field_name;
+ });
+ self._setCellValue(index, column_index, true, true);
+ }
+ });
+ },
+ /**
+ * 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.
+ */
+ _unselectRow: function () {
+ var self = this;
+ if (this.currentRow === null) {
+ return;
+ }
+ // trigger the save (if the record isn't dirty, the datamodel won't save
+ // anything)
+ this.trigger_up('field_changed', {
+ dataPointID: this.state.data[this.currentRow].id,
+ changes: {},
+ force_save: true,
+ });
+ this.trigger_up('change_mode', {mode: 'readonly'});
+ var widgets = _.where(this.widgets, {__rowIndex: this.currentRow});
+ _.each(widgets, function (widget) {
+ self._setCellValue(self.currentRow, widget.__colIndex, true);
+ widget.destroy();
+ });
+ self.widgets = _.without(self.widgets, widgets);
+ var $row = this.$('tbody tr').eq(this.currentRow);
+ $row.removeClass('o_selected_row');
+ $row.find('td').removeClass('o_edit_mode');
+ this.currentRow = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is called when we click on the 'Add an Item' button in a sub
+ * list such as a one2many in a form view.
+ *
+ * @param {MouseEvent} event
+ */
+ _onAddRecord: function (event) {
+ // we don't want the browser to navigate to a the # url
+ event.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
+ event.stopPropagation();
+
+ this.trigger_up('add_record');
+ },
+ /**
+ * When the user clicks on a cell, we simply select it.
+ *
+ * @param {MouseEvent} event
+ */
+ _onCellClick: function (event) {
+ var $td = $(event.currentTarget);
+ var rowIndex = $td.parent().data('index');
+ var colIndex = $td.data('index');
+ if (colIndex !== undefined && rowIndex !== undefined) {
+ this._selectCell(rowIndex, colIndex);
+ event.stopPropagation();
+ }
+ // FIXME: this seems like dead code
+ // if (this.state.groupedBy.length) {
+ // var id = $td.parent().data('id');
+ // if (id) {
+ // this.trigger_up('record_selected', {id:id});
+ // }
+ // }
+ },
+ /**
+ * We need to manually unselect row, because noone else would do it
+ */
+ _onEmptyRowClick: function (event) {
+ this._unselectRow();
+ },
+ /**
+ * We react to field_changed events by adding the .o_field_dirty css class,
+ * which changes its style to a visible orange outline. It allows the user
+ * to have a visual feedback on which fields changed, and on which fields
+ * have been saved.
+ *
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ var $td = event.target.$el.parent();
+ $td.addClass('o_field_dirty');
+ },
+ /**
+ * 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();
+ },
+ /**
+ * Move the cursor on the cell just down the current cell.
+ */
+ _onMoveDown: function () {
+ if (this.currentRow < this.state.data.length - 1) {
+ this._selectCell(this.currentRow + 1, this.currentCol);
+ }
+ },
+ /**
+ * Move the cursor on the left, if possible
+ */
+ _onMoveLeft: function () {
+ if (this.currentCol > 0) {
+ this._selectCell(this.currentRow, this.currentCol - 1);
+ }
+ },
+ /**
+ * Move the cursor on the next available cell, so either the next available
+ * cell on the right, or the first on the next line. This method is called
+ * when the user press the TAB key.
+ */
+ _onMoveNext: function () {
+ if (this.currentCol < this.columns.length - 1) {
+ this._selectCell(this.currentRow, this.currentCol + 1);
+ } else if (this.currentRow < this.state.data.length - 1) {
+ this._selectCell(this.currentRow + 1, 0);
+ }
+ },
+ /**
+ * Move the cursor one the beginning of the next line, if possible. This is
+ * called when the user press the ENTER key.
+ */
+ _onMoveNextLine: function () {
+ if (this.currentRow < this.state.data.length - 1) {
+ this._selectCell(this.currentRow + 1, 0);
+ }
+ },
+ /**
+ * Move the cursor one cell right, if possible
+ */
+ _onMoveRight: function () {
+ if (this.currentCol < this.columns.length - 1) {
+ this._selectCell(this.currentRow, this.currentCol + 1);
+ }
+ },
+ /**
+ * Move the cursor one line up
+ */
+ _onMoveUp: function () {
+ if (this.currentRow > 0) {
+ this._selectCell(this.currentRow - 1, this.currentCol);
+ }
+ },
+ /**
+ * Triggers a delete event. I don't know why we stop the propagation of the
+ * event.
+ *
+ * @param {MouseEvent} event
+ */
+ _onTrashIconClick: function (event) {
+ event.stopPropagation();
+ var id = $(event.target).closest('tr').data('id');
+ this.trigger_up('list_record_delete', {id: id});
+ },
+ /**
+ * 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 if this renderer is not in the dom.
+ if (!document.contains(this.el)) {
+ return;
+ }
+
+ // there is currenctly no selected row
+ if (this.currentRow === null) {
+ return;
+ }
+
+ // ignore clicks in autocomplete dropdowns
+ if ($(event.target).parents('.ui-autocomplete').length) {
+ return;
+ }
+
+ // ignore clicks in modals, except if the list is in a modal, and the
+ // click is performed in that modal
+ var $list_modal = this.$el.closest('.modal');
+ if ($list_modal.length) {
+ var $click_modal = $(event.target).parents('.modal');
+ if ($click_modal.prop('id') === $list_modal.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;
+ }
+
+ this._unselectRow();
+ },
+
+});
+
+});
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 00000000000..e3ec4637658
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -0,0 +1,583 @@
+odoo.define('web.ListRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var field_registry = require('web.field_registry');
+var field_utils = require('web.field_utils');
+var session = require('web.session');
+var utils = require('web.utils');
+
+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 = {
+ 'monetary': 'o_list_number',
+ 'text': 'o_list_text',
+ 'float': 'o_list_number',
+};
+
+var ListRenderer = BasicRenderer.extend({
+ className: 'table-responsive',
+ events: {
+ 'click tbody tr': '_onRowClicked',
+ 'click tbody .o_list_record_selector': '_onSelectRecord',
+ 'click thead th.o_column_sortable': '_onSortColumn',
+ 'click .o_group_header': '_onToggleGroup',
+ 'click thead .o_list_record_selector input': '_onToggleSelection',
+ },
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {any} state
+ * @param {Object} params
+ * @param {boolean} params.hasSelectors
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ var self = this;
+ this.hasHandle = false;
+ this.columns = _.reject(this.arch.children, function (c) {
+ if (c.attrs.invisible === '1') {
+ return true;
+ }
+ var modifiers = JSON.parse(c.attrs.modifiers || '{}');
+ if (modifiers.tree_invisible) {
+ return true;
+ }
+ if (c.attrs.widget === 'handle') {
+ self.hasHandle = true;
+ }
+ var keys = ['list.' + c.attrs.widget, c.attrs.widget];
+ c.Widget = field_registry.getAny(keys);
+ c.modifiers = modifiers;
+ return false;
+ });
+ this.rowDecorations = _.chain(this.arch.attrs)
+ .pick(function (value, key) {
+ return DECORATIONS.indexOf(key) >= 0;
+ }).mapObject(function (value) {
+ return py.parse(py.tokenize(value));
+ }).value();
+ this.hasSelectors = params.hasSelectors;
+ this.selection = [];
+ },
+
+ //--------------------------------------------------------------------------
+ // 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.
+ */
+ _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;
+ }
+ if (data.length === 0) {
+ return;
+ }
+
+ _.each(this.columns, function (column) {
+ var attrs = column.attrs;
+ var field = self.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 aggregate_value = (func === 'max') ? -Infinity : (func === 'min') ? Infinity : 0;
+ _.each(data, function (d) {
+ count += 1;
+ var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name];
+ if (func === 'avg') {
+ aggregate_value += value;
+ } else if (func === 'sum') {
+ aggregate_value += value;
+ } else if (func === 'max') {
+ aggregate_value = Math.max(aggregate_value, value);
+ } else if (func === 'min') {
+ aggregate_value = Math.min(aggregate_value, value);
+ }
+ });
+ if (func === 'avg') {
+ aggregate_value = aggregate_value / count;
+ }
+ column.aggregate = {
+ help: attrs[func],
+ value: aggregate_value,
+ };
+ }
+ });
+ },
+ /**
+ * Each line can be decorated according to a few simple rules. The arch
+ * description of the list may have one of the decoration-X attribute with
+ * a domain as value. Then, for each record, we check if the domain matches
+ * the record, and 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.
+ *
+ * @param {Object} record a basic model record
+ * @returns {string[]} a list of css classes
+ */
+ _computeDecorationClassNames: function (record) {
+ var context = _.extend({}, record.data, {
+ uid: session.uid,
+ current_date: moment().format('YYYY-MM-DD')
+ // TODO: time, datetime, relativedelta
+ });
+ return _.chain(this.rowDecorations)
+ .pick(function (expr) {
+ return py.PY_isTrue(py.evaluate(expr, context));
+ }).map(function (expr, decoration) {
+ return decoration.replace('decoration', 'text');
+ }).value();
+ },
+ /**
+ * When a list view is grouped, we need to display the name of each group in
+ * the 'title' row. This is the purpose of this method.
+ *
+ * @param {any} value
+ * @param {Object} field a field description
+ * @returns {string}
+ */
+ _formatValue: function (value, field) {
+ if (field && field.type === 'selection') {
+ var choice = _.find(field.selection, function (c) {
+ return c[0] === value;
+ });
+ return choice[1];
+ }
+ return value || _t('Undefined');
+ },
+ /**
+ * 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'.
+ *
+ * @returns {number}
+ */
+ _getNumberOfCols: function () {
+ var n = this.columns.length;
+ return this.hasSelectors ? n+1 : n;
+ },
+ /**
+ * Determine if a given cell is invisible. A cell is considered invisible
+ * if there is an 'invisible' attr, with a matching domain.
+ *
+ * @param {Object} record a basic model record
+ * @param {Object} node a node object (from the arch)
+ * @returns {boolean}
+ */
+ _isInvisible: function (record, node) {
+ if ('invisible' in node.modifiers) {
+ var fieldValues = this._getFieldValues(record);
+ return new Domain(node.modifiers.invisible).compute(fieldValues);
+ }
+ return false;
+ },
+ /**
+ * Render a list of <td>, with aggregates if available. It can be displayed
+ * in the footer, or for each open groups.
+ *
+ * @param {any} aggregate_values
+ * @returns {jQueryElement[]} a list of <td> with the aggregate values
+ */
+ _renderAggregateCells: function (aggregate_values) {
+ var self = this;
+ return _.map(this.columns, function (column) {
+ var $cell = $('<td>');
+ if (column.attrs.name in aggregate_values) {
+ var field = self.state.fields[column.attrs.name];
+ var value = aggregate_values[column.attrs.name].value;
+ var help = aggregate_values[column.attrs.name].help;
+ var formattedValue = field_utils['format_' + field.type](value, field, {});
+ $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.
+ *
+ * @returns {jQueryElement} a jquery element <tbody>
+ */
+ _renderBody: function () {
+ var $rows = this._renderRows();
+ while ($rows.length < 4) {
+ $rows.push(this._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.
+ *
+ * @param {Object} record
+ * @param {Object} node
+ * @returns {jQueryElement} a <td> element
+ */
+ _renderBodyCell: function (record, node) {
+ var self = this;
+ var $td = $('<td>');
+ if (this._isInvisible(record, node)) {
+ return $td;
+ }
+ if (node.tag === 'button') {
+ var $button = $('<button type="button">').addClass('o_icon_button');
+ $button.append($('<i>').addClass('fa').addClass(node.attrs.icon))
+ .prop('title', node.attrs.string)
+ .click(function (e) {
+ e.stopPropagation();
+ self.trigger_up('list_button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ });
+ $td.append($button);
+ $td.addClass('o_list_button').click(function (e) {
+ // prevent opening or editing the record on cell click
+ e.stopPropagation();
+ });
+ return $td;
+ }
+ var field = this.state.fields[node.attrs.name];
+ var value = record.data[node.attrs.name];
+ if (node.Widget) {
+ var widget = new node.Widget(this, node.attrs.name, record, {
+ mode: 'readonly',
+ });
+ widget.appendTo($td);
+ $td.addClass('o_' + node.attrs.widget + '_cell');
+ return $td;
+ }
+ $td.addClass(FIELD_CLASSES[field.type]);
+ var formatted_value = field_utils.format_field(value, field, { data: record.data });
+ return $td.html(formatted_value);
+ },
+ /**
+ * Render a complete empty row. This is used to fill in the blanks when we
+ * have less than 4 lines to display.
+ *
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderEmptyRow: function () {
+ var $td = $('<td>&nbsp;</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.
+ *
+ * @param {boolean} isGrouped if the view is grouped, we have to add an
+ * extra <td>
+ * @returns {jQueryElement} a <tfoot> element
+ */
+ _renderFooter: function (isGrouped) {
+ var aggregates = {};
+ _.each(this.columns, function (column) {
+ if ('aggregate' in column) {
+ aggregates[column.attrs.name] = column.aggregate;
+ }
+ });
+ var $cells = this._renderAggregateCells(aggregates);
+ if (isGrouped) {
+ $cells.unshift($('<td>'));
+ }
+ if (this.hasSelectors) {
+ $cells.unshift($('<td>'));
+ }
+ return $('<tfoot>').append($('<tr>').append($cells));
+ },
+ /**
+ * Render the row that represent a group
+ *
+ * @param {Object} group
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderGroupRow: function (group) {
+ var aggregate_values = _.mapObject(group.aggregateValues, function (value) {
+ return { value: value };
+ });
+ var $cells = this._renderAggregateCells(aggregate_values);
+ if (this.hasSelectors) {
+ $cells.unshift($('<td>'));
+ }
+ var field = this.state.fields[group.groupedBy[0]];
+ var name = this._formatValue(group.value, field);
+ var $th = $('<th>')
+ .addClass('o_group_name')
+ .text(name + ' (' + group.count + ')');
+ if (group.count > 0) {
+ var $arrow = $('<span style="padding-right: 5px;">')
+ .addClass('fa')
+ .toggleClass('fa-caret-right', !group.isOpen)
+ .toggleClass('fa-caret-down', group.isOpen);
+ $th.prepend($arrow);
+ }
+ return $('<tr>')
+ .addClass('o_group_header')
+ .data('group', group)
+ .append($th)
+ .append($cells);
+ },
+ /**
+ * 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.
+ *
+ * @returns {jQueryElement[]} a list of <tbody>
+ */
+ _renderGroups: function () {
+ var self = this;
+ var result = [];
+ var $tbody = $('<tbody>');
+ _.each(this.state.data, function (group) {
+ if (!$tbody) {
+ $tbody = $('<tbody>');
+ }
+ $tbody.append(self._renderGroupRow(group));
+ if (group.data.length) {
+ result.push($tbody);
+ var $records = _.map(group.data, function (record) {
+ return self._renderRow(record).prepend($('<td>'));
+ });
+ result.push($('<tbody>').append($records));
+ $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
+ *
+ * @param {boolean} isGrouped
+ * @returns {jQueryElement} a <thead> element
+ */
+ _renderHeader: function (isGrouped) {
+ var $tr = $('<tr>')
+ .append(_.map(this.columns, this._renderHeaderCell.bind(this)));
+ if (this.hasSelectors) {
+ $tr.prepend(this._renderSelector('th'));
+ }
+ if (isGrouped) {
+ $tr.prepend($('<th>').html('&nbsp;'));
+ }
+ return $('<thead>').append($tr);
+ },
+ /**
+ * Render a single <th> with the informations for a column. If it is not a
+ * field, the th will be empty. Otherwise, it will contains all relevant
+ * information for the field.
+ *
+ * @param {Object} node
+ * @returns {jQueryElement} a <th> element
+ */
+ _renderHeaderCell: function (node) {
+ var order = this.state.orderedBy;
+ var isNodeSorted = order[0] && order[0].name === node.attrs.name;
+ var field = this.state.fields[node.attrs.name];
+ var $th = $('<th>');
+ if (!field) {
+ return $th;
+ }
+ var description;
+ if (node.Widget) {
+ description = node.Widget.prototype.description;
+ }
+ if (description === undefined) {
+ description = node.attrs.string || field.string;
+ }
+ $th
+ .text(description)
+ .data('name', node.attrs.name)
+ .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false)
+ .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false)
+ .addClass(field.sortable && 'o_column_sortable');
+
+ if (field.type === 'float' || field.type === 'monetary') {
+ $th.css({textAlign: 'right'});
+ }
+
+ if (config.debug) {
+ var fieldDescr = {
+ field: field,
+ name: node.attrs.name,
+ string: description || node.attrs.name,
+ record: this.state,
+ attrs: node.attrs,
+ };
+ this._addFieldTooltip(fieldDescr, $th);
+ }
+ return $th;
+ },
+ /**
+ * Render a row, corresponding to a record.
+ *
+ * @param {Object} record
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderRow: function (record) {
+ var decorations = this._computeDecorationClassNames(record);
+ var $cells = _.map(this.columns, this._renderBodyCell.bind(this, record));
+ var $tr = $('<tr class="o_data_row">')
+ .data('id', record.id)
+ .addClass(decorations.length && decorations.join(' '))
+ .append($cells);
+ if (this.hasSelectors) {
+ $tr.prepend(this._renderSelector('td'));
+ }
+ return $tr;
+ },
+ /**
+ * Render all rows. This method should only called when the view is not
+ * grouped.
+ *
+ * @returns {jQueryElement[]} a list of <tr>
+ */
+ _renderRows: function () {
+ return _.map(this.state.data, this._renderRow.bind(this));
+ },
+ _renderSelector: function (tag) {
+ var $content = $('<div class="o_checkbox"><input type="checkbox"><span/></div>');
+ return $('<' + tag + ' width="1">')
+ .addClass('o_list_record_selector')
+ .append($content);
+ },
+ /**
+ * @override
+ * returns {Deferred}
+ */
+ _renderView: function () {
+ var self = this;
+ var $table = $('<table>').addClass('o_list_view table table-condensed table-striped');
+ this.$el.empty().append($table);
+ var is_grouped = !!this.state.groupedBy.length;
+ this._computeAggregates();
+ $table.toggleClass('o_list_view_grouped', is_grouped);
+ $table.toggleClass('o_list_view_ungrouped', !is_grouped);
+ if (is_grouped) {
+ $table
+ .append(this._renderHeader(true))
+ .append(this._renderGroups())
+ .append(this._renderFooter(true));
+ } else {
+ $table
+ .append(this._renderHeader())
+ .append(this._renderBody())
+ .append(this._renderFooter());
+ }
+ if (this.selection.length) {
+ var $checked_rows = this.$('tr').filter(function (index, el) {
+ return _.contains(self.selection, $(el).data('id'));
+ });
+ $checked_rows.find('.o_list_record_selector input').prop('checked', true);
+ }
+ return this._super();
+ },
+ _updateSelection: function () {
+ var $selectedRows = this.$('tbody .o_list_record_selector input:checked')
+ .closest('tr');
+ this.selection = _.map($selectedRows, function (row) {
+ return $(row).data('id');
+ });
+ this.trigger_up('selection_changed', { selection: this.selection });
+ this._computeAggregates();
+ this.$('tfoot').replaceWith(this._renderFooter(!!this.state.groupedBy.length));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {MouseEvent} event
+ */
+ _onRowClicked: function (event) {
+ var id = $(event.currentTarget).data('id');
+ if (id) {
+ this.trigger_up('open_record', {id:id, target: event.target});
+ }
+ },
+ /**
+ * @param {MouseEvent} event
+ */
+ _onSelectRecord: function (event) {
+ event.stopPropagation();
+ this._updateSelection();
+ if (!$(event.currentTarget).find('input').prop('checked')) {
+ this.$('thead .o_list_record_selector input').prop('checked', false);
+ }
+ },
+ /**
+ * @param {MouseEvent} event
+ */
+ _onSortColumn: function (event) {
+ var name = $(event.currentTarget).data('name');
+ this.trigger_up('toggle_column_order', {id: this.state.id, name: name});
+ },
+ /**
+ * @param {MouseEvent} event
+ */
+ _onToggleGroup: function (event) {
+ var group = $(event.currentTarget).data('group');
+ if (group.count) {
+ this.trigger_up('toggle_group', {group: group});
+ }
+ },
+ _onToggleSelection: function (event) {
+ var checked = $(event.currentTarget).prop('checked') || false;
+ this.$('tbody .o_list_record_selector input').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 00000000000..a616e9704df
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -0,0 +1,58 @@
+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 ListRenderer = require('web.ListRenderer');
+var ListController = require('web.ListController');
+
+var _lt = core._lt;
+
+var ListView = BasicView.extend({
+ accesskey: "l",
+ display_name: _lt('List'),
+ icon: 'fa-list-ul',
+ config: _.extend({}, BasicView.prototype.config, {
+ Renderer: ListRenderer,
+ Controller: ListController,
+ }),
+ /**
+ * @override
+ *
+ * @param {Object} arch
+ * @param {Object} fields
+ * @param {Object} params
+ * @param {boolean} params.sidebar
+ * @param {boolean} [params.hasSelectors=true]
+ */
+ init: function (arch, fields, params) {
+ this._super.apply(this, arguments);
+
+ this.controllerParams.editable = arch.attrs.editable;
+ this.controllerParams.hasSidebar = params.sidebar;
+ this.controllerParams.noContentHelp = params.action && params.action.help;
+ this.controllerParams.activeActions.delete = true;
+ this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
+
+ this.rendererParams.arch = arch;
+ this.rendererParams.hasSelectors =
+ 'hasSelectors' in params ? params.hasSelectors : true;
+ this.rendererParams.mode =
+ arch.attrs.editable && !params.readonly ? "edit" : "readonly";
+
+ this.loadParams.limit = this.loadParams.limit || 80;
+ this.loadParams.type = 'list';
+ },
+});
+
+return ListView;
+
+});
diff --git a/addons/web/static/src/js/views/list_common.js b/addons/web/static/src/js/views/list_common.js
deleted file mode 100644
index 0d96b251ec2..00000000000
--- a/addons/web/static/src/js/views/list_common.js
+++ /dev/null
@@ -1,405 +0,0 @@
-odoo.define('web.list_common', function (require) {
-"use strict";
-
-var core = require('web.core');
-
-var _t = core._t;
-var Class = core.Class;
-
-/**
- * @mixin Events
- */
-var Events = /** @lends Events# */{
- /**
- * @param {String} event event to listen to on the current object, null for all events
- * @param {Function} handler event handler to bind to the relevant event
- * @returns this
- */
- bind: function (event, handler) {
- var calls = this['_callbacks'] || (this._callbacks = {});
-
- if (event in calls) {
- calls[event].push(handler);
- } else {
- calls[event] = [handler];
- }
- return this;
- },
- /**
- * @param {String} event event to unbind on the current object
- * @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event)
- * @returns this
- */
- unbind: function (event, handler) {
- var calls = this._callbacks || {};
- if (!(event in calls)) { return this; }
- if (!handler) {
- delete calls[event];
- } else {
- var handlers = calls[event];
- handlers.splice(
- _(handlers).indexOf(handler),
- 1);
- }
- return this;
- },
- /**
- * @param {String} event
- * @returns this
- */
- trigger: function (event) {
- var calls;
- if (!(calls = this._callbacks)) { return this; }
- var callbacks = (calls[event] || []).concat(calls[null] || []);
- for(var i=0, length=callbacks.length; i<length; ++i) {
- callbacks[i].apply(this, arguments);
- }
- return this;
- }
-};
-var Record = Class.extend(/** @lends Record# */{
- /**
- * @constructs Record
- * @extends instance.web.Class
- *
- * @mixes Events
- * @param {Object} [data]
- */
- init: function (data) {
- this.attributes = data || {};
- },
- /**
- * @param {String} key
- * @returns {Object}
- */
- get: function (key) {
- return this.attributes[key];
- },
- /**
- * @param key
- * @param value
- * @param {Object} [options]
- * @param {Boolean} [options.silent=false]
- * @returns {Record}
- */
- set: function (key, value, options) {
- options = options || {};
- var old_value = this.attributes[key];
- if (old_value === value) {
- return this;
- }
- this.attributes[key] = value;
- if (!options.silent) {
- this.trigger('change:' + key, this, value, old_value);
- this.trigger('change', this, key, value, old_value);
- }
- return this;
- },
- /**
- * Converts the current record to the format expected by form views:
- *
- * .. code-block:: javascript
- *
- * data: {
- * $fieldname: {
- * value: $value
- * }
- * }
- *
- *
- * @returns {Object} record displayable in a form view
- */
- toForm: function () {
- var form_data = {}, attrs = this.attributes;
- for(var k in attrs) {
- form_data[k] = {value: attrs[k]};
- }
-
- return {data: form_data};
- },
- /**
- * Converts the current record to a format expected by context evaluations
- * (identical to record.attributes, except m2o fields are their integer
- * value rather than a pair)
- */
- toContext: function () {
- var output = {}, attrs = this.attributes;
- for(var k in attrs) {
- var val = attrs[k];
- if (typeof val !== 'object') {
- output[k] = val;
- } else if (val instanceof Array) {
- output[k] = val[0];
- } else {
- throw new Error(_.str.sprintf(_t("Can't convert value %s to context"), val));
- }
- }
- return output;
- }
-});
-Record.include(Events);
-var Collection = Class.extend(/** @lends Collection# */{
- /**
- * Smarter collections, with events, very strongly inspired by Backbone's.
- *
- * Using a "dumb" array of records makes synchronization between the
- * various serious
- *
- * @constructs Collection
- * @extends instance.web.Class
- *
- * @mixes Events
- * @param {Array} [records] records to initialize the collection with
- * @param {Object} [options]
- */
- init: function (records, options) {
- options = options || {};
- _.bindAll(this, '_onRecordEvent');
- this.length = 0;
- this.records = [];
- this._byId = {};
- this._proxies = {};
- this._key = options.key;
- this._parent = options.parent;
-
- if (records) {
- this.add(records);
- }
- },
- /**
- * @param {Object|Array} record
- * @param {Object} [options]
- * @param {Number} [options.at]
- * @param {Boolean} [options.silent=false]
- * @returns this
- */
- add: function (record, options) {
- options = options || {};
- var records = record instanceof Array ? record : [record];
-
- for(var i=0, length=records.length; i<length; ++i) {
- var instance_ = (records[i] instanceof Record) ? records[i] : new Record(records[i]);
- instance_.bind(null, this._onRecordEvent);
- this._byId[instance_.get('id')] = instance_;
- if (options.at === undefined || options.at === null) {
- this.records.push(instance_);
- if (!options.silent) {
- this.trigger('add', this, instance_, this.records.length-1);
- }
- } else {
- var insertion_index = options.at + i;
- this.records.splice(insertion_index, 0, instance_);
- if (!options.silent) {
- this.trigger('add', this, instance_, insertion_index);
- }
- }
- this.length++;
- }
- return this;
- },
-
- /**
- * Get a record by its index in the collection, can also take a group if
- * the collection is not degenerate
- *
- * @param {Number} index
- * @param {String} [group]
- * @returns {Record|undefined}
- */
- at: function (index, group) {
- if (group) {
- var groups = group.split('.');
- return this._proxies[groups[0]].at(index, groups.join('.'));
- }
- return this.records[index];
- },
- /**
- * Get a record by its database id
- *
- * @param {Number} id
- * @returns {Record|undefined}
- */
- get: function (id) {
- if (!_(this._proxies).isEmpty()) {
- var record = null;
- _(this._proxies).detect(function (proxy) {
- record = proxy.get(id);
- return record;
- });
- return record;
- }
- return this._byId[id];
- },
- /**
- * Builds a proxy (insert/retrieve) to a subtree of the collection, by
- * the subtree's group
- *
- * @param {String} section group path section
- * @returns {Collection}
- */
- proxy: function (section) {
- this._proxies[section] = new Collection(null, {
- parent: this,
- key: section
- }).bind(null, this._onRecordEvent);
- return this._proxies[section];
- },
- /**
- * @param {Array} [records]
- * @returns this
- */
- reset: function (records, options) {
- options = options || {};
- _(this._proxies).each(function (proxy) {
- proxy.reset();
- });
- this._proxies = {};
- _(this.records).invoke('unbind', null, this._onRecordEvent);
- this.length = 0;
- this.records = [];
- this._byId = {};
- if (records) {
- this.add(records);
- }
- if (!options.silent) {
- this.trigger('reset', this);
- }
- return this;
- },
- /**
- * Removes the provided record from the collection
- *
- * @param {Record} record
- * @param {Boolean} [options.silent=false]
- * @returns this
- */
- remove: function (record, options) {
- var index = this.indexOf(record);
- if (index === -1) {
- _(this._proxies).each(function (proxy) {
- proxy.remove(record);
- });
- return this;
- }
-
- record.unbind(null, this._onRecordEvent);
- this.records.splice(index, 1);
- delete this._byId[record.get('id')];
- this.length--;
- if (!options || !options.silent) {
- this.trigger('remove', record, this);
- }
- return this;
- },
-
- _onRecordEvent: function (event) {
- switch(event) {
- // don't propagate reset events
- case 'reset': return;
- case 'change:id':
- var record = arguments[1];
- var new_value = arguments[2];
- var old_value = arguments[3];
- // [change:id, record, new_value, old_value]
- if (this._byId[old_value] === record) {
- delete this._byId[old_value];
- this._byId[new_value] = record;
- }
- break;
- }
- this.trigger.apply(this, arguments);
- },
-
- // underscore-type methods
- find: function (callback) {
- var record;
- for(var section in this._proxies) {
- if (!this._proxies.hasOwnProperty(section)) {
- continue;
- }
- if ((record = this._proxies[section].find(callback))) {
- return record;
- }
- }
- for(var i=0; i<this.length; ++i) {
- record = this.records[i];
- if (callback(record)) {
- return record;
- }
- }
- },
- each: function (callback) {
- for(var section in this._proxies) {
- if (this._proxies.hasOwnProperty(section)) {
- this._proxies[section].each(callback);
- }
- }
- for(var i=0; i<this.length; ++i) {
- callback(this.records[i]);
- }
- },
- map: function (callback) {
- var results = [];
- this.each(function (record) {
- results.push(callback(record));
- });
- return results;
- },
- pluck: function (fieldname) {
- return this.map(function (record) {
- return record.get(fieldname);
- });
- },
- indexOf: function (record) {
- return _(this.records).indexOf(record);
- },
- succ: function (record, options) {
- options = options || {wraparound: false};
- var result;
- for(var section in this._proxies) {
- if (!this._proxies.hasOwnProperty(section)) {
- continue;
- }
- if ((result = this._proxies[section].succ(record, options))) {
- return result;
- }
- }
- var index = this.indexOf(record);
- if (index === -1) { return null; }
- var next_index = index + 1;
- if (options.wraparound && (next_index === this.length)) {
- return this.at(0);
- }
- return this.at(next_index);
- },
- pred: function (record, options) {
- options = options || {wraparound: false};
-
- var result;
- for (var section in this._proxies) {
- if (!this._proxies.hasOwnProperty(section)) {
- continue;
- }
- if ((result = this._proxies[section].pred(record, options))) {
- return result;
- }
- }
-
- var index = this.indexOf(record);
- if (index === -1) { return null; }
- var next_index = index - 1;
- if (options.wraparound && (next_index === -1)) {
- return this.at(this.length - 1);
- }
- return this.at(next_index);
- }
-});
-Collection.include(Events);
-
-return {
- Events: Events,
- Record: Record,
- Collection: Collection
-};
-
-});
diff --git a/addons/web/static/src/js/views/list_view.js b/addons/web/static/src/js/views/list_view.js
deleted file mode 100644
index 83e1b7bf900..00000000000
--- a/addons/web/static/src/js/views/list_view.js
+++ /dev/null
@@ -1,2075 +0,0 @@
-
-odoo.define('web.ListView', function (require) {
-"use strict";
-
-var core = require('web.core');
-var data = require('web.data');
-var data_manager = require('web.data_manager');
-var DataExport = require('web.DataExport');
-var formats = require('web.formats');
-var common = require('web.list_common');
-var Model = require('web.DataModel');
-var Pager = require('web.Pager');
-var pyeval = require('web.pyeval');
-var session = require('web.session');
-var Sidebar = require('web.Sidebar');
-var utils = require('web.utils');
-var View = require('web.View');
-
-var Class = core.Class;
-var _t = core._t;
-var _lt = core._lt;
-var QWeb = core.qweb;
-var list_widget_registry = core.list_widget_registry;
-
-// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes
-var row_decoration = [
- 'decoration-bf',
- 'decoration-it',
- 'decoration-danger',
- 'decoration-info',
- 'decoration-muted',
- 'decoration-primary',
- 'decoration-success',
- 'decoration-warning'
-];
-
-var ListView = View.extend({
- _template: 'ListView',
- accesskey: "l",
- require_fields: true,
- defaults: _.extend({}, View.prototype.defaults, {
- // records can be selected one by one
- selectable: true,
- // list rows can be deleted
- deletable: false,
- // whether the column headers should be displayed
- header: true,
- // display addition button, with that label
- addable: _lt("Create"),
- // whether the list view can be sorted, note that once a view has been
- // sorted it can not be reordered anymore
- sortable: true,
- // whether the view rows can be reordered (via vertical drag & drop)
- reorderable: true,
- action_buttons: true,
- //whether the editable property of the view has to be disabled
- disable_editable_mode: false,
- }),
- display_name: _lt('List'),
- events: {
- 'click thead th.o_column_sortable[data-id]': 'sort_by_column',
- 'click .oe_view_nocontent': function() {
- if (this.$buttons) {
- this.$buttons.width(this.$buttons.width() + 1).openerpBounce();
- }
- },
- },
- icon: 'fa-list-ul',
-
- /**
- * Core class for list-type displays.
- *
- * As a view, needs a number of view-related parameters to be correctly
- * instantiated, provides options and overridable methods for behavioral
- * customization.
- *
- * See constructor parameters and method documentations for information on
- * the default behaviors and possible options for the list view.
- *
- * @constructs ListView
- * @extends View
- *
- * @param parent parent object
- * @param {DataSet} dataset the dataset the view should work with
- * @param {String} view_id the listview's identifier, if any
- * @param {Object} options A set of options used to configure the view
- * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox)
- * @param {Boolean} [options.header=true] should the list's header be displayed
- * @param {Boolean} [options.deletable=true] are the list rows deletable
- * @param {void|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button.
- * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers
- * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows
- */
- init: function() {
- var self = this;
- this._super.apply(this, arguments);
- this.options = _.defaults(this.options, {
- GroupsType: ListView.Groups,
- ListType: ListView.List,
- });
-
- this.previous_colspan = null;
- this.decoration = null;
-
- this.columns = [];
-
- this.records = new common.Collection();
-
- this.set_groups(new (this.options.GroupsType)(this));
-
- if (this.dataset instanceof data.DataSetStatic) {
- this.groups.datagroup = new StaticDataGroup(this.dataset);
- } else {
- this.groups.datagroup = new DataGroup(
- this, this.model,
- this.dataset.get_domain(),
- this.dataset.get_context());
- this.groups.datagroup.sort = this.dataset._sort;
- }
-
- this.records.bind('change', function (event, record, key) {
- if (!_(self.aggregate_columns).chain()
- .pluck('name').contains(key).value()) {
- return;
- }
- self.compute_aggregates();
- });
-
- this.no_leaf = false;
- this.grouped = false;
-
- if (!this.options.$pager || !this.options.$pager.length) {
- this.options.$pager = false;
- }
-
- this.options.deletable = this.options.deletable && this.is_action_enabled('delete');
- this.name = "" + this.fields_view.arch.attrs.string;
-
- // the view's number of records per page (|| section)
- this._limit = (this.options.limit ||
- this.defaults.limit ||
- (this.getParent().action || {}).limit ||
- parseInt(this.fields_view.arch.attrs.limit, 10) ||
- 80);
- // the index of the first displayed record (starting from 1)
- this.current_min = 1;
-
- // Sort
- var default_order = this.fields_view.arch.attrs.default_order;
- var unsorted = !this.dataset._sort.length;
- if (unsorted && default_order && !this.grouped) {
- this.dataset.set_sort(default_order.split(','));
- }
- },
- willStart: function() {
- var self = this;
- // Retrieve the decoration defined on the model's list view
- this.decoration = _.pick(this.fields_view.arch.attrs, function(value, key) {
- return row_decoration.indexOf(key) >= 0;
- });
- this.decoration = _.mapObject(this.decoration, function(value) {
- return py.parse(py.tokenize(value));
- });
- var fields_def = data_manager.load_fields(this.dataset).then(function(fields_get) {
- self.fields_get = fields_get;
- });
- return $.when(this._super(), fields_def);
- },
- /**
- * Set a custom Group construct as the root of the List View.
- *
- * @param {instance.web.ListView.Groups} groups
- */
- set_groups: function (groups) {
- var self = this;
- if (this.groups) {
- $(this.groups).unbind("selected deleted action row_link");
- delete this.groups;
- }
-
- this.groups = groups;
- $(this.groups).bind({
- 'selected': function (e, ids, records, deselected) {
- self.do_select(ids, records, deselected);
- },
- 'deleted': function (e, ids) {
- self.do_delete(ids);
- },
- 'action': function (e, action_name, id, callback) {
- self.do_button_action(action_name, id, callback);
- },
- 'row_link': function (e, id, dataset, view) {
- self.do_activate_record(dataset.index, id, dataset, view);
- }
- });
- },
- /**
- * Computes and returns the classnames for the provided record (from the
- * ``@decoration`` attribute)
- *
- * @param {Record} record record for the current row
- * @returns {String} classnames
- */
- compute_decoration_classnames: function (record) {
- var classnames= '';
- var context = _.extend({}, record.attributes, {
- uid: session.uid,
- current_date: moment().format('YYYY-MM-DD')
- // TODO: time, datetime, relativedelta
- });
-
- _.each(this.decoration, function(expr, decoration) {
- if (py.PY_isTrue(py.evaluate(expr, context))) {
- classnames += ' ' + decoration.replace('decoration', 'text');
- }
- });
- return classnames;
- },
- /**
- * Renders the table itself and inserts its content
- * Computes and displays the aggregates
- * Hooks up the selection of records event
- * Adds adequate classname on the sorted column
- */
- load_list: function() {
- var self = this;
-
- // Render the table and append its content
- this.$el.html(QWeb.render(this._template, this));
- this.$el.addClass(this.fields_view.arch.attrs['class']);
- if (this.grouped) {
- this.$('.o_list_view').addClass('o_list_view_grouped');
- }
- this.$('.o_list_view').append(this.groups.elements);
-
- // Compute the aggregates and display them in the list's footer
- this.compute_aggregates();
-
- // Head hook
- // Selecting records
- this.$('thead .o_list_record_selector input').click(function() {
- self.$('tbody .o_list_record_selector input').prop('checked', $(this).prop('checked') || false);
- var selection = self.groups.get_selection();
- $(self.groups).trigger('selected', [selection.ids, selection.records]);
- });
-
- // Sort
- if (this.dataset._sort.length) {
- if (this.dataset._sort[0].indexOf('-') === -1) {
- this.$('th[data-id=' + this.dataset._sort[0] + ']').addClass("o-sort-down");
- } else {
- this.$('th[data-id=' + this.dataset._sort[0].split('-')[1] + ']').addClass("o-sort-up");
- }
- }
-
- this.trigger('list_view_loaded', data, this.grouped);
- return $.when();
- },
- /**
- * Render the buttons according to the ListView.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 ListView inserts them into this.options.$buttons
- * if it exists
- */
- render_buttons: function($node) {
- if (!this.$buttons) {
- this.$buttons = $(QWeb.render("ListView.buttons", {'widget': this}));
- this.$buttons.on('click', '.o_list_button_add', this.proxy('do_add_record'));
- this.$buttons.appendTo($node);
- }
- },
- /**
- * Instantiate and render the sidebar.
- * Sets this.sidebar
- * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted
- * $node may be undefined, in which case the ListView inserts the sidebar in
- * this.options.$sidebar or in a div of its template
- **/
- render_sidebar: function($node) {
- if (!this.sidebar && this.options.sidebar) {
- this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')});
- if (this.fields_view.toolbar) {
- this.sidebar.add_toolbar(this.fields_view.toolbar);
- }
- this.sidebar.add_items('other', _.compact([
- { label: _t("Export"), callback: this.on_sidebar_export },
- this.fields_view.fields.active && {label: _t("Archive"), callback: this.do_archive_selected},
- this.fields_view.fields.active && {label: _t("Restore"), callback: this.do_unarchive_selected},
- this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.do_delete_selected }
- ]));
-
- $node = $node || this.options.$sidebar;
- this.sidebar.appendTo($node);
-
- // Hide the sidebar by default (it will be shown as soon as a record is selected)
- this.sidebar.do_hide();
- }
- },
- /**
- * Instantiate and render the pager and add listeners on it.
- * Set this.pager
- * @param {jQuery} [$node] a jQuery node where the pager should be inserted
- * $node may be undefined, in which case the ListView inserts the pager into this.options.$pager
- */
- render_pager: function($node, options) {
- if (!this.pager && this.options.pager) {
- this.pager = new Pager(this, this.dataset.size(), 1, this._limit, options);
- this.pager.appendTo($node || this.options.$pager);
-
- this.pager.on('pager_changed', this, function (new_state) {
- var self = this;
- var limit_changed = (this._limit !== new_state.limit);
-
- this._limit = new_state.limit;
- this.current_min = new_state.current_min;
- this.reload_content().then(function() {
- // Reset the scroll position to the top on page changed only
- if (!limit_changed) {
- self.set_scrollTop(0);
- self.trigger_up('scrollTo', {offset: 0});
- }
- });
- });
- }
- },
- /**
- * Updates the pager based on the provided dataset's information
- *
- * Horrifying side-effect: sets the dataset's data on this.dataset?
- *
- * @param {instance.web.DataSet} [dataset]
- * @param {int} [current_min] the min pager value
- */
- update_pager: function (dataset, current_min) {
- this.dataset.ids = dataset.ids;
- // Not exactly clean
- if (dataset._length !== undefined) {
- this.dataset._length = dataset._length;
- }
- if (this.pager && !this.grouped) {
- var new_state = { size: this.dataset.size(), limit: this._limit };
- if (current_min) {
- new_state.current_min = current_min;
- }
- this.pager.update_state(new_state);
- }
- },
- sort_by_column: function (e) {
- e.stopPropagation();
- var $column = $(e.currentTarget);
- var col_name = $column.data('id');
- var field = this.fields_view.fields[col_name];
- // test whether the field is sortable
- if (field && !field.sortable) {
- return false;
- }
- this.dataset.sort(col_name);
- if($column.hasClass("o-sort-down") || $column.hasClass("o-sort-up")) {
- $column.toggleClass("o-sort-up o-sort-down");
- } else {
- $column.addClass("o-sort-down");
- }
- $column.siblings('.o_column_sortable').removeClass("o-sort-up o-sort-down");
-
- return this.reload_content();
- },
- /**
- * Sets up the listview's columns: merges view and fields data, move
- * grouped-by columns to the front of the columns list and make them all
- * visible.
- *
- * @param {Object} fields fields_view_get's fields section
- * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
- */
- setup_columns: function (fields, grouped) {
- this.columns.splice(0, this.columns.length);
- this.columns.push.apply(this.columns,
- _(this.fields_view.arch.children).map(function (field) {
- var id = field.attrs.name;
- return for_(id, fields[id], field);
- }));
- if (grouped) {
- this.columns.unshift(new ListView.MetaColumn('_group'));
- }
-
- this.visible_columns = _.filter(this.columns, function (column) {
- return column.invisible !== '1';
- });
-
- this.aggregate_columns = _(this.visible_columns).invoke('to_aggregate');
- },
- /**
- * Used to handle a click on a table row, if no other handler caught the
- * event.
- *
- * The default implementation asks the list view's view manager to switch
- * to a different view (by calling
- * :js:func:`~instance.web.ViewManager.on_mode_switch`), using the
- * provided record index (within the current list view's dataset).
- *
- * If the index is null, ``switch_to_record`` asks for the creation of a
- * new record.
- *
- * @param {Number|void} index the record index (in the current dataset) to switch to
- * @param {String} [view="page"] the view type to switch to
- */
- select_record:function (index, view) {
- view = view || index === null || index === undefined ? 'form' : 'form';
- this.dataset.index = index;
- _.delay(_.bind(function () {
- this.do_switch_view(view);
- }, this));
- },
- do_show: function () {
- this._super();
- if (this.sidebar) {
- // Hide the sidebar by default (will be shown once a record is selected)
- this.sidebar.do_hide();
- }
- },
- /**
- * re-renders the content of the list view
- *
- * @returns {$.Deferred} promise to content reloading
- */
- reload_content: synchronized(function () {
- var self = this;
- this.setup_columns(this.fields_view.fields, this.grouped);
- this.$('tbody .o_list_record_selector input').prop('checked', false);
- this.records.reset();
- var reloaded = $.Deferred();
- this.groups.render(function () {
- if (self.dataset.index === null) {
- if (self.records.length) {
- self.dataset.index = 0;
- }
- } else if (self.dataset.index >= self.records.length) {
- self.dataset.index = self.records.length ? 0 : null;
- }
- self.load_list().then(function () {
- if (!self.grouped && self.display_nocontent_helper()) {
- self.no_result();
- }
- reloaded.resolve();
- });
- });
- this.do_push_state({
- min: this.current_min,
- limit: this._limit
- });
- return reloaded.promise();
- }),
- reload: function () {
- return this.reload_content();
- },
- reload_record: function (record) {
- var self = this;
- var fields = this.fields_view.fields;
- return this.dataset.read_ids(
- [record.get('id')],
- _.pluck(_(this.columns).filter(function (r) {
- return r.tag === 'field';
- }), 'name'),
- {check_access_rule: true}
- ).then(function (records) {
- var values = records[0];
- if (!values) {
- self.records.remove(record);
- return;
- }
- // _.each is broken if a field "length" is present
- for (var key in values) {
- if (fields[key] && fields[key].type === 'many2many')
- record.set(key + '__display', false, {silent: true});
- record.set(key, values[key], {silent: true});
- }
- record.trigger('change', record);
-
- /* When a record is reloaded, there is a rendering lag because of the addition/suppression of
- a table row. Since the list view editable need to wait for the end of this rendering lag before
- computing the position of the editable fields, a 100ms delay is added. */
- var def = $.Deferred();
- setTimeout(function() {
- def.resolve(records);
- }, 100);
- return def;
- });
- },
-
- do_load_state: function(state, warm) {
- var reload = false;
- if (state.min && this.current_min !== state.min) {
- this.current_min = state.min;
- reload = true;
- }
- if (state.limit) {
- if (_.isString(state.limit)) {
- state.limit = null;
- }
- if (state.limit !== this._limit) {
- this._limit = state.limit;
- reload = true;
- }
- }
- if (reload) {
- this.reload_content();
- }
- },
- on_sidebar_export: function() {
- new DataExport(this, this.dataset).open();
- },
- /**
- * Handler for the result of eval_domain_and_context, actually perform the
- * searching
- *
- * @param {Object} results results of evaluating domain and process for a search
- */
- do_search: function (domain, context, group_by) {
- this.current_min = 1;
- this.groups.datagroup = new DataGroup(
- this, this.model, domain, context, group_by);
- this.groups.datagroup.sort = this.dataset._sort;
-
- if (_.isEmpty(group_by) && !context['group_by_no_leaf']) {
- group_by = null;
- }
- this.no_leaf = !!context['group_by_no_leaf'];
- this.grouped = !!group_by;
-
- // Hide the pager in grouped mode
- if (this.pager && this.grouped) {
- this.pager.do_hide();
- }
- return this.reload_content();
- },
- /**
- * Handles the signal to delete lines from the records list
- *
- * @param {Array} ids the ids of the records to delete
- */
- do_delete: function (ids) {
- if (!(ids.length && confirm(_t("Do you really want to remove these records?")))) {
- return;
- }
- var self = this;
-
- return $.when(this.dataset.unlink(ids)).done(function () {
- _(ids).each(function (id) {
- self.records.remove(self.records.get(id));
- });
- // Hide the table if there is no more record in the dataset
- if (self.display_nocontent_helper()) {
- self.no_result();
- } else {
- if (self.records.length && self.current_min === 1) {
- // Reload the list view if we delete all the records of the first page
- self.reload();
- } else if (self.records.length && self.dataset.size() > 0) {
- // Load previous page if the current one is empty
- self.pager.previous();
- }
- // Reload the list view if we are not on the last page
- if (self.current_min + self._limit - 1 < self.dataset.size()) {
- self.reload();
- }
- }
- self.update_pager(self.dataset);
- self.compute_aggregates();
- });
- },
- /**
- * Handles the signal indicating that a new record has been selected
- *
- * @param {Array} ids selected record ids
- * @param {Array} records selected record values
- */
- do_select: function (ids, records, deselected) {
- // uncheck header hook if at least one row has been deselected
- if (deselected) {
- this.$('thead .o_list_record_selector input').prop('checked', false);
- }
-
- if (!ids.length) {
- this.dataset.index = 0;
- if (this.sidebar) {
- this.sidebar.do_hide();
- }
- this.compute_aggregates();
- return;
- }
-
- this.dataset.index = _(this.dataset.ids).indexOf(ids[0]);
- if (this.sidebar) {
- this.sidebar.do_show();
- }
-
- this.compute_aggregates(_(records).map(function (record) {
- return {count: 1, values: record};
- }));
- },
- /**
- * Handles action button signals on a record
- *
- * @param {String} name action name
- * @param {Object} id id of the record the action should be called on
- * @param {Function} callback should be called after the action is executed, if non-null
- */
- do_button_action: function (name, id, callback) {
- this.handle_button(name, id, callback);
- },
- /**
- * Base handling of buttons, can be called when overriding do_button_action
- * in order to bypass parent overrides.
- *
- * The callback will be provided with the ``id`` as its parameter, in case
- * handle_button's caller had to alter the ``id`` (or even create one)
- * while not being ``callback``'s creator.
- *
- * This method should not be overridden.
- *
- * @param {String} name action name
- * @param {Object} id id of the record the action should be called on
- * @param {Function} callback should be called after the action is executed, if non-null
- */
- handle_button: function (name, id, callback) {
- var action = _.detect(this.columns, function (field) {
- return field.name === name;
- });
- if (!action) { return; }
- if ('confirm' in action && !window.confirm(action.confirm)) {
- return;
- }
-
- var c = new data.CompoundContext();
- c.set_eval_context(_.extend({
- active_id: id,
- active_ids: [id],
- active_model: this.model
- }, this.records.get(id).toContext()));
- if (action.context) {
- c.add(action.context);
- }
- action.context = c;
- this.do_execute_action(
- action, this.dataset, id, _.bind(callback, null, id));
- },
- /**
- * Handles the activation of a record (clicking on it)
- *
- * @param {Number} index index of the record in the dataset
- * @param {Object} id identifier of the activated record
- * @param {instance.web.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
- */
- do_activate_record: function (index, id, dataset, view) {
- this.dataset.ids = dataset.ids;
- this.select_record(index, view);
- },
- /**
- * Handles signal for the addition of a new record (can be a creation,
- * can be the addition from a remote source, ...)
- *
- * The default implementation is to switch to a new record on the form view
- */
- do_add_record: function () {
- this.select_record(null);
- },
- /**
- * Handles deletion of all selected lines
- */
- do_delete_selected: function () {
- var ids = this.groups.get_selection().ids;
- if (ids.length) {
- this.do_delete(this.groups.get_selection().ids);
- } else {
- this.do_warn(_t("Warning"), _t("You must select at least one record."));
- }
- },
- /**
- * Handles archiving/unarchiving of selected lines
- */
- do_archive_selected: function () {
- var records = this.groups.get_selection().records;
- this.do_archive(records, true);
- },
- do_unarchive_selected: function () {
- var records = this.groups.get_selection().records;
- this.do_archive(records, false);
- },
- do_archive: function (records, archive) {
- var active_value = !archive;
- var record_ids = [];
- _.each(records, function(record) {
- if (record.active != active_value) {
- record_ids.push(record.id);
- }
- });
- if (record_ids.length) {
- this.dataset.call('write', [record_ids, {active: active_value}])
- .done(_.bind(this.reload, this));
- }
- },
- /**
- * Computes the aggregates for the current list view, either on the
- * records provided or on the records of the internal
- * :js:class:`~instance.web.ListView.Group`, by calling
- * :js:func:`~instance.web.ListView.group.get_records`.
- *
- * Then displays the aggregates in the table through
- * :js:method:`~instance.web.ListView.display_aggregates`.
- *
- * @param {Array} [records]
- */
- compute_aggregates: function (records) {
- var columns = _(this.aggregate_columns).filter(function (column) {
- return column['function']; });
- if (_.isEmpty(columns)) { return; }
-
- if (_.isEmpty(records)) {
- records = this.groups.get_records();
- }
- records = _(records).compact();
-
- var count = 0, sums = {};
- _(columns).each(function (column) {
- switch (column['function']) {
- case 'max':
- sums[column.id] = -Infinity;
- break;
- case 'min':
- sums[column.id] = Infinity;
- break;
- default:
- sums[column.id] = 0;
- }
- });
- _(records).each(function (record) {
- count += record.count || 1;
- _(columns).each(function (column) {
- var field = column.id,
- value = record.values[field];
- switch (column['function']) {
- case 'sum':
- sums[field] += value;
- break;
- case 'avg':
- sums[field] += record.count * value;
- break;
- case 'min':
- if (sums[field] > value) {
- sums[field] = value;
- }
- break;
- case 'max':
- if (sums[field] < value) {
- sums[field] = value;
- }
- break;
- }
- });
- });
-
- var aggregates = {};
- _.each(_.filter(columns, function (column) {
- if (column.currency_field && records.length > 0 && records[0].values['currency_id']) {
- var currency_ids = _.map(records, function(record) {return record.values['currency_id'][0]});
- if (_.every(currency_ids, function (currency_id){return currency_id === currency_ids[0]})) {
- return column;
- }
- } else {
- return column;
- }
- }), function (column) {
- var field = column.id;
- switch (column['function']) {
- case 'avg':
- aggregates[field] = {value: sums[field] / count};
- break;
- default:
- aggregates[field] = {value: sums[field]};
- }
- });
-
- this.display_aggregates(aggregates);
- },
- display_aggregates: function (aggregation) {
- var $footer_cells = this.$('tfoot td');
- _(this.aggregate_columns).each(function (column) {
- if (!column['function']) {
- return;
- }
-
- $footer_cells.filter(_.str.sprintf('[data-field=%s]', column.id))
- .addClass('o_list_number')
- .html(column.format(aggregation, { process_modifiers: false }));
- });
- },
- get_selected_ids: function() {
- var ids = this.groups.get_selection().ids;
- return ids;
- },
- /**
- * Calculate the active domain of the list view. This should be done only
- * if the header checkbox has been checked. This is done by evaluating the
- * search results, and then adding the dataset domain (i.e. action domain).
- */
- get_active_domain: function () {
- var self = this;
- if (this.$('thead .o_list_record_selector input').prop('checked')) {
- var search_view = this.getParent().searchview;
- var search_data = search_view.build_search_data();
- return pyeval.eval_domains_and_contexts({
- domains: search_data.domains,
- contexts: search_data.contexts,
- group_by_seq: search_data.groupbys || []
- }).then(function (results) {
- var domain = self.dataset.domain.concat(results.domain || []);
- return domain;
- });
- }
- else {
- return $.Deferred().resolve();
- }
- },
- /**
- * Adds padding columns at the start or end of all table rows (including
- * field names row)
- *
- * @param {Number} count number of columns to add
- * @param {Object} options
- * @param {"before"|"after"} [options.position="after"] insertion position for the new columns
- * @param {Object} [options.except] content row to not pad
- */
- pad_columns: function (count, options) {
- options = options || {};
- // padding for action/pager header
- var $first_header = this.$('thead tr:first th');
- var colspan = $first_header.attr('colspan');
- if (colspan) {
- if (!this.previous_colspan) {
- this.previous_colspan = colspan;
- }
- $first_header.attr('colspan', parseInt(colspan, 10) + count);
- }
- // Padding for column titles, footer and data rows
- var $rows = this.$el
- .find('.oe_list_header_columns, tr:not(thead tr)')
- .not(options['except']);
- var newcols = new Array(count+1).join('<td class="oe_list_padding"></td>');
- if (options.position === 'before') {
- $rows.prepend(newcols);
- } else {
- $rows.append(newcols);
- }
- },
- /**
- * Removes all padding columns of the table
- */
- unpad_columns: function () {
- this.$('.oe_list_padding').remove();
- if (this.previous_colspan) {
- this.$el
- .find('thead tr:first th')
- .attr('colspan', this.previous_colspan);
- this.previous_colspan = null;
- }
- },
- display_nocontent_helper: function () {
- return (this.dataset.size() === 0);
- },
- no_result: function () {
- this.$('.oe_view_nocontent').remove();
- if (this.groups.group_by ||
- !this.options.action ||
- !this.options.action.help) {
- return;
- }
- this.$('table:first').hide();
- this.$el.prepend(
- $('<div class="oe_view_nocontent">').html(this.options.action.help)
- );
- }
-});
-core.view_registry.add('list', ListView);
-
-ListView.List = Class.extend({
- /**
- * List display for the ListView, handles basic DOM events and transforms
- * them in the relevant higher-level events, to which the list view (or
- * other consumers) can subscribe.
- *
- * Events on this object are registered via jQuery.
- *
- * Available events:
- *
- * `selected`
- * Triggered when a row is selected (using check boxes), provides an
- * array of ids of all the selected records.
- * `deleted`
- * Triggered when deletion buttons are hit, provide an array of ids of
- * all the records being marked for suppression.
- * `action`
- * Triggered when an action button is clicked, provides two parameters:
- *
- * * The name of the action to execute (as a string)
- * * The id of the record to execute the action on
- * `row_link`
- * Triggered when a row of the table is clicked, provides the index (in
- * the rows array) and id of the selected record to the handle function.
- *
- * @constructs instance.web.ListView.List
- * @extends instance.web.Class
- *
- * @param {Object} opts display options, identical to those of :js:class:`instance.web.ListView`
- */
- init: function (group, opts) {
- var self = this;
- this.group = group;
- this.view = group.view;
- this.session = this.view.session;
-
- this.options = opts.options;
- this.columns = opts.columns;
- this.dataset = opts.dataset;
- this.records = opts.records;
-
- this.record_callbacks = {
- 'remove': function (event, record) {
- var id = record.get('id');
- self.dataset.remove_ids([id]);
- var $row = self.$current.children('[data-id=' + id + ']');
- var index = $row.data('index');
- $row.remove();
- },
- 'reset': function () { return self.on_records_reset(); },
- 'change': function (event, record, attribute, value, old_value) {
- var $row;
- if (attribute === 'id') {
- if (old_value) {
- throw new Error(_.str.sprintf( _t("Setting 'id' attribute on existing record %s"),
- JSON.stringify(record.attributes) ));
- }
- self.dataset.add_ids([value], self.records.indexOf(record));
- // Set id on new record
- $row = self.$current.children('[data-id=false]');
- } else {
- $row = self.$current.children(
- '[data-id=' + record.get('id') + ']');
- }
- if ($row.length) {
- var $newRow = $(self.render_record(record));
- $newRow.find('.o_list_record_selector input').prop('checked', !!$row.find('.o_list_record_selector input').prop('checked'));
- $row.replaceWith($newRow);
- }
- },
- 'add': function (ev, records, record, index) {
- var $new_row = $(self.render_record(record));
- var id = record.get('id');
- if (id) { self.dataset.add_ids([id], index); }
-
- if (index === 0) {
- $new_row.prependTo(self.$current);
- } else {
- var previous_record = records.at(index-1),
- $previous_sibling = self.$current.children(
- '[data-id=' + previous_record.get('id') + ']');
- $new_row.insertAfter($previous_sibling);
- }
- }
- };
- _(this.record_callbacks).each(function (callback, event) {
- this.records.bind(event, callback);
- }, this);
-
- this.$current = $('<tbody>')
- .delegate('input[readonly=readonly]', 'click', function (e) {
- /*
- Against all logic and sense, as of right now @readonly
- apparently does nothing on checkbox and radio inputs, so
- the trick of using @readonly to have, well, readonly
- checkboxes (which still let clicks go through) does not
- work out of the box. We *still* need to preventDefault()
- on the event, otherwise the checkbox's state *will* toggle
- on click
- */
- e.preventDefault();
- })
- .delegate('td.o_list_record_selector', 'click', function (e) {
- e.stopPropagation();
- var selection = self.get_selection();
- var checked = $(e.currentTarget).find('input').prop('checked');
- $(self).trigger(
- 'selected', [selection.ids, selection.records, ! checked]);
- })
- .delegate('td.o_list_record_delete', 'click', function (e) {
- e.stopPropagation();
- var $row = $(e.target).closest('tr');
- $(self).trigger('deleted', [[self.row_id($row)]]);
- // IE Edge go crazy when we use confirm dialog and remove the focused element
- if(document.hasFocus && !document.hasFocus()) {
- $('<input />').appendTo('body').focus().remove();
- }
- })
- .delegate('td button', 'click', function (e) {
- e.stopPropagation();
- var $target = $(e.currentTarget),
- field = $target.closest('td').data('field'),
- $row = $target.closest('tr'),
- record_id = self.row_id($row);
-
- if ($target.attr('disabled')) {
- return;
- }
- $target.attr('disabled', 'disabled');
-
- // note: $.data converts data to number if it's composed only
- // of digits, nice when storing actual numbers, not nice when
- // storing strings composed only of digits. Force the action
- // name to be a string
- $(self).trigger('action', [field.toString(), record_id, function (id) {
- $target.removeAttr('disabled');
- return self.reload_record(self.records.get(id));
- }]);
- })
- .delegate('a', 'click', function (e) {
- e.stopPropagation();
- })
- .delegate('tr', 'click', function (e) {
- var row_id = self.row_id(e.currentTarget);
- if (row_id) {
- e.stopPropagation();
- if (!self.dataset.select_id(row_id)) {
- throw new Error(_t("Could not find id in dataset"));
- }
- self.row_clicked(e);
- }
- });
- },
- row_clicked: function (e, view) {
- $(this).trigger(
- 'row_link',
- [this.dataset.ids[this.dataset.index],
- this.dataset, view]);
- },
- render_cell: function (record, column) {
- var value;
- if(column.type === 'reference') {
- value = record.get(column.id);
- var ref_match;
- // Ensure that value is in a reference "shape", otherwise we're
- // going to loop on performing name_get after we've resolved (and
- // set) a human-readable version. m2o does not have this issue
- // because the non-human-readable is just a number, where the
- // human-readable version is a pair
- if (value && (ref_match = /^([\w\.]+),(\d+)$/.exec(value))) {
- // reference values are in the shape "$model,$id" (as a
- // string), we need to split and name_get this pair in order
- // to get a correctly displayable value in the field
- var model = ref_match[1],
- id = parseInt(ref_match[2], 10);
- new data.DataSet(this.view, model).name_get([id]).done(function(names) {
- if (!names.length) { return; }
- record.set(column.id + '__display', names[0][1]);
- });
- }
- } else if (column.type === 'many2one') {
- value = record.get(column.id);
- // m2o values are usually name_get formatted, [Number, String]
- // pairs, but in some cases only the id is provided. In these
- // cases, we need to perform a name_get call to fetch the actual
- // displayable value
- if (typeof value === 'number' || value instanceof Number) {
- // fetch the name, set it on the record (in the right field)
- // and let the various registered events handle refreshing the
- // row
- new data.DataSet(this.view, column.relation)
- .name_get([value]).done(function (names) {
- if (!names.length) { return; }
- record.set(column.id, names[0]);
- });
- }
- } else if (column.type === 'many2many') {
- value = record.get(column.id);
- // non-resolved (string) m2m values are arrays
- if (value instanceof Array && !_.isEmpty(value)
- && !record.get(column.id + '__display')) {
- var ids;
- // they come in two shapes:
- if (value[0] instanceof Array) {
- _.each(value, function(command) {
- switch (command[0]) {
- case 4: ids.push(command[1]); break;
- case 5: ids = []; break;
- case 6: ids = command[2]; break;
- default: throw new Error(_.str.sprintf( _t("Unknown m2m command %s"), command[0]));
- }
- });
- } else {
- // 2. an array of ids
- ids = value;
- }
- new Model(column.relation)
- .call('name_get', [ids, this.dataset.get_context()]).done(function (names) {
- // FIXME: nth horrible hack in this poor listview
- record.set(column.id + '__display',
- _(names).pluck(1).join(', '));
- record.set(column.id, ids);
- });
- // temporary empty display name
- record.set(column.id + '__display', false);
- }
- }
- return column.format(record.toForm().data, {
- model: this.dataset.model,
- id: record.get('id')
- });
- },
- render: function () {
- var self = this;
- this.$current.html(
- QWeb.render('ListView.rows', _.extend({}, this, {
- render_cell: function () {
- return self.render_cell.apply(self, arguments); }
- })));
- this.pad_table_to(4);
- },
- pad_table_to: function (count) {
- if (this.records.length >= count ||
- _(this.columns).any(function(column) { return column.meta; })) {
- return;
- }
- var cells = [];
- if (this.options.selectable) {
- cells.push('<td class="o_list_record_selector"></td>');
- }
- _(this.columns).each(function(column) {
- if (column.invisible === '1') {
- return;
- }
- cells.push('<td title="' + column.string + '">&nbsp;</td>');
- });
- if (this.options.deletable) {
- cells.push('<td class="o_list_record_delete"></td>');
- }
- cells.unshift('<tr>');
- cells.push('</tr>');
-
- var row = cells.join('');
- this.$current
- .children('tr:not([data-id])').remove().end()
- .append(new Array(count - this.records.length + 1).join(row));
- },
- /**
- * Gets the ids of all currently selected records, if any
- * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
- */
- get_selection: function () {
- var result = {ids: [], records: []};
- if (!this.options.selectable) {
- return result;
- }
- var records = this.records;
- this.$current.find('td.o_list_record_selector input:checked')
- .closest('tr').each(function () {
- var record = records.get($(this).data('id'));
- result.ids.push(record.get('id'));
- result.records.push(record.attributes);
- });
- return result;
- },
- /**
- * Returns the identifier of the object displayed in the provided table
- * row
- *
- * @param {Object} row the selected table row
- * @returns {Number|String} the identifier of the row's object
- */
- row_id: function (row) {
- return $(row).data('id');
- },
- /**
- * Death signal, cleans up records's callbacks
- */
- on_records_reset: function () {
- _(this.record_callbacks).each(function (callback, event) {
- this.records.unbind(event, callback);
- }, this);
- },
- get_records: function () {
- return this.records.map(function (record) {
- return {count: 1, values: record.attributes};
- });
- },
- /**
- * Reloads the provided record by re-reading its content from the server.
- *
- * @param {Record} record
- * @returns {$.Deferred} promise to the finalization of the reloading
- */
- reload_record: function (record) {
- return this.view.reload_record(record);
- },
- /**
- * Renders a list record to HTML
- *
- * @param {Record} record index of the record to render in ``this.rows``
- * @returns {String} QWeb rendering of the selected record
- */
- render_record: function (record) {
- var self = this;
- var index = this.records.indexOf(record);
- return QWeb.render('ListView.row', {
- columns: this.columns,
- options: this.options,
- record: record,
- row_parity: (index % 2 === 0) ? 'even' : 'odd',
- view: this.view,
- render_cell: function () {
- return self.render_cell.apply(self, arguments); }
- });
- }
-});
-
-ListView.Groups = Class.extend({
- passthrough_events: 'action deleted row_link',
- /**
- * Grouped display for the ListView. Handles basic DOM events and interacts
- * with the :js:class:`~DataGroup` bound to it.
- *
- * Provides events similar to those of
- * :js:class:`~instance.web.ListView.List`
- *
- * @constructs instance.web.ListView.Groups
- * @extends instance.web.Class
- *
- * @param {instance.web.ListView} view
- * @param {Object} [options]
- * @param {Collection} [options.records]
- * @param {Object} [options.options]
- * @param {Array} [options.columns]
- */
- init: function (view, options) {
- options = options || {};
- this.view = view;
- this.records = options.records || view.records;
- this.options = options.options || view.options;
- this.columns = options.columns || view.columns;
- this.datagroup = null;
-
- this.$row = null;
- this.children = {};
-
- this.pager = null; // group pager
-
- var self = this;
- this.records.bind('reset', function () {
- return self.on_records_reset(); });
- },
- make_fragment: function () {
- return document.createDocumentFragment();
- },
- /**
- * Returns a DOM node after which a new tbody can be inserted, so that it
- * follows the provided row.
- *
- * Necessary to insert the result of a new group or list view within an
- * existing groups render, without losing track of the groups's own
- * elements
- *
- * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
- * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
- */
- point_insertion: function (row) {
- var $row = $(row);
- var red_letter_tboday = $row.closest('tbody')[0];
-
- var $next_siblings = $row.nextAll();
- if ($next_siblings.length) {
- var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
-
- $root_kanal.append($next_siblings);
- this.elements.splice(
- _.indexOf(this.elements, red_letter_tboday),
- 0,
- $root_kanal[0]);
- }
- return red_letter_tboday;
- },
- /**
- * Renders the group pager and append it to the group's header
- */
- render_group_pager: function () {
- var self = this;
- if (this.datagroup.length > this.view._limit) {
- this.pager = new Pager(this, this.datagroup.length, 1, this.view._limit);
- this.pager.on('pager_changed', this, function (state) {
- self.view._limit = state.limit;
- self.current_min = state.current_min;
- self.render().then(function() {
- self.$row.closest('tbody').next().replaceWith($(self.elements));
- });
- });
-
- var $last_cell = this.$row.children().last();
- $last_cell.addClass('o_group_pager');
- // Prevent group to fold when clicking on the pager
- $last_cell.click(function(e) {
- e.stopPropagation();
- });
- this.pager.appendTo($last_cell);
- }
- },
- open: function (point_insertion) {
- this.render();
- $(this.elements).insertAfter(point_insertion);
-
- var no_subgroups = _(this.datagroup.group_by).isEmpty(),
- records_terminated = !this.datagroup.context['group_by_no_leaf'];
- if (no_subgroups && records_terminated) {
- this.render_group_pager();
- }
- },
- close: function () {
- if (this.pager) {
- this.pager.destroy();
- }
- this.records.reset();
- this.$to_be_removed.remove();
- },
- /**
- * Prefixes ``$node`` with floated spaces in order to indent it relative
- * to its own left margin/baseline
- *
- * @param {jQuery} $node jQuery object to indent
- * @param {Number} level current nesting level, >= 1
- * @returns {jQuery} the indentation node created
- */
- indent: function ($node, level) {
- return $('<span>')
- .css({'float': 'left', 'white-space': 'pre'})
- .text(new Array(level).join(' '))
- .prependTo($node);
- },
- render_groups: function (datagroups) {
- var self = this;
- var placeholder = this.make_fragment();
- _(datagroups).each(function (group) {
- if (self.children[group.value]) {
- self.records.proxy(group.value).reset();
- delete self.children[group.value];
- }
- var child = self.children[group.value] = new (self.view.options.GroupsType)(self.view, {
- records: self.records.proxy(group.value),
- options: self.options,
- columns: self.columns
- });
- self.bind_child_events(child);
- child.datagroup = group;
-
- var $row = child.$row = $('<tr class="o_group_header">');
- if (group.openable && group.length) {
- $row.click(function (e) {
- if (!$row.data('open')) {
- $row.data('open', true)
- .find('span.fa')
- .removeClass('fa-caret-right')
- .addClass('fa-caret-down');
- child.open(self.point_insertion(e.currentTarget));
- } else {
- $row.removeData('open')
- .find('span.fa')
- .removeClass('fa-caret-down')
- .addClass('fa-caret-right');
- child.close();
- // force recompute the selection as closing group reset properties
- var selection = self.get_selection();
- $(self).trigger('selected', [selection.ids, this.records]);
- }
- });
- }
- placeholder.appendChild($row[0]);
-
- var $group_column = $('<th class="o_group_name">').appendTo($row);
- // Don't fill this if group_by_no_leaf but no group_by
- if (group.grouped_on) {
- var row_data = {};
- row_data[group.grouped_on] = group;
- var group_label = _t("Undefined");
- var group_column = _(self.columns).detect(function (column) {
- return column.id === group.grouped_on; });
- if (group_column) {
- try {
- group_label = group_column.format(row_data, {
- value_if_empty: _t("Undefined"),
- process_modifiers: false
- });
- } catch (e) {
- group_label = _.str.escapeHTML(row_data[group_column.id].value);
- }
- } else {
- group_label = group.value;
- var grouped_on_field = self.view.fields_get[group.grouped_on];
- if (grouped_on_field && grouped_on_field.type === 'selection') {
- group_label = _.find(grouped_on_field.selection, function(selection) {
- return selection[0] === group.value;
- });
- }
- if (group_label instanceof Array) {
- group_label = group_label[1];
- }
- if (group_label === false) {
- group_label = _t('Undefined');
- }
- group_label = _.str.escapeHTML(group_label);
- }
-
- // group_label is html-clean (through format or explicit
- // escaping if format failed), can inject straight into HTML
- $group_column.html(_.str.sprintf("%s (%d)",
- group_label, group.length));
-
- if (group.length && group.openable) {
- // Make openable if not terminal group & group_by_no_leaf
- $group_column.prepend('<span class="fa fa-caret-right" style="padding-right: 5px;">');
- } else {
- $group_column.prepend('<span class="fa">');
- }
- }
- self.indent($group_column, group.level);
-
- if (self.options.selectable) {
- $row.append('<td>');
- }
- _(self.columns).chain()
- .filter(function (column) { return column.invisible !== '1'; })
- .each(function (column) {
- if (column.meta) {
- // do not do anything
- } else if (column.id in group.aggregates) {
- var r = {};
- r[column.id] = {value: group.aggregates[column.id]};
- $('<td class="oe_number">')
- .html(column.format(r, {process_modifiers: false}))
- .appendTo($row);
- } else {
- $row.append('<td>');
- }
- });
- if (self.options.deletable) {
- $row.append('<td class="oe_list_group_pagination">');
- }
- });
- return placeholder;
- },
- bind_child_events: function (child) {
- var $this = $(this),
- self = this;
- $(child).bind('selected', function (e, _0, _1, deselected) {
- // can have selections spanning multiple links
- var selection = self.get_selection();
- $this.trigger(e, [selection.ids, selection.records, deselected]);
- }).bind(this.passthrough_events, function (e) {
- // additional positional parameters are provided to trigger as an
- // Array, following the event type or event object, but are
- // provided to the .bind event handler as *args.
- // Convert our *args back into an Array in order to trigger them
- // on the group itself, so it can ultimately be forwarded wherever
- // it's supposed to go.
- var args = Array.prototype.slice.call(arguments, 1);
- $this.trigger.call($this, e, args);
- });
- },
- render_dataset: function (dataset) {
- var self = this,
- list = new (this.view.options.ListType)(this, {
- options: this.options,
- columns: this.columns,
- dataset: dataset,
- records: this.records
- });
- this.bind_child_events(list);
-
- var view = this.view;
- var current_min = this.datagroup.openable ? this.current_min : view.current_min;
-
- var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name');
- var options = { offset: current_min - 1, limit: view._limit, context: {bin_size: true} };
- return utils.async_when().then(function() {
- return dataset.read_slice(fields, options).then(function (records) {
- // FIXME: ignominious hacks, parents (aka form view) should not send two ListView#reload_content concurrently
- if (self.records.length) {
- self.records.reset(null, {silent: true});
- }
- if (!self.datagroup.openable) {
- // Update the main list view pager
- view.update_pager(dataset, current_min);
- }
-
- self.records.add(records, {silent: true});
- list.render();
- return list;
- });
- });
- },
- setup_resequence_rows: function (list, dataset) {
- var sequence_field = _(this.columns).findWhere({'widget': 'handle'});
- var seqname = sequence_field ? sequence_field.name : 'sequence';
-
- // drag and drop enabled if list is not sorted (unless it is sorted by
- // its sequence field (ASC)), and there is a visible column with
- // @widget=handle or "sequence" column in the view.
- if ((dataset.sort && [seqname, seqname + ' ASC', ''].indexOf(dataset.sort()) === -1)
- || !_(this.columns).findWhere({'name': seqname})) {
- return;
- }
-
- // ondrop, move relevant record & fix sequences
- list.$current.sortable({
- axis: 'y',
- items: '> tr[data-id]',
- helper: 'clone'
- });
- if (sequence_field) {
- list.$current.sortable('option', 'handle', '.o_row_handle');
- }
- list.$current.sortable('option', {
- start: function (e, ui) {
- ui.placeholder.height(ui.item.height());
- },
- stop: function (event, ui) {
- var to_move = list.records.get(ui.item.data('id')),
- target_id = ui.item.prev().data('id'),
- from_index = list.records.indexOf(to_move),
- target = list.records.get(target_id);
- if (list.records.at(from_index - 1) == target) {
- return;
- }
-
- list.records.remove(to_move, {silent: true});
- var to = target_id ? list.records.indexOf(target) + 1 : 0;
- list.records.add(to_move, { at: to, silent: true });
-
- // resequencing time!
- var record, index = to,
- // if drag to 1st row (to = 0), start sequencing from 0
- // (exclusive lower bound)
- seq = to ? list.records.at(to - 1).get(seqname) : 0;
- var defs = [];
- var fct = function (dataset, id, seq) {
- defs.push(utils.async_when().then(function () {
- var attrs = {};
- attrs[seqname] = seq;
- return dataset.write(id, attrs, {internal_dataset_changed: true});
- }));
- };
- while (++seq, (record = list.records.at(index++))) {
- // write are independent from one another, so we can just
- // launch them all at the same time and we don't really
- // give a fig about when they're done
- fct(dataset, record.get('id'), seq);
- record.set(seqname, seq);
- }
- $.when.apply($, defs).then(function () {
- // use internal_dataset_changed and trigger one onchange after all writes
- dataset.trigger("dataset_changed");
- });
- }
- });
- },
- render: function (post_render) {
- var self = this;
- var $el = $('<tbody>');
- this.elements = [$el[0]];
-
- return this.datagroup.list(
- _(this.view.visible_columns).chain()
- .filter(function (column) { return column.tag === 'field';})
- .pluck('name').value(),
- function (groups) {
- $el[0].appendChild(
- self.render_groups(groups));
- if (post_render) { post_render(); }
- }, function (dataset) {
- return self.render_dataset(dataset).then(function (list) {
- self.children[null] = list;
- self.elements =
- [list.$current.replaceAll($el)[0]];
- self.setup_resequence_rows(list, dataset);
- }).always(function() {
- if (post_render) { post_render(); }
- self.view.trigger('view_list_rendered');
- });
- });
- },
- /**
- * Returns the ids of all selected records for this group, and the records
- * themselves
- */
- get_selection: function () {
- var ids = [], records = [];
-
- _(this.children)
- .each(function (child) {
- var selection = child.get_selection();
- ids.push.apply(ids, selection.ids);
- records.push.apply(records, selection.records);
- });
-
- return {ids: ids, records: records};
- },
- on_records_reset: function () {
- this.$to_be_removed = $(this.elements);
- _.each(this.children, function(child){
- this.$to_be_removed = this.$to_be_removed.add(child.$to_be_removed);
- }.bind(this));
- this.children = {};
- },
- get_records: function () {
- if (_(this.children).isEmpty()) {
- if (!this.datagroup.length) {
- return;
- }
- return {
- count: this.datagroup.length,
- values: this.datagroup.aggregates
- };
- }
- return _(this.children).chain()
- .map(function (child) {
- return child.get_records();
- }).flatten().value();
- }
-});
-
-/**
- * Serializes concurrent calls to this asynchronous method. The method must
- * return a deferred or promise.
- *
- * Current-implementation is class-serialized (the mutex is common to all
- * instances of the list view). Can be switched to instance-serialized if
- * having concurrent list views becomes possible and common.
- */
-function synchronized(fn) {
- var fn_mutex = new utils.Mutex();
- return function () {
- var obj = this;
- var args = _.toArray(arguments);
- return fn_mutex.exec(function () {
- if (obj.isDestroyed()) { return $.when(); }
- return fn.apply(obj, args);
- });
- };
-}
-
-var DataGroup = Class.extend({
- init: function(parent, model, domain, context, group_by, level) {
- this.model = new Model(model, context, domain);
- this.group_by = group_by;
- this.context = context;
- this.domain = domain;
-
- this.level = level || 0;
- },
- list: function (fields, ifGroups, ifRecords) {
- var self = this;
- if (!_.isEmpty(this.group_by)) {
- // ensure group_by fields are read.
- fields = _.unique((fields || []).concat(this.group_by));
- }
- var query = this.model.query(fields).order_by(this.sort).group_by(this.group_by);
- return $.when(query).then(function (querygroups) {
- // leaf node
- if (!querygroups) {
- var ds = new data.DataSetSearch(self, self.model.name, self.model.context(), self.model.domain());
- ds._sort = self.sort;
- return ifRecords(ds);
- }
- // internal node
- var child_datagroups = _(querygroups).map(function (group) {
- var child_context = _.extend(
- {}, self.model.context(), group.model.context());
- var child_dg = new DataGroup(
- self, self.model.name, group.model.domain(),
- child_context, group.model._context.group_by,
- self.level + 1);
- child_dg.sort = self.sort;
- // copy querygroup properties
- child_dg.__context = child_context;
- child_dg.__domain = group.model.domain();
- child_dg.folded = group.get('folded');
- child_dg.grouped_on = group.get('grouped_on');
- child_dg.length = group.get('length');
- child_dg.value = group.get('value');
- child_dg.openable = group.get('has_children');
- child_dg.aggregates = group.get('aggregates');
- return child_dg;
- });
- ifGroups(child_datagroups);
- });
- }
-});
-
-var StaticDataGroup = DataGroup.extend({
- init: function (dataset) {
- this.dataset = dataset;
- },
- list: function (fields, ifGroups, ifRecords) {
- return ifRecords(this.dataset);
- }
-});
-
-var Column = Class.extend({
- init: function (id, tag, attrs) {
- _.extend(attrs, {
- id: id,
- tag: tag
- });
- this.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
- delete attrs.modifiers;
- _.extend(this, attrs);
-
- if (this.modifiers['tree_invisible']) {
- this.invisible = '1';
- } else { delete this.invisible; }
- },
- modifiers_for: function (fields) {
- var out = {};
- var domain_computer = data.compute_domain;
-
- for (var attr in this.modifiers) {
- if (!this.modifiers.hasOwnProperty(attr)) { continue; }
- var modifier = this.modifiers[attr];
- out[attr] = _.isBoolean(modifier)
- ? modifier
- : domain_computer(modifier, fields);
- }
-
- return out;
- },
- to_aggregate: function () {
- if (this.type !== 'integer' && this.type !== 'float' && this.type !== 'monetary') {
- return {};
- }
-
- var aggregation_func = (this.sum && 'sum') || (this.avg && 'avg') ||
- (this.max && 'max') || (this.min && 'min');
-
- if (!aggregation_func) {
- return {};
- }
-
- var C = function (fn, label) {
- this['function'] = fn;
- this.label = label;
- };
- C.prototype = this;
- return new C(aggregation_func, this[aggregation_func]);
- },
- heading: function () {
- return _.escape(this.string);
- },
- width: function () {},
- /**
- *
- * @param row_data record whose values should be displayed in the cell
- * @param {Object} [options]
- * @param {String} [options.value_if_empty=''] what to display if the field's value is ``false``
- * @param {Boolean} [options.process_modifiers=true] should the modifiers be computed ?
- * @param {String} [options.model] current record's model
- * @param {Number} [options.id] current record's id
- * @return {String}
- */
- format: function (row_data, options) {
- options = options || {};
- var attrs = {};
- if (options.process_modifiers !== false) {
- attrs = this.modifiers_for(row_data);
- }
- if (attrs.invisible) { return ''; }
-
- if (!row_data[this.id]) {
- return options.value_if_empty === undefined
- ? ''
- : options.value_if_empty;
- }
- var f = this._format(row_data, options);
- return (f !== '')? f : '&nbsp;';
- },
- /**
- * Method to override in order to provide alternative HTML content for the
- * cell. Column._format will simply call ``instance.web.format_value`` and
- * escape the output.
- *
- * The output of ``_format`` will *not* be escaped by ``format``, any
- * escaping *must be done* by ``format``.
- *
- * @private
- */
- _format: function (row_data, options) {
- return _.escape(formats.format_value(
- row_data[this.id].value, this, options.value_if_empty));
- }
-});
-ListView.Column = Column;
-
-var MetaColumn = Column.extend({
- meta: true,
- init: function (id, string) {
- this._super(id, '', {string: string});
- }
-});
-// to do: do this in a better way (communicate with view_list_editable)
-ListView.MetaColumn = MetaColumn;
-
-var ColumnButton = Column.extend({
- /**
- * Return an actual ``<button>`` tag
- */
- format: function (row_data, options) {
- options = options || {};
- var attrs = {};
- if (options.process_modifiers !== false) {
- attrs = this.modifiers_for(row_data);
- }
- if (attrs.invisible) { return ''; }
- var template = this.icon && 'ListView.row.button' || 'ListView.row.text_button';
- return QWeb.render(template, {
- widget: this,
- prefix: session.prefix,
- disabled: attrs.readonly
- || isNaN(row_data.id.value)
- || data.BufferedDataSet.virtual_id_regex.test(row_data.id.value)
- });
- }
-});
-
-var ColumnBoolean = Column.extend({
- /**
- * Return a potentially disabled checkbox input
- *
- * @private
- */
- _format: function (row_data, options) {
- return _.str.sprintf('<div class="o_checkbox"><input type="checkbox" %s disabled="disabled"/><span/></div>',
- row_data[this.id].value ? 'checked="checked"' : '');
- }
-});
-
-var ColumnBinary = Column.extend({
- /**
- * Return a link to the binary data as a file
- *
- * @private
- */
- _format: function (row_data, options) {
- var text = _t("Download"), filename=_t('Binary file');
- var value = row_data[this.id].value;
- if (!value) {
- return options.value_if_empty || '';
- }
-
- var download_url;
- if (value.substr(0, 10).indexOf(' ') == -1) {
- download_url = "data:application/octet-stream;base64," + value;
- } else {
- download_url = session.url('/web/content', {model: options.model, field: this.id, id: options.id, download: true});
- if (this.filename) {
- download_url += '&filename_field=' + this.filename;
- }
- }
- if (this.filename && row_data[this.filename]) {
- text = _.str.sprintf(_t("Download \"%s\""), formats.format_value(
- row_data[this.filename].value, {type: 'char'}));
- filename = row_data[this.filename].value;
- }
- return _.template('<a download="<%-download%>" href="<%-href%>"><%-text%></a> (<%-size%>)')({
- text: text,
- href: download_url,
- size: utils.binary_to_binsize(value),
- download: filename,
- });
- }
-});
-
-var ColumnChar = Column.extend({
- replacement: '*',
- /**
- * If password field, only display replacement characters (if value is
- * non-empty)
- */
- _format: function (row_data, options) {
- var value = row_data[this.id].value;
- if (value && this.password === 'True') {
- return value.replace(/[\s\S]/g, _.escape(this.replacement));
- }
- return this._super(row_data, options);
- }
-});
-
-var ColumnProgressBar = Column.extend({
- /**
- * Return a formatted progress bar display
- *
- * @private
- */
- _format: function (row_data, options) {
- return _.template(
- '<progress value="<%-value%>" max="100"><%-value%>%</progress>')({
- value: _.str.sprintf("%.0f", row_data[this.id].value || 0)
- });
- }
-});
-
-var ColumnHandle = Column.extend({
- init: function () {
- this._super.apply(this, arguments);
- // Handle overrides the field to not be form-editable.
- this.modifiers.readonly = true;
- this.string = ""; // Don't display the column header
- },
- heading: function () {
- return '<span class="o_row_handle fa fa-arrows invisible"></span>';
- },
- width: function () { return 1; },
- /**
- * Return styling hooks for a drag handle
- *
- * @private
- */
- _format: function (row_data, options) {
- return '<span class="o_row_handle fa fa-arrows"/>';
- }
-});
-
-var ColumnMany2OneButton = Column.extend({
- _format: function (row_data, options) {
- this.has_value = !!row_data[this.id].value;
- this.icon = this.has_value ? 'fa-circle o_toggle_button_success' : 'fa-circle text-danger';
- this.string = this.has_value ? _t('View') : _t('Create');
- return QWeb.render('Many2OneButton.cell', {
- 'widget': this,
- 'prefix': session.prefix,
- });
- },
-});
-
-var ColumnMany2Many = Column.extend({
- _format: function (row_data, options) {
- if (!_.isEmpty(row_data[this.id].value)) {
- // If value, use __display version for printing
- row_data[this.id] = row_data[this.id + '__display'];
- }
- return this._super(row_data, options);
- }
-});
-
-var ColumnReference = Column.extend({
- _format: function (row_data, options) {
- if (!_.isEmpty(row_data[this.id].value)) {
- // If value, use __display version for printing
- if (!!row_data[this.id + '__display']) {
- row_data[this.id] = row_data[this.id + '__display'];
- } else {
- row_data[this.id] = {'value': ''};
- }
- }
- return this._super(row_data, options);
- }
-});
-
-var ColumnUrl = Column.extend({
- /**
- * Regex checking if a URL has a scheme
- */
- PROTOCOL_REGEX: /^(?!\w+:?\/\/)/,
-
- /**
- * Format a column as a URL if the column has content.
- * Add "//" (inherit current protocol) specified in
- * RFC 1808, 2396, and 3986 if no other protocol is included.
- *
- * @param row_data record whose values should be displayed in the cell
- * @param options
- */
- _format: function(row_data, options) {
- var value = row_data[this.id].value;
-
- if (value) {
- return _.template("<a href='<%-href%>' target='_blank'><%-text%></a>")({
- href: value.trim().replace(this.PROTOCOL_REGEX, '//'),
- text: value
- });
- }
- return this._super(row_data, options);
- }
-});
-
-var ColumnMonetary = Column.extend({
-
- _format: function (row_data, options) {
- var options = pyeval.py_eval(this.options || '{}');
- //name of currency field is defined either by field attribute, in view options or we assume it is named currency_id
- var currency_field = (_.isEmpty(options) === false && options.currency_field) || this.currency_field || 'currency_id';
- var currency_id = row_data[currency_field] && row_data[currency_field].value[0];
- var currency = session.get_currency(currency_id);
- var digits_precision = this.digits || (currency && currency.digits);
- var value = formats.format_value(row_data[this.id].value || 0, {type: this.type, digits: digits_precision}, options.value_if_empty);
- if (currency) {
- if (currency.position === "after") {
- value += '&nbsp;' + currency.symbol;
- } else {
- value = currency.symbol + '&nbsp;' + value;
- }
- }
- return value;
- },
-});
-
-var ColumnToggleButton = Column.extend({
- format: function (row_data, options) {
- this._super(row_data, options);
- var button_tips = JSON.parse(this.options);
- var fieldname = this.field_name;
- var has_value = row_data[fieldname] && !!row_data[fieldname].value;
- this.icon = has_value ? 'fa-circle o_toggle_button_success' : 'fa-circle text-muted';
- this.string = has_value ? (button_tips ? button_tips['active']: ''): (button_tips ? button_tips['inactive']: '');
- return QWeb.render('toggle_button', {
- widget: this,
- prefix: session.prefix,
- });
- },
-});
-
-/**
- * Registry for column objects used to format table cells (and some other tasks
- * e.g. aggregation computations).
- *
- * Maps a field or button to a Column type via its ``$tag.$widget``,
- * ``$tag.$type`` or its ``$tag`` (alone).
- *
- * This specific registry has a dedicated utility method ``for_`` taking a
- * field (from fields_get/fields_view_get.field) and a node (from a view) and
- * returning the right object *already instantiated from the data provided*.
- *
- * @type {instance.web.Registry}
- */
-list_widget_registry
- .add('field', Column)
- .add('field.boolean', ColumnBoolean)
- .add('field.binary', ColumnBinary)
- .add('field.char', ColumnChar)
- .add('field.progressbar', ColumnProgressBar)
- .add('field.handle', ColumnHandle)
- .add('button', ColumnButton)
- .add('field.many2onebutton', ColumnMany2OneButton)
- .add('field.reference', ColumnReference)
- .add('field.many2many', ColumnMany2Many)
- .add('field.url', ColumnUrl)
- .add('button.toggle_button', ColumnToggleButton)
- .add('field.monetary', ColumnMonetary);
-
-function for_ (id, field, node) {
- var description = _.extend({tag: node.tag}, field, node.attrs);
- var tag = description.tag;
- var Type = list_widget_registry.get_any([
- tag + '.' + description.widget,
- tag + '.'+ description.type,
- tag
- ]);
- return new Type(id, node.tag, description);
-}
-
-return ListView;
-});
diff --git a/addons/web/static/src/js/views/list_view_editable.js b/addons/web/static/src/js/views/list_view_editable.js
deleted file mode 100644
index 6beb6a7a044..00000000000
--- a/addons/web/static/src/js/views/list_view_editable.js
+++ /dev/null
@@ -1,866 +0,0 @@
-odoo.define('web.ListEditor', function (require) {
-"use strict";
-/*---------------------------------------------------------
- * Odoo Editable List view
- *---------------------------------------------------------*/
-/**
- * handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out
- * @namespace
- */
-
-var core = require('web.core');
-var data = require('web.data');
-var FormView = require('web.FormView');
-var common = require('web.list_common');
-var ListView = require('web.ListView');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-
-var Editor = Widget.extend({
- /**
- * @constructs instance.web.list.Editor
- * @extends instance.web.Widget
- *
- * Adapter between listview and formview for editable-listview purposes
- *
- * @param {instance.web.Widget} parent
- * @param {Object} options
- * @param {instance.web.FormView} [options.formView=instance.web.FormView]
- * @param {Object} [options.delegate]
- */
- init: function (parent, options) {
- this._super(parent);
- this.options = options || {};
- _.defaults(this.options, {
- formView: FormView,
- delegate: this.getParent(),
- });
- this.delegate = this.options.delegate;
- this.record = null;
- },
- start: function () {
- var self = this;
- var form_fields_view = this._validate_view(this.delegate.edition_view(this));
- this.form = new (this.options.formView)(this, this.delegate.dataset, form_fields_view, {
- initial_mode: 'edit',
- is_list_editable: true,
- disable_autofocus: true,
- $buttons: $(),
- $pager: $(),
- });
- return $.when(this._super(), this.form.appendTo($('<div/>')).then(function() {
- self.form.$el.addClass(self.$el.attr('class'));
- self.replaceElement(self.form.$el);
- }).done(this.proxy('do_hide')));
- },
- _validate_view: function (edition_view) {
- if (!edition_view) {
- throw new Error("editor delegate's #edition_view must return a view descriptor");
- }
- var arch = edition_view.arch;
- if (!(arch && arch.children instanceof Array)) {
- throw new Error("Editor delegate's #edition_view must have a non-empty arch");
- }
- if (arch.tag !== "form") {
- throw new Error("Editor delegate's #edition_view must have a 'form' root node");
- }
- if (!(arch.attrs && arch.attrs.version === "7.0")) {
- throw new Error("Editor delegate's #edition_view must be a version 7 view");
- }
- if (!/\bo_list_editable_form\b/.test(arch.attrs['class'])) {
- throw new Error("Editor delegate's #edition_view must have the class " +
- "'o_list_editable_form' on its root element");
- }
- return edition_view;
- },
- is_editing: function () {
- return !!this.record;
- },
- is_creating: function () {
- return (this.is_editing() && !this.record.id);
- },
- edit: function (record, configureField) {
- var self = this;
- // TODO: specify sequence of edit calls
- var loaded;
- if(record) {
- this.form.trigger('load_record', _.extend({}, record));
- loaded = this.form.record_loaded;
- } else {
- loaded = this.form.load_defaults().then(function() {
- return self.form.record_loaded;
- });
- }
-
- return loaded.then(function () {
- return self.do_show({reload: false});
- }).then(function () {
- self.record = self.form.datarecord;
- _(self.form.fields).each(function (field, name) {
- configureField(name, field);
- });
- return self.form;
- });
- },
- save: function () {
- var self = this;
- return this.form
- .save(this.delegate.prepends_on_create())
- .then(function (result) {
- if(result.created && !self.record.id) {
- self.record.id = result.result;
- }
- return self.record;
- });
- },
- cancel: function (force) {
- var self = this;
- if(force) {
- return do_cancel();
- }
- var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?");
- return this.form.can_be_discarded(message).then(do_cancel);
-
- function do_cancel() {
- var record = self.record;
- self.record = null;
- self.do_hide();
- return $.when(record);
- }
- },
- do_hide: function() {
- this.form.do_hide.apply(this.form, arguments);
- this.form.set({display_invalid_fields: false});
- },
- do_show: function() {
- this.form.do_show.apply(this.form, arguments);
- },
-});
-
-// editability status of list rows
-ListView.prototype.defaults.editable = null;
-
-// TODO: not sure second @lends on existing item is correct, to check
-ListView.include(/** @lends instance.web.ListView# */{
- init: function () {
- var self = this;
- this._super.apply(this, arguments);
-
- this.saving_mutex = new utils.Mutex();
-
- this._force_editability = null;
- this._context_editable = false;
- this.editor = new Editor(this);
- // Stores records of {field, cell}, allows for re-rendering fields
- // depending on cell state during and after resize events
- this.fields_for_resize = [];
- core.bus.on('resize', this, this.resize_fields);
-
- $(this.groups).bind({
- 'edit': function (e, id, dataset) {
- self.do_edit(dataset.index, id, dataset);
- },
- 'saved': function () {
- if (self.groups.get_selection().length) {
- return;
- }
- self.configure_pager(self.dataset);
- self.compute_aggregates();
- }
- });
-
- this.records.bind('remove', function () {
- if (self.editor.is_editing()) {
- self.cancel_edition();
- }
- });
-
- this.on('edit:before', this, function (event) {
- if (!self.editable() || self.editor.is_editing()) {
- event.cancel = true;
- }
- });
- this.on('edit:after', this, function () {
- self.$el.add(self.$buttons).addClass('o-editing');
- self.$('.ui-sortable').sortable('disable');
- });
- this.on('save:after cancel:after', this, function () {
- self.$('.ui-sortable').sortable('enable');
- self.$el.add(self.$buttons).removeClass('o-editing');
- });
- },
- destroy: function () {
- core.bus.off('resize', this, this.resize_fields);
- this._super();
- },
- do_hide: function () {
- if (this.editor.is_editing()) {
- this.cancel_edition(true);
- }
- this._super();
- },
- sort_by_column: function (e) {
- e.stopPropagation();
- if (!this.editor.is_editing()) {
- this._super.apply(this, arguments);
- }
- },
- /**
- * Handles the activation of a record in editable mode (making a record
- * editable), called *after* the record has become editable.
- *
- * The default behavior is to setup the listview's dataset to match
- * whatever dataset was provided by the editing List
- *
- * @param {Number} index index of the record in the dataset
- * @param {Object} id identifier of the record being edited
- * @param {instance.web.DataSet} dataset dataset in which the record is available
- */
- do_edit: function (index, id, dataset) {
- _.extend(this.dataset, dataset);
- },
- do_delete: function (ids) {
- var nonfalse = _.compact(ids);
- var _super = this._super.bind(this);
- var next = (this.editor.is_editing())? this.cancel_edition(true) : $.when();
- return next.then(function () {
- return _super(nonfalse);
- });
- },
- editable: function () {
- return !this.grouped
- && !this.options.disable_editable_mode
- && (this.fields_view.arch.attrs.editable || this._context_editable || this.options.editable);
- },
- /**
- * Replace do_search to handle editability process
- */
- do_search: function(domain, context, group_by) {
- var self = this;
- var _super = this._super;
- var args = arguments;
- var ready = (this.editor.is_editing())? this.cancel_edition(true) : $.when();
- return ready.then(function () {
- self._context_editable = !!context.set_editable;
- return _super.apply(self, args);
- });
- },
- /**
- * Replace do_add_record to handle editability (and adding new record
- * as an editable row at the top or bottom of the list)
- */
- do_add_record: function () {
- if (this.editable()) {
- this.$('table:first').show();
- this.$('.oe_view_nocontent').remove();
- this.start_edition();
- } else {
- this._super.apply(this, arguments);
- }
- },
- load_list: function (data, grouped) {
- // tree/@editable takes priority on everything else if present.
- var result = this._super.apply(this, arguments);
-
- // In case current editor was started previously, also has to run
- // when toggling from editable to non-editable in case form widgets
- // have setup global behaviors expecting themselves to exist somehow.
- this.editor.destroy();
- this.editor = new Editor(this); // Editor is not restartable due to formview not being restartable
-
- if(this.editable()) {
- this.$el.addClass('o_list_editable');
- return $.when(result, this.editor.prependTo(this.$el).done(this.proxy('setup_events')));
- } else {
- this.$el.removeClass('o_list_editable');
- }
- return result;
- },
- /**
- * Extend the render_buttons function of ListView by adding event listeners
- * in the case of an editable list.
- * @return {jQuery} the rendered buttons
- */
- render_buttons: function() {
- var add_button = !this.$buttons; // Ensures that this is only done once
- var result = this._super.apply(this, arguments); // Sets this.$buttons
-
- if (add_button && (this.editable() || this.grouped)) {
- var self = this;
- this.$buttons
- .off('click', '.o_list_button_save')
- .on('click', '.o_list_button_save', this.proxy('save_edition'))
- .off('click', '.o_list_button_discard')
- .on('click', '.o_list_button_discard', function (e) {
- e.preventDefault();
- self.cancel_edition();
- });
- }
- return result;
- },
- do_button_action: function (name, id, callback) {
- var self = this;
- this.save_edition().done(function (data) {
- if(!id && data.created) {
- id = data.record.get('id');
- }
- self.handle_button(name, id, callback);
- });
- },
- /**
- * Builds a record with the provided id (``false`` for a creation),
- * setting all columns with ``false`` value so code which relies on
- * having an actual value behaves correctly
- *
- * @param {*} id
- * @return {instance.web.list.Record}
- */
- make_empty_record: function (id) {
- var attrs = {id: id};
- _(this.columns).chain()
- .filter(function (x) { return x.tag === 'field'; })
- .pluck('name')
- .each(function (field) { attrs[field] = false; });
- return new common.Record(attrs);
- },
- /**
- * Set up the edition of a record of the list view "inline"
- *
- * @param {instance.web.list.Record} [record] record to edit, leave empty to create a new record
- * @param {Object} [options]
- * @param {String} [options.focus_field] field to focus at start of edition
- * @return {jQuery.Deferred}
- */
- start_edition: function (record, options) {
- var self = this;
- var item = false;
- if (record) {
- item = record.attributes;
- this.dataset.select_id(record.get('id'));
- } else {
- record = this.make_empty_record(false);
- this.records.add(record, {at: (this.prepends_on_create())? 0 : null});
- }
-
- return this.save_edition().then(function() {
- return $.when.apply($, self.editor.form.render_value_defs);
- }).then(function () {
- var $recordRow = self.groups.get_row_for(record);
- var cells = self.get_cells_for($recordRow);
- var fields = {};
- self.fields_for_resize.splice(0, self.fields_for_resize.length); // Empty array
- return self.with_event('edit', {
- record: record.attributes,
- cancel: false,
- }, function () {
- return self.editor.edit(item, function (field_name, field) {
- var cell = cells[field_name];
- if (!cell) {
- return;
- }
-
- // FIXME: need better way to get the field back from bubbling (delegated) DOM events somehow
- field.$el.attr('data-fieldname', field_name);
- fields[field_name] = field;
- self.fields_for_resize.push({field: field, cell: cell});
- }, options).then(function () {
- $recordRow.addClass('o_row_edition');
- self.resize_fields();
- // Local function that returns true if field is visible and editable
- var is_focusable = function(field) {
- return field && field.$el.is(':visible:not(.o_readonly)');
- };
- var focus_field = options && options.focus_field ? options.focus_field : undefined;
- if (!is_focusable(fields[focus_field])) {
- focus_field = _.find(self.editor.form.fields_order, function(field) {
- return is_focusable(fields[field]);
- });
- }
- if (fields[focus_field]) {
- fields[focus_field].$el.find('input, textarea').andSelf().filter('input, textarea').select();
- }
- return record.attributes;
- });
- }).fail(function () {
- // if the start_edition event is cancelled and it was a creation, remove the newly-created empty record
- if(!record.get('id')) {
- self.records.remove(record);
- }
- });
- }, function() {
- return $.Deferred().resolve(); // Here the save/cancel edition failed so the start_edition is considered as done and succeeded
- });
- },
- get_cells_for: function ($row) {
- var cells = {};
- $row.children('td').each(function (index, el) {
- cells[el.getAttribute('data-field')] = el;
- });
- return cells;
- },
- /**
- * If currently editing a row, resizes all registered form fields based
- * on the corresponding row cell
- */
- resize_fields: function () {
- if (!this.editor.is_editing()) {
- return;
- }
- for(var i = 0, len = this.fields_for_resize.length ; i < len ; i++) {
- var item = this.fields_for_resize[i];
- this.resize_field(item.field, item.cell);
- }
- },
- /**
- * Resizes a field's root element based on the corresponding cell of
- * a listview row
- *
- * @param {instance.web.form.AbstractField} field
- * @param {jQuery} cell
- */
- resize_field: function (field, cell) {
- var $cell = $(cell);
- field.set_dimensions($cell.outerHeight(), $cell.outerWidth());
- position_element(field.$el, 'left top');
- position_element(field.$translate, 'right top');
- if(field.get('effective_readonly')) {
- field.$el.addClass('o_readonly');
- }
- if(field.widget == "handle") {
- field.$el.addClass('o_row_handle');
- }
-
- // Workaround a bug in safari mobile where the inputs are not displayed correctly.
- if (window.getComputedStyle(field.el.parentElement).webkitOverflowScrolling) {
- field.el.style.zIndex = 1;
- }
-
- function position_element($el, pos) {
- $el.addClass('o_temp_visible').css({top: 0, left: 0}).position({
- my: pos,
- at: pos,
- of: $cell,
- }).removeClass('o_temp_visible');
- }
- },
- /**
- * @return {jQuery.Deferred}
- */
- save_edition: function (cancel_onfail) {
- var self = this;
- return self.saving_mutex.exec(function() {
- if (!self.editor.is_editing()) {
- return $.when();
- }
- return self.with_event('save', {
- editor: self.editor,
- form: self.editor.form,
- cancel: false,
- }, function () {
- return self.editor.save().then(function (attrs) {
- var created = false;
- var record = self.records.get(attrs.id);
- if (!record) {
- // new record
- created = true;
- record = self.records.find(function (r) {
- return !r.get('id');
- }).set('id', attrs.id);
- }
- // onwrite callback could be altering & reloading the
- // record which has *just* been saved, so first perform all
- // onwrites then do a final reload of the record
- return self.cancel_edition(true)
- .then(function() {
- return self.handle_onwrite(record);
- })
- .then(function () {
- return self.reload_record(record);
- })
- .then(function () {
- return {created: created, record: record};
- });
- }, function() {
- if (cancel_onfail) {
- return self.cancel_edition();
- }
- });
- });
- });
- },
- /**
- * @param {Boolean} [force] force the line to be discarded (even if there was changes)
- * @return {jQuery.Deferred}
- */
- cancel_edition: function (force) {
- var self = this;
- return this.with_event('cancel', {
- editor: this.editor,
- form: this.editor.form,
- cancel: false
- }, function () {
- return this.editor.cancel(force).then(function (attrs) {
- if (attrs.id) {
- var record = self.records.get(attrs.id);
- if (!record) {
- return; // Record removed by third party during edition
- }
- return self.reload_record(record, {do_not_evict: true});
- }
- var to_delete = self.records.find(function (r) {
- return !r.get('id');
- });
- if (to_delete) {
- self.records.remove(to_delete);
- }
- });
- });
- },
- /**
- * Executes an action on the view's editor bracketed by a cancellable
- * event of the name provided.
- *
- * The event name provided will be post-fixed with ``:before`` and
- * ``:after``, the ``event`` parameter will be passed alongside the
- * ``:before`` variant and if the parameter's ``cancel`` key is set to
- * ``true`` the action *will not be called* and the method will return
- * a rejection
- *
- * @param {String} event_name name of the event
- * @param {Object} event event object, provided to ``:before`` sub-event
- * @param {Function} action callable, called with the view's editor as its context
- * @param {Array} [args] supplementary arguments provided to the action
- * @param {Array} [trigger_params] supplementary arguments provided to the ``:after`` sub-event, before anything fetched by the ``action`` function
- * @return {jQuery.Deferred}
- */
- with_event: function (event_name, event, action) {
- var self = this;
- event = event || {};
- this.trigger(event_name + ':before', event);
- if (event.cancel) {
- return $.Deferred().reject({
- message: _.str.sprintf("Event %s:before cancelled",
- event_name)});
- }
- return $.when(action.call(this)).done(function () {
- self.trigger.apply(self, [event_name + ':after']
- .concat(_.toArray(arguments)));
- });
- },
- edition_view: function (editor) {
- var view = $.extend(true, {}, this.fields_view);
- view.arch.tag = 'form';
- _.extend(view.arch.attrs, {
- 'class': 'o_list_editable_form',
- version: '7.0'
- });
- _(view.arch.children).chain()
- .zip(_(this.columns).filter(function (c) {
- return !(c instanceof ListView.MetaColumn);}))
- .each(function (ar) {
- var widget = ar[0], column = ar[1];
- var modifiers = _.extend({}, column.modifiers);
- widget.attrs.nolabel = true;
- if (modifiers['tree_invisible'] || widget.tag === 'button') {
- modifiers.invisible = true;
- }
- widget.attrs.modifiers = JSON.stringify(modifiers);
- });
- return view;
- },
- handle_onwrite: function (source_record) {
- var self = this;
- var on_write_callback = self.fields_view.arch.attrs.on_write;
- if (!on_write_callback) {
- return $.when();
- }
- var context = new data.CompoundContext(self.dataset.get_context(), {'on_write_domain': self.dataset.domain}).eval();
- return this.dataset.call(on_write_callback, [source_record.get('id'), context])
- .then(function (ids) {
- return $.when.apply(null, _(ids).map(_.bind(self.handle_onwrite_record, self, source_record)));
- });
- },
- handle_onwrite_record: function (source_record, id) {
- var record = this.records.get(id);
- if (!record) {
- // insert after the source record
- var index = this.records.indexOf(source_record) + 1;
- record = this.make_empty_record(id);
- this.records.add(record, {at: index});
- }
- return this.reload_record(record);
- },
- prepends_on_create: function () {
- return (this.editable() === 'top');
- },
- setup_events: function () {
- var self = this;
- _.each(this.editor.form.fields, function(field, field_name) {
- field.on("change:effective_readonly", self, function(){
- var item = _(self.fields_for_resize).find(function (item) {
- return item.field === field;
- });
- if (item) {
- setTimeout(function() {
- self.resize_field(item.field, item.cell);
- }, 0);
- }
-
- });
- });
-
- this.editor.$el.on('keyup keypress keydown', function (e) {
- if (!self.editor.is_editing()) { return true; }
- var key = _($.ui.keyCode).chain()
- .map(function (v, k) { return {name: k, code: v}; })
- .find(function (o) { return o.code === e.which; })
- .value();
- if (!key) { return true; }
- var method = e.type + '_' + key.name;
- if (!(method in self)) { return true; }
- return self[method](e);
- });
- },
- /**
- * Saves the current record, and goes to the next one (creation or
- * edition)
- *
- * @private
- * @param {String} [next_record='succ'] method to call on the records collection to get the next record to edit
- * @param {Object} [options]
- * @param {String} [options.focus_field]
- * @return {*}
- */
- _next: function (next_record, options) {
- next_record = next_record || 'succ';
- var self = this;
- return this.save_edition().then(function (saveInfo) {
- if (!saveInfo) { return null; }
- if (saveInfo.created) {
- return self.start_edition();
- }
- if (!options) {
- options = {};
- }
- options.wraparound = !self.is_action_enabled('create');
- var record = self.records[next_record](saveInfo.record, options);
- if (record === undefined) {
- return self.start_edition();
- }
- return self.start_edition(record, options);
- });
- },
- keyup_ENTER: function () {
- return this._next();
- },
- keydown_ESCAPE: function (e) {
- return false;
- },
- keyup_ESCAPE: function (e) {
- return this.cancel_edition();
- },
- /**
- * Gets the selection range (start, end) for the provided element,
- * returns ``null`` if it can't get a range.
- *
- * @private
- */
- _text_selection_range: function (el) {
- var selectionStart;
- try {
- selectionStart = el.selectionStart;
- } catch (e) {
- // radio or checkbox throw on selectionStart access
- return null;
- }
- if (selectionStart !== undefined) {
- return {
- start: selectionStart,
- end: el.selectionEnd
- };
- } else if (document.body.createTextRange) {
- throw new Error("Implement text range handling for MSIE");
- }
- // Element without selection ranges (select, div/@contenteditable)
- return null;
- },
- _text_cursor: function (el) {
- var selection = this._text_selection_range(el);
- if (!selection) {
- return null;
- }
- if (selection.start !== selection.end) {
- return {position: null, collapsed: false};
- }
- return {position: selection.start, collapsed: true};
- },
- /**
- * Checks if the cursor is at the start of the provided el
- *
- * @param {HTMLInputElement | HTMLTextAreaElement}
- * @returns {Boolean}
- * @private
- */
- _at_start: function (cursor, el) {
- return cursor.collapsed && (cursor.position === 0);
- },
- /**
- * Checks if the cursor is at the end of the provided el
- *
- * @param {HTMLInputElement | HTMLTextAreaElement}
- * @returns {Boolean}
- * @private
- */
- _at_end: function (cursor, el) {
- return cursor.collapsed && (cursor.position === el.value.length);
- },
- /**
- * @param DOMEvent event
- * @param {String} record_direction direction to move into to get the next record (pred | succ)
- * @param {Function} is_valid_move whether the edition should be moved to the next record
- * @private
- */
- _key_move_record: function (event, record_direction, is_valid_move) {
- if (!this.editor.is_editing() || this.editor.is_creating()) { return $.when(); }
- var cursor = this._text_cursor(event.target);
- // if text-based input (has a cursor)
- // and selecting (not collapsed) or not at a field boundary
- // don't move to the next record
- if (cursor && !is_valid_move(event.target, cursor)) { return $.when(); }
-
- event.preventDefault();
- var source_field = $(event.target).closest('[data-fieldname]')
- .attr('data-fieldname');
- return this._next(record_direction, {focus_field: source_field});
-
- },
- keyup_UP: function (e) {
- var self = this;
- return this._key_move_record(e, 'pred', function (el, cursor) {
- return self._at_start(cursor, el) && !$(el).is('select,.ui-autocomplete-input');
- });
- },
- keyup_DOWN: function (e) {
- var self = this;
- return this._key_move_record(e, 'succ', function (el, cursor) {
- return self._at_end(cursor, el) && !$(el).is('select,.ui-autocomplete-input');
- });
- },
-
- keydown_LEFT: function (e) {
- // If the cursor is at the beginning of the field
- var source_field = $(e.target).closest('[data-fieldname]')
- .attr('data-fieldname');
- var cursor = this._text_cursor(e.target);
- if (cursor && !this._at_start(cursor, e.target)) { return $.when(); }
-
- var fields_order = this.editor.form.fields_order;
- var field_index = _(fields_order).indexOf(source_field);
-
- // Look for the closest visible form field to the left
- var fields = this.editor.form.fields;
- var field;
- do {
- if (--field_index < 0) { return $.when(); }
-
- field = fields[fields_order[field_index]];
- } while (!field.$el.is(':visible'));
-
- // and focus it
- field.focus();
- return $.when();
- },
- keydown_RIGHT: function (e) {
- // same as above, but with cursor at the end of the field and
- // looking for new fields at the right
- var source_field = $(e.target).closest('[data-fieldname]')
- .attr('data-fieldname');
- var cursor = this._text_cursor(e.target);
- if (cursor && !this._at_end(cursor, e.target)) { return $.when(); }
-
- var fields_order = this.editor.form.fields_order;
- var field_index = _(fields_order).indexOf(source_field);
-
- var fields = this.editor.form.fields;
- var field;
- do {
- if (++field_index >= fields_order.length) { return $.when(); }
-
- field = fields[fields_order[field_index]];
- } while (!field.$el.is(':visible'));
-
- field.focus();
- return $.when();
- },
- keydown_TAB: function (e) { // Keydown and not keyup because this handler must be called before the browser has focused the next field
- var form = this.editor.form;
- var last_field = _(form.fields_order).chain()
- .map(function (name) { return form.fields[name]; })
- .filter(function (field) { return field.$el.is(':visible') && !field.get('effective_readonly'); })
- .last()
- .value();
- // tabbed from last field in form
- if (last_field && $(e.target).closest(last_field.el).length) {
- e.preventDefault();
- return this._next();
- }
- this.editor.form.__clicked_inside = true;
- return $.when();
- },
-});
-
-
-ListView.Groups.include(/** @lends instance.web.ListView.Groups# */{
- passthrough_events: ListView.Groups.prototype.passthrough_events + " edit saved",
- get_row_for: function (record) {
- return _(this.children).chain()
- .invoke('get_row_for', record)
- .compact()
- .first()
- .value();
- }
-});
-
-ListView.List.include(/** @lends instance.web.ListView.List# */{
- row_clicked: function (event) {
- if (!this.view.editable() || !this.view.is_action_enabled('edit')) {
- return this._super.apply(this, arguments);
- }
- if (this.__is_starting_edition) {
- return;
- }
- this.__is_starting_edition = true;
-
- var self = this;
- var args = arguments;
- var _super = self._super;
-
- var record_id = $(event.currentTarget).data('id');
- return this.view.start_edition(
- ((record_id)? this.records.get(record_id) : null), {
- focus_field: $(event.target).not(".o_readonly").data('field'),
- }).fail(function() {
- return _super.apply(self, args); // The record can't be edited so open it in a modal (use-case: readonly mode)
- }).always(function () {
- self.__is_starting_edition = false;
- });
- },
- /**
- * If a row mapping to the record (@data-id matching the record's id or
- * no @data-id if the record has no id), returns it. Otherwise returns
- * ``null``.
- *
- * @param {Record} record the record to get a row for
- * @return {jQuery|null}
- */
- get_row_for: function (record) {
- var $row = this.$current.children('[data-id=' + record.get('id') + ']');
- return (($row.length)? $row : null);
- },
-});
-
-return Editor;
-
-});
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 00000000000..9b154214dfc
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -0,0 +1,368 @@
+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.
+ */
+
+var AbstractController = require('web.AbstractController');
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var framework = require('web.framework');
+var session = require('web.session');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var PivotController = AbstractController.extend({
+ template: 'PivotView',
+ events: {
+ 'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
+ 'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
+ 'click .o_pivot_field_menu a': '_onFieldMenuSelection',
+ 'click td': '_onCellClick',
+ 'click .o_pivot_measure_row': '_onMeasureRowClick',
+ },
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} params.groupableFields a map from field name to field
+ * props
+ * @param {boolean} params.enableLinking configure the pivot view to allow
+ * opening a list view by clicking on a cell with some data.
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+
+ this.measures = params.measures;
+ this.groupableFields = params.groupableFields;
+ this.title = params.title;
+ this.enableLinking = params.enableLinking;
+ this.lastHeaderSelected = null;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.toggleClass('o_enableLinking', this.enableLinking);
+ this.$fieldSelection = this.$('.o_field_selection');
+ core.bus.on('click', this, function () {
+ this.$fieldSelection.empty();
+ });
+ return this._super();
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.$buttons) {
+ // remove jquery's tooltip() handlers
+ this.$buttons.find('button').off();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * We save the current measure and group bys, so we can restore the view
+ * when we save the current state in the search view.
+ *
+ * @override method from AbstractController
+ * @returns {Object}
+ */
+ getContext: function () {
+ if (this.handle) {
+ var state = this.model.get(this.handle);
+ return {
+ pivot_measures: state.measures,
+ pivot_column_groupby: state.data.main_col.groupbys,
+ pivot_row_groupby: state.data.main_row.groupbys,
+ };
+ }
+ return {};
+ },
+ /**
+ * Render the buttons according to the PivotView.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 PivotView
+ * does nothing
+ */
+ renderButtons: function ($node) {
+ if ($node) {
+ var context = {measures: _.pairs(_.omit(this.measures, '__count'))};
+ this.$buttons = $(QWeb.render('PivotView.buttons', context));
+ this.$buttons.click(this._onButtonClick.bind(this));
+ this.$buttons.find('button').tooltip();
+
+ this.$buttons.appendTo($node);
+ this._updateButtons();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // 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_xls.
+ *
+ * @private
+ */
+ _downloadTable: function () {
+ var table = this.model.exportData();
+ if(table.measure_row.length + 1 > 256) {
+ crash_manager.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures."));
+ return;
+ }
+ framework.blockUI();
+ session.get_file({
+ url: '/web/pivot/export_xls',
+ data: {data: JSON.stringify(table)},
+ complete: framework.unblockUI,
+ error: crash_manager.rpc_error.bind(crash_manager)
+ });
+ },
+ /**
+ * Render the field selection menu, to select a groupable field. We disable
+ * already selected groupbys.
+ *
+ * @private
+ * @param {number} top top coordinate where we have to render the menu
+ * @param {number} left left coordinate for the menu
+ */
+ _renderFieldSelection: function (top, left) {
+ var state = this.model.get({raw: true});
+ var groupedFields = state.rowGroupBys
+ .concat(state.colGroupBys)
+ .map(function (f) { return f.split(':')[0];});
+
+ var fields = _.chain(this.groupableFields)
+ .pairs()
+ .sortBy(function (f) { return f[1].string; })
+ .map(function (f) {
+ return [f[0], f[1], _.contains(groupedFields, f[0])];
+ })
+ .value();
+
+ this.$fieldSelection.html(QWeb.render('PivotView.FieldSelection', {
+ fields: fields
+ }));
+
+ this.$fieldSelection.find('ul').first()
+ .css({top: top, left: left})
+ .show();
+ },
+ /**
+ * @private
+ */
+ _update: function (state) {
+ this._updateButtons();
+ var hasNoContent = !state.measures.length || !state.has_data;
+
+ if (hasNoContent) {
+ this.renderer.$el.detach();
+ this.$el.append(QWeb.render('PivotView.nodata'));
+ } else {
+ if (!document.contains(this.renderer.el)) {
+ this.$('div.oe_view_nocontent').remove();
+ this.$el.append(this.renderer.$el);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ */
+ _updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ var self = this;
+ var state = this.model.get({raw: true});
+ _.each(this.measures, function (measure, name) {
+ var isSelected = _.contains(state.measures, name);
+ self.$buttons.find('li[data-field="' + name + '"]')
+ .toggleClass('selected', isSelected);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // 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} event
+ */
+ _onButtonClick: function (event) {
+ var $target = $(event.target);
+ if ($target.hasClass('o_pivot_flip_button')) {
+ this.model.flip(this.handle);
+ this.update(null, {reload: false});
+ }
+ if ($target.hasClass('o_pivot_expand_button')) {
+ this.model
+ .expandAll(this.handle)
+ .then(this.update_state.bind(this));
+ }
+ if ($target.parents('.o_pivot_measures_list').length) {
+ var parent = $target.parent();
+ var field = parent.data('field');
+ event.preventDefault();
+ event.stopPropagation();
+ this.model
+ .toggleMeasure(field)
+ .then(this.update.bind(this, {}, {reload: false}));
+ }
+ if ($target.hasClass('o_pivot_download')) {
+ this._downloadTable();
+ }
+ },
+ /**
+ * When the user clicks on a cell, and the view is configured to allow
+ * 'linking' (with enableLinking), we want to open a list view with the
+ * corresponding record.
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onCellClick: function (event) {
+ var $target = $(event.target);
+ if ($target.hasClass('o_pivot_header_cell_closed') ||
+ $target.hasClass('o_pivot_header_cell_opened') ||
+ $target.hasClass('o_empty') ||
+ !this.enableLinking) {
+ return;
+ }
+ var row_id = $target.data('id');
+ var col_id = $target.data('col_id');
+ var state = this.model.get(this.handle);
+ var row_domain = state.data.headers[row_id].domain;
+ var col_domain = state.data.headers[col_id].domain;
+ var context = _.omit(_.clone(state.context), 'group_by');
+
+ function _find_view_info (views, view_type) {
+ return _.find(views, function (view) {
+ return view[1] === view_type;
+ }) || [false, view_type];
+ }
+ var views = [
+ _find_view_info(this.options.action.views, "list"),
+ _find_view_info(this.options.action.views, "form"),
+ ];
+
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: this.title,
+ res_model: this.model,
+ views: views,
+ view_type : "list",
+ view_mode : "list",
+ target: 'current',
+ context: context,
+ domain: state.domain.concat(row_domain, col_domain),
+ });
+ },
+ /**
+ * When we click on a closed header cell, we either want to open the
+ * dropdown menu to select a new groupby level, or we want to open the
+ * clicked header, if a corresponging groupby has already been selected.
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClosedHeaderClick: function (event) {
+ var $target = $(event.target);
+ var id = $target.data('id');
+ var header = this.model.getHeader(id);
+ var groupbys = header.root.groupbys;
+
+ if (header.path.length - 1 < groupbys.length) {
+ this.model
+ .expandHeader(header, groupbys[header.path.length - 1])
+ .then(this.update.bind(this, {}, {reload: false}));
+ } else {
+ this.lastHeaderSelected = id;
+ var position = $target.position();
+ var top = position.top + $target.height();
+ var left = position.left + event.offsetX;
+ this._renderFieldSelection(top, left);
+ event.stopPropagation();
+ }
+ },
+ /**
+ * This handler is called when the user selects a field in the dropdown menu
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onFieldMenuSelection: function (event) {
+ event.preventDefault();
+ var $target = $(event.target);
+ if ($target.parent().hasClass('disabled')) {
+ event.stopPropagation();
+ return;
+ }
+ var field = $target.parent().data('field');
+ var interval = $target.data('interval');
+ var header = this.model.getHeader(this.lastHeaderSelected);
+ if (interval) {
+ field = field + ':' + interval;
+ }
+ this.model
+ .expandHeader(header, field)
+ .then(this.update.bind(this, {}, {reload: false}));
+ },
+ /**
+ * If the user clicks on a measure row, we can perform an in-memory sort
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onMeasureRowClick: function (event) {
+ var $target = $(event.target);
+ var col_id = $target.data('id');
+ var measure = $target.data('measure');
+ var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc');
+ this.model
+ .sortRows(this.handle, col_id, measure, isAscending)
+ .then(this.update_state.bind(this));
+ },
+ /**
+ * This method is called when someone clicks on an open header. When that
+ * happens, we want to close the header, then redisplay the view.
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onOpenHeaderClick: function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ var headerID = $(event.target).data('id');
+ this.model.closeHeader(headerID)
+ 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 00000000000..cd203c22d35
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -0,0 +1,704 @@
+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
+ *
+ * @todo add a full description/specification of the data layout
+ */
+
+var core = require('web.core');
+var utils = require('web.utils');
+var AbstractModel = require('web.AbstractModel');
+
+var _t = core._t;
+
+var PivotModel = AbstractModel.extend({
+ /**
+ * @override
+ * @param {Object} params
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.numbering = {};
+ this.data = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close a header. This method is actually synchronous, but returns a
+ * deferred.
+ *
+ * @param {any} headerID
+ * @returns {Deferred}
+ */
+ closeHeader: function (headerID) {
+ var header = this.data.headers[headerID];
+ header.expanded = false;
+ header.children = [];
+ var newGroupbyLength = this._getHeaderDepth(header.root) - 1;
+ header.root.groupbys.splice(newGroupbyLength);
+ },
+ /**
+ * @param {string} id
+ * @returns {Deferred}
+ */
+ expandAll: function (id) {
+ var record = this.localData[id];
+ return this._loadData(record);
+ },
+ /**
+ * Expand (open up) a given header, be it a row or a column.
+ *
+ * @todo: add discussion on the number of read_group that it will generate,
+ * which is (r+1) or (c+1) I think
+ *
+ * @param {any} header
+ * @param {any} field
+ * @returns
+ */
+ expandHeader: function (header, field) {
+ var self = this;
+
+ var other_root = header.root.other_root;
+ var other_groupbys = header.root.other_root.groupbys;
+ var fields = [].concat(field, other_groupbys, this.data.measures);
+ var groupbys = [];
+
+ for (var i = 0; i <= other_groupbys.length; i++) {
+ groupbys.push([field].concat(other_groupbys.slice(0,i)));
+ }
+
+ return $.when.apply(null, groupbys.map(function (groupBy) {
+ return self.performModelRPC(self.modelName, 'read_group', [], {
+ context: self.data.context,
+ domain: header.domain.length ? header.domain : self.data.domain,
+ fields: _.map(fields, function (field) { return field.split(':')[0]; }),
+ groupby: groupBy,
+ lazy: false,
+ });
+ })).then(function () {
+ var data = Array.prototype.slice.call(arguments);
+ var datapt, attrs, j, l, row, col, cell_value, groupBys;
+ for (i = 0; i < data.length; i++) {
+ for (j = 0; j < data[i].length; j++){
+ datapt = data[i][j];
+ groupBys = [field].concat(other_groupbys.slice(0,i));
+ attrs = {
+ value: self._getValue(datapt, groupBys),
+ domain: datapt.__domain || [],
+ length: datapt.__count,
+ };
+
+ if (i === 0) {
+ row = self._makeHeader(attrs.value, attrs.domain, header.root, 0, 1, header);
+ } else {
+ row = self._getHeader(attrs.value, header.root, 0, 1, header);
+ }
+ col = self._getHeader(attrs.value, other_root, 1, i + 1);
+ if (!col) {
+ continue;
+ }
+ for (cell_value = {}, l=0; l < self.data.measures.length; l++) {
+ cell_value[self.data.measures[l]] = datapt[self.data.measures[l]];
+ }
+ // cell_value.__count = attrs.length;
+ if (!self.data.cells[row.id]) {
+ self.data.cells[row.id] = [];
+ }
+ self.data.cells[row.id][col.id] = cell_value;
+ }
+ }
+ if (!_.contains(header.root.groupbys, field)) {
+ header.root.groupbys.push(field);
+ }
+ });
+ },
+ /**
+ * @returns {Object}
+ */
+ exportData: function () {
+ var record = this.model.get(this.handle);
+ var measureNbr = record.measures.length;
+ var headers = this.renderer._computeHeaders();
+ var measureRow = measureNbr > 1 ? _.last(headers) : [];
+ var rows = this.renderer._computeRows();
+ var i, j, value;
+ headers[0].splice(0,1);
+
+ // process measureRow
+ for (i = 0; i < measureRow.length; i++) {
+ measureRow[i].measure = this.measures[measureRow[i].measure].string;
+ }
+ // process all rows
+ for (i =0, j, value; i < rows.length; i++) {
+ for (j = 0; j < rows[i].values.length; j++) {
+ value = rows[i].values[j];
+ rows[i].values[j] = {
+ is_bold: (i === 0) ||
+ ((record.data.main_col.width > 1) &&
+ (j >= rows[i].values.length - measureNbr)),
+ value: (value === undefined) ? "" : value,
+ };
+ }
+ }
+ return {
+ headers: _.initial(headers),
+ measure_row: measureRow,
+ rows: rows,
+ nbr_measures: measureNbr,
+ title: this.title,
+ };
+ },
+ /**
+ * Swap the columns and the rows. It is a synchronous operation.
+ */
+ flip: function () {
+ // swap the data: the main column and the main row
+ var temp = this.data.main_col;
+ this.data.main_col = this.data.main_row;
+ this.data.main_row = temp;
+
+ // we need to update the record metadata: row and col groupBys
+ temp = this.data.groupedBy;
+ this.data.groupedBy = this.data.colGroupBys;
+ this.data.colGroupBys = temp;
+ },
+ /**
+ * @override
+ * @param {Object} [options]
+ * @param {boolean} [options.raw=false]
+ * @returns {Object}
+ */
+ get: function (options) {
+ var isRaw = options && options.raw;
+ return {
+ colGroupBys: this.data.main_col.groupbys,
+ fields: this.fields,
+ has_data: this.data.has_data,
+ headers: !isRaw && this._computeHeaders(),
+ mainColWidth: this.data.main_col.width,
+ measures: this.data.measures,
+ rows: !isRaw && this._computeRows(),
+ rowGroupBys: this.data.main_row.groupbys,
+ sortedColumn: this.data.sorted_column,
+ };
+ },
+ getHeader: function (id) {
+ return this.data.headers[id];
+ },
+ /**
+ * @override
+ * @param {Object} params
+ * @param {string[]} [params.groupedBy]
+ * @param {string[]} [params.colGroupBys]
+ * @param {string[]} params.domain
+ * @param {string[]} params.rowGroupBys
+ * @param {string[]} params.colGroupBys
+ * @param {string[]} params.measures
+ * @param {Object} params.fields
+ * @returns {Deferred}
+ */
+ load: function (params) {
+ this.initialDomain = params.domain;
+ this.initialRowGroupBys = params.rowGroupBys;
+ this.initialColGroupBys = params.colGroupBys;
+ this.initialMeasures = params.measures;
+ this.fields = params.fields;
+ this.modelName = params.modelName;
+ var groupedBy = params.groupedBy.length ? params.groupedBy : this.initialRowGroupBys;
+ this.data = {
+ domain: params.domain,
+ context: params.context,
+ groupedBy: groupedBy,
+ colGroupBys: params.colGroupBys || this.initialColGroupBys,
+ measures: this.initialMeasures,
+ sorted_column: {},
+ };
+ return this._loadData(params);
+ },
+ /**
+ * @override
+ * @param {any} handle this parameter is ignored
+ * @param {Object} params
+ * @returns {Deferred}
+ */
+ reload: function (handle, params) {
+ var self = this;
+ var old_row_root = this.data.main_row.root;
+ var old_col_root = this.data.main_col.root;
+ if ('domain' in params) {
+ this.data.domain = params.domain;
+ } else {
+ this.data.domain = this.initialDomain;
+ }
+
+ return this._loadData(params).then(function () {
+ self._updateTree(old_row_root, self.data.main_row.root);
+ var new_groupby_length = self._getHeaderDepth(self.data.main_row.root) - 1;
+ self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length);
+
+ self._updateTree(old_col_root, self.data.main_col.root);
+ new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1;
+ self.data.main_row.groupbys = old_col_root.groupbys.slice(0, new_groupby_length);
+ });
+ },
+ /**
+ * Sort the rows, depending on the values of a given column. This is an
+ * in-memory sort.
+ *
+ * Note that it returns a deferred. @todo: makes sure this does not return a
+ * deferred.
+ *
+ * @param {any} id
+ * @param {any} col_id
+ * @param {any} measure
+ * @param {any} descending
+ * @returns {Deferred}
+ */
+ sortRows: function (id, col_id, measure, descending) {
+ var record = this.localData[id];
+
+ _traverseTree(record.data.main_row.root, function (header) {
+ header.children.sort(compare);
+ });
+ record.sorted_column = {
+ id: col_id,
+ measure: measure,
+ order: descending ? 'desc' : 'asc',
+ };
+ function _getValue (id1, id2) {
+ if ((id1 in record.data.cells) && (id2 in record.data.cells[id1])) {
+ return record.data.cells[id1][id2];
+ }
+ if (id2 in record.data.cells) return record.data.cells[id2][id1];
+ }
+
+ function compare (row1, row2) {
+ var values1 = _getValue(row1.id, col_id),
+ values2 = _getValue(row2.id, col_id),
+ value1 = values1 ? values1[measure] : 0,
+ value2 = values2 ? values2[measure] : 0;
+ return descending ? value1 - value2 : value2 - value1;
+ }
+ return $.when(id);
+ },
+ /**
+ * Toggle the active state for a given measure, then reload the data.
+ *
+ * @param {string} field
+ * @returns {Deferred}
+ */
+ toggleMeasure: function (field) {
+ if (_.contains(this.data.measures, field)) {
+ this.data.measures = _.without(this.data.measures, field);
+ // in this case, we already have all data in memory, no need to
+ // actually reload a lesser amount of information
+ return $.when();
+ } else {
+ this.data.measures.push(field);
+ }
+ return this._loadData();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _computeHeaders: function () {
+ var self = this;
+ var main_col_dims = this._getHeaderDim(this.data.main_col.root);
+ var depth = main_col_dims.depth;
+ var width = main_col_dims.width;
+ var nbr_measures = this.data.measures.length;
+ var result = [[{width:1, height: depth + 1}]];
+ var col_ids = [];
+ this.data.main_col.width = width;
+ this._traverseTree(this.data.main_col.root, function (header) {
+ var index = header.path.length - 1;
+ var cell = {
+ width: self._getHeaderWidth(header) * nbr_measures,
+ height: header.expanded ? 1 : depth - index,
+ title: header.path[header.path.length-1],
+ id: header.id,
+ expanded: header.expanded,
+ };
+ if (!header.expanded) col_ids.push(header.id);
+ if (result[index]) result[index].push(cell);
+ else result[index] = [cell];
+ });
+ col_ids.push(this.data.main_col.root.id);
+ this.data.main_col.width = width;
+ if (width > 1) {
+ var total_cell = {width:nbr_measures, height: depth, title:""};
+ if (nbr_measures === 1) {
+ total_cell.total = true;
+ }
+ result[0].push(total_cell);
+ }
+ var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures;
+ for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) {
+ measure = this.data.measures[i % nbr_measures];
+ measure_row.push({
+ measure: measure,
+ is_bold: (width > 1) && (i >= nbr_measures*width),
+ id: col_ids[Math.floor(i / nbr_measures)],
+ });
+ }
+ result.push(measure_row);
+ return result;
+ },
+ _computeRows: function () {
+ var self = this;
+ var aggregates, i;
+ var result = [];
+ this._traverseTree(this.data.main_row.root, function (header) {
+ var values = [],
+ col_ids = [];
+ result.push({
+ id: header.id,
+ col_ids: col_ids,
+ indent: header.path.length - 1,
+ title: header.path[header.path.length-1],
+ expanded: header.expanded,
+ values: values,
+ });
+ self._traverseTree(self.data.main_col.root, add_cells, header.id, values, col_ids);
+ if (self.data.main_col.width > 1) {
+ aggregates = self._getCellValue(header.id, self.data.main_col.root.id);
+ for (i = 0; i < self.data.measures.length; i++) {
+ values.push(aggregates && aggregates[self.data.measures[i]]);
+ }
+ col_ids.push( self.data.main_col.root.id);
+ }
+ });
+ return result;
+ function add_cells (col_hdr, row_id, values, col_ids) {
+ if (col_hdr.expanded) return;
+ col_ids.push(col_hdr.id);
+ aggregates = self._getCellValue(row_id, col_hdr.id);
+ for (i = 0; i < self.data.measures.length; i++) {
+ values.push(aggregates && aggregates[self.data.measures[i]]);
+ }
+ }
+ },
+ /**
+ * Static helper method
+ *
+ * @private @static
+ * @param {any} root
+ * @param {any} path
+ * @returns
+ */
+ _findPathInTree: function (root, path) {
+ var i,
+ l = root.path.length;
+ if (l === path.length) {
+ return (root.path[l-1] === path[l - 1]) ? root : null;
+ }
+ for (i = 0; i < root.children.length; i++) {
+ if (root.children[i].path[l] === path[l]) {
+ return this._findPathInTree(root.children[i], path);
+ }
+ }
+ return null;
+ },
+ _getCellValue: function (id1, id2) {
+ if ((id1 in this.data.cells) && (id2 in this.data.cells[id1])) {
+ return this.data.cells[id1][id2];
+ }
+ if (id2 in this.data.cells) return this.data.cells[id2][id1];
+ },
+ /**
+ * @param {any} value
+ * @param {any} root
+ * @param {any} i
+ * @param {any} j
+ * @param {any} parent
+ * @returns {Object}
+ */
+ _getHeader: function (value, root, i, j, parent) {
+ var path;
+ var total = _t("Total");
+ if (parent) {
+ path = parent.path.concat(value.slice(i,j));
+ } else {
+ path = [total].concat(value.slice(i,j));
+ }
+ return this._findPathInTree(root, path);
+ },
+ /**
+ * @private @static
+ * @param {any} header
+ * @returns {integer}
+ */
+ _getHeaderDepth: function (header) {
+ var depth = 1;
+ this._traverseTree(header, function (hdr) {
+ depth = Math.max(depth, hdr.path.length);
+ });
+ return depth;
+ },
+ _getHeaderDim: function (header) {
+ var depth = 1;
+ var width = 0;
+ this._traverseTree(header, function (hdr) {
+ depth = Math.max(depth, hdr.path.length);
+ if (!hdr.expanded) width++;
+ });
+ return {width: width, depth: depth};
+ },
+ _getHeaderWidth: function (header) {
+ var self = this;
+ if (!header.children.length) return 1;
+ if (!header.expanded) return 1;
+ return header.children.reduce(function (s, c) {
+ return s + self._getHeaderWidth(c);
+ }, 0);
+ },
+ /**
+ * @param {any} value
+ * @param {any} field
+ * @returns {string}
+ */
+ _getNumberedValue: function (value, field) {
+ var id= value[0];
+ var name= value[1];
+ this.numbering[field] = this.numbering[field] || {};
+ this.numbering[field][name] = this.numbering[field][name] || {};
+ var numbers = this.numbering[field][name];
+ numbers[id] = numbers[id] || _.size(numbers) + 1;
+ return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : "");
+ },
+ /**
+ * @param {any} datapt
+ * @param {any} fields
+ * @returns {string[]}
+ */
+ _getValue: function (datapt, fields) {
+ var result = [];
+ var value;
+ for (var i = 0; i < fields.length; i++) {
+ value = this._sanitizeValue(datapt[fields[i]],fields[i]);
+ result.push(value);
+ }
+ return result;
+ },
+ /**
+ * @returns {Deferred}
+ */
+ _loadData: function () {
+ var self = this;
+ var groupBys = [];
+ var rowGroupBys = this.data.groupedBy;
+ var colGroupBys = this.data.colGroupBys;
+ var fields = [].concat(rowGroupBys, colGroupBys, this.data.measures);
+
+ for (var i = 0; i < rowGroupBys.length + 1; i++) {
+ for (var j = 0; j < colGroupBys.length + 1; j++) {
+ groupBys.push(rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j)));
+ }
+ }
+
+ return $.when.apply(null, groupBys.map(function (groupBy) {
+ return self.performModelRPC(self.modelName, 'read_group', [], {
+ context: self.data.context,
+ domain: self.data.domain,
+ fields: _.map(fields, function (field) { return field.split(':')[0]; }),
+ groupby: groupBy,
+ lazy: false,
+ });
+ })).then(function () {
+ var data = Array.prototype.slice.call(arguments);
+ if (data[0][0].__count === 0) {
+ self.data.has_data = false;
+ return;
+ }
+ self._prepareData(data);
+ });
+ },
+ /**
+ * @param {any} value
+ * @param {any} domain
+ * @param {any} root
+ * @param {any} i
+ * @param {any} j
+ * @param {any} parent_header
+ * @returns {Object}
+ */
+ _makeHeader: function (value, domain, root, i, j, parent_header) {
+ var total = _t("Total");
+ var title = value.length ? value[value.length - 1] : total;
+ var path, parent;
+ if (parent_header) {
+ path = parent_header.path.concat(title);
+ parent = parent_header;
+ } else {
+ path = [total].concat(value.slice(i,j-1));
+ parent = value.length ? this._findPathInTree(root, path) : null;
+ }
+ var header = {
+ id: utils.generateID(),
+ expanded: false,
+ domain: domain || [],
+ children: [],
+ path: value.length ? parent.path.concat(title) : [title]
+ };
+ this.data.headers[header.id] = header;
+ header.root = root || header;
+ if (parent) {
+ parent.children.push(header);
+ parent.expanded = true;
+ }
+ return header;
+ },
+ /**
+ * @param {Object} data
+ */
+ _prepareData: function (data) {
+ var self = this;
+ _.extend(self.data, {
+ main_row: {},
+ main_col: {},
+ headers: {},
+ cells: [],
+ });
+
+ var index = 0;
+ var rowGroupBys = this.data.groupedBy;
+ var colGroupBys = this.data.colGroupBys;
+ var datapt, row, col, attrs, cell_value;
+ var main_row_header, main_col_header;
+ var groupBys;
+ var m;
+
+
+ for (var i = 0; i < rowGroupBys.length + 1; i++) {
+ for (var j = 0; j < colGroupBys.length + 1; j++) {
+ for (var k = 0; k < data[index].length; k++) {
+ datapt = data[index][k];
+ groupBys = rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j));
+ attrs = {
+ value: self._getValue(datapt, groupBys),
+ domain: datapt.__domain || [],
+ length: datapt.__count,
+ };
+
+ if (j === 0) {
+ row = this._makeHeader(attrs.value, attrs.domain, main_row_header, 0, i);
+ } else {
+ row = this._getHeader(attrs.value, main_row_header, 0, i);
+ }
+ if (i === 0) {
+ col = this._makeHeader(attrs.value, attrs.domain, main_col_header, i, i+j);
+ } else {
+ col = this._getHeader(attrs.value, main_col_header, i, i+j);
+ }
+ if (i + j === 0) {
+ this.data.has_data = attrs.length > 0;
+ main_row_header = row;
+ main_col_header = col;
+ }
+ if (!this.data.cells[row.id]) this.data.cells[row.id] = [];
+ for (cell_value = {}, m=0; m < this.data.measures.length; m++) {
+ cell_value[this.data.measures[m]] = datapt[this.data.measures[m]];
+ }
+ this.data.cells[row.id][col.id] = cell_value;
+ }
+ index++;
+ }
+ }
+
+ this.data.main_row.groupbys = rowGroupBys;
+ this.data.main_col.groupbys = colGroupBys;
+
+ main_row_header.other_root = main_col_header;
+ main_col_header.other_root = main_row_header;
+
+ main_row_header.groupbys = rowGroupBys;
+ main_col_header.groupbys = colGroupBys;
+
+ this.data.main_row.root = main_row_header;
+ this.data.main_col.root = main_col_header;
+ },
+ /**
+ * Format a value to a usable string, for the renderer to display.
+ *
+ * @param {any} value
+ * @param {any} field
+ * @returns {string}
+ */
+ _sanitizeValue: function (value, field) {
+ if (value === false) {
+ return _t("Undefined");
+ }
+ if (value instanceof Array) {
+ return this._getNumberedValue(value, field);
+ }
+ if (field && this.fields[field] && (this.fields[field].type === 'selection')) {
+ var selected = _.where(this.fields[field].selection, {0: value})[0];
+ return selected ? selected[1] : value;
+ }
+ return value;
+ },
+ /**
+ * @private @static
+ * @param {any} root
+ * @param {any} f
+ * @param {any} arg1
+ * @param {any} arg2
+ * @param {any} arg3
+ * @returns
+ */
+ _traverseTree: function (root, f, arg1, arg2, arg3) {
+ f(root, arg1, arg2, arg3);
+ if (!root.expanded) return;
+ for (var i = 0; i < root.children.length; i++) {
+ this._traverseTree(root.children[i], f, arg1, arg2, arg3);
+ }
+ },
+ /**
+ * @param {Object} old_tree
+ * @param {Object} new_tree
+ */
+ _updateTree: function (old_tree, new_tree) {
+ if (!old_tree.expanded) {
+ new_tree.expanded = false;
+ new_tree.children = [];
+ return;
+ }
+ var tree, j, old_title, new_title;
+ for (var i = 0; i < new_tree.children.length; i++) {
+ tree = undefined;
+ new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1];
+ for (j = 0; j < old_tree.children.length; j++) {
+ old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1];
+ if (old_title === new_title) {
+ tree = old_tree.children[j];
+ break;
+ }
+ }
+ if (tree) this._updateTree(tree, new_tree.children[i]);
+ else {
+ new_tree.children[i].expanded = false;
+ new_tree.children[i].children = [];
+ }
+ }
+ },
+});
+
+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 00000000000..0323dfd8bed
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js
@@ -0,0 +1,142 @@
+odoo.define('web.PivotRenderer', function (require) {
+"use strict";
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var field_utils = require('web.field_utils');
+
+var PivotRenderer = AbstractRenderer.extend({
+ tagName: 'table',
+ className: 'table-hover table-condensed table-bordered',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @returns {Deferred}
+ */
+ _render: function () {
+ var $fragment = $(document.createDocumentFragment());
+ var $table = $('<table>').appendTo($fragment);
+ var $thead = $('<thead>').appendTo($table);
+ var $tbody = $('<tbody>').appendTo($table);
+ var nbr_measures = this.state.measures.length;
+ var nbrCols = (this.state.mainColWidth === 1) ?
+ nbr_measures :
+ (this.state.mainColWidth + 1) * nbr_measures;
+ for (var i=0; i < nbrCols + 1; i++) {
+ $table.prepend($('<col>'));
+ }
+ this._renderHeaders($thead, this.state.headers);
+ this._renderRows($tbody, this.state.rows);
+ $table.on('hover', 'td', function () {
+ $table.find('col:eq(' + $(this).index()+')').toggleClass('hover');
+ });
+ // todo: make sure the next line does something
+ $table.find('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip();
+ this.$el.html($table.contents());
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @param {jQueryElement} $thead
+ * @param {jQueryElement} headers
+ */
+ _renderHeaders: function ($thead, headers) {
+ var self = this;
+ var i, j, cell, $row, $cell;
+
+ var groupbyLabels = _.map(this.state.colGroupBys, function (gb) {
+ return self.state.fields[gb.split(':')[0]].string;
+ });
+
+ for (i = 0; i < headers.length; i++) {
+ $row = $('<tr>');
+ for (j = 0; j < headers[i].length; j++) {
+ cell = headers[i][j];
+ $cell = $('<th>')
+ .text(cell.title)
+ .attr('rowspan', cell.height)
+ .attr('colspan', cell.width);
+ if (i > 0) {
+ $cell.attr('title', groupbyLabels[i-1]);
+ }
+ if (cell.expanded !== undefined) {
+ $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
+ $cell.data('id', cell.id);
+ }
+ if (cell.measure) {
+ $cell.addClass('o_pivot_measure_row text-muted')
+ .text(this.state.fields[cell.measure].string);
+ $cell.data('id', cell.id).data('measure', cell.measure);
+ if (cell.id === this.state.sortedColumn.id && cell.measure === this.state.sortedColumn.measure) {
+ $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order);
+ }
+ }
+ $row.append($cell);
+
+ $cell.toggleClass('hidden-xs', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.state.measures.length));
+ if (cell.height > 1) {
+ $cell.css('padding', 0);
+ }
+ }
+ $thead.append($row);
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryElement} $tbody
+ * @param {jQueryElement} rows
+ */
+ _renderRows: function ($tbody, rows) {
+ var self = this;
+ var i, j, value, measure, name, $row, $cell, $header;
+ var nbrMeasures = this.state.measures.length;
+ var length = rows[0].values.length;
+ var shouldDisplayTotal = this.state.mainColWidth > 1;
+
+ var groupbyLabels = _.map(this.state.rowGroupBys, function (gb) {
+ return self.state.fields[gb.split(':')[0]].string;
+ });
+ var measureTypes = this.state.measures.map(function (name) {
+ return self.state.fields[name].type;
+ });
+ for (i = 0; i < rows.length; i++) {
+ $row = $('<tr>');
+ $header = $('<td>')
+ .text(rows[i].title)
+ .data('id', rows[i].id)
+ .css('padding-left', (5 + rows[i].indent * 30) + 'px')
+ .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
+ if (rows[i].indent > 0) $header.attr('title', groupbyLabels[rows[i].indent - 1]);
+ $header.appendTo($row);
+ for (j = 0; j < length; j++) {
+ value = rows[i].values[j];
+ if (value !== undefined) {
+ name = this.state.measures[j % nbrMeasures];
+ measure = this.state.fields[name];
+ value = field_utils['format_' + measureTypes[j % nbrMeasures]](value, measure);
+ }
+ $cell = $('<td>')
+ .data('id', rows[i].id)
+ .data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)])
+ .toggleClass('o_empty', !value)
+ .text(value)
+ .addClass('o_pivot_cell_value text-right');
+ if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){
+ $cell.css('font-weight', 'bold');
+ }
+ $row.append($cell);
+
+ $cell.toggleClass('hidden-xs', j < length - nbrMeasures);
+ }
+ $tbody.append($row);
+ }
+ },
+
+});
+
+return 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 00000000000..777d9bcb5a7
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -0,0 +1,103 @@
+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.
+ */
+
+var AbstractView = require('web.AbstractView');
+var core = require('web.core');
+var PivotModel = require('web.PivotModel');
+var PivotController = require('web.PivotController');
+var PivotRenderer = require('web.PivotRenderer');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var GROUPABLE_TYPES =
+ ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+
+var PivotView = AbstractView.extend({
+ display_name: _lt('Pivot'),
+ icon: 'fa-table',
+ config: {
+ Model: PivotModel,
+ Controller: PivotController,
+ Renderer: PivotRenderer,
+ },
+ /**
+ * @override
+ * @param {Object} params
+ */
+ init: function (arch, fields, params) {
+ this._super.apply(this, arguments);
+
+ var activeMeasures = [];
+ var colGroupBys = [];
+ var rowGroupBys = [];
+
+ var measures = {};
+ var groupableFields = {};
+
+ _.each(fields, function (field, name) {
+ if ((name !== 'id') && (field.store === true)) {
+ if (_.contains(['integer', 'float', 'monetary'], field.type)) {
+ measures[name] = field;
+ }
+ if (_.contains(GROUPABLE_TYPES, field.type)) {
+ groupableFields[name] = field;
+ }
+ }
+ });
+ measures.__count = {string: _t("Count"), type: "integer"};
+
+ arch.children.forEach(function (field) {
+ var name = field.attrs.name;
+ if (field.attrs.interval) {
+ name += ':' + field.attrs.interval;
+ }
+
+ // 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.type === 'measure' && !(field.name in measures)) {
+ measures[field.name] = field;
+ }
+ if (field.attrs.type === 'measure' || 'operator' in field.attrs) {
+ activeMeasures.push(name);
+ }
+ if (field.attrs.type === 'col') {
+ colGroupBys.push(name);
+ }
+ if (field.attrs.type === 'row') {
+ rowGroupBys.push(name);
+ }
+ });
+ if ((!activeMeasures.length) || arch.attrs.display_quantity) {
+ activeMeasures.push('__count');
+ }
+ fields.__count = {string: _t("Count"), type: "integer"};
+
+ this.loadParams.measures = activeMeasures;
+ this.loadParams.colGroupBys = colGroupBys;
+ this.loadParams.rowGroupBys = rowGroupBys;
+ this.loadParams.fields = fields;
+
+ this.controllerParams.title = params.title || arch.attrs.string || _t("Untitled");
+ this.controllerParams.enableLinking = !arch.attrs.disable_linking;
+ this.controllerParams.measures = measures;
+ this.controllerParams.groupableFields = groupableFields;
+ },
+});
+
+return PivotView;
+
+});
+
+
diff --git a/addons/web/static/src/js/views/pivot_view.js b/addons/web/static/src/js/views/pivot_view.js
deleted file mode 100644
index 95e8d13e5ff..00000000000
--- a/addons/web/static/src/js/views/pivot_view.js
+++ /dev/null
@@ -1,892 +0,0 @@
-odoo.define('web.PivotView', function (require) {
-"use strict";
-/*---------------------------------------------------------
- * Odoo Pivot Table view
- *---------------------------------------------------------*/
-
-var core = require('web.core');
-var crash_manager = require('web.crash_manager');
-var data_manager = require('web.data_manager');
-var formats = require('web.formats');
-var framework = require('web.framework');
-var Model = require('web.DataModel');
-var session = require('web.session');
-var Sidebar = require('web.Sidebar');
-var utils = require('web.utils');
-var View = require('web.View');
-
-var _lt = core._lt;
-var _t = core._t;
-var QWeb = core.qweb;
-
-var PivotView = View.extend({
- events: {
- 'click .o_pivot_header_cell_opened': 'on_open_header_click',
- 'click .o_pivot_header_cell_closed': 'on_closed_header_click',
- 'click .o_pivot_field_menu a': 'on_field_menu_selection',
- 'click td': 'on_cell_click',
- 'click .o_pivot_measure_row': 'on_measure_row_click',
- },
- display_name: _lt('Pivot'),
- icon: 'fa-table',
- require_fields: true,
- template: 'PivotView',
-
- init: function() {
- this._super.apply(this, arguments);
- this._model = new Model(this.model, {group_by_no_leaf: true});
-
- this.measures = {};
- this.groupable_fields = {};
- this.ready = false; // will be ready after the first do_search
- this.data_loaded = $.Deferred();
- this.title = this.options.title || this.fields_view.arch.attrs.string;
-
- this.main_row = {
- root: undefined,
- groupbys: [],
- };
- this.main_col = {
- root: undefined,
- groupbys: [],
- };
- this.initial_col_groupby = [];
- this.initial_row_groupby = [];
-
- this.active_measures = [];
- this.headers = {};
- this.cells = {};
- this.has_data = false;
- this.$buttons = $();
-
- this.last_header_selected = null;
- this.sorted_column = {};
-
- this.numbering = {};
- this.widgets = [];
-
- this.enable_linking = !this.fields_view.arch.attrs.disable_linking;
- },
- willStart: function () {
- var self = this;
-
- var fields_def = data_manager.load_fields(this.dataset);
- var xlwt_def = session.rpc('/web/pivot/check_xlwt').then(function(result) {
- self.xlwt_installed = result;
- });
-
- this.fields_view.arch.children.forEach(function (field) {
- var name = field.attrs.name;
- if (field.attrs.interval) {
- name += ':' + field.attrs.interval;
- }
- //noinspection FallThroughInSwitchStatementJS
- switch (field.attrs.type) {
- case 'measure':
- self.widgets.push(field.attrs.widget || "");
- self.active_measures.push(name);
- break;
- case 'col':
- self.initial_col_groupby.push(name);
- break;
- default:
- if ('operator' in field.attrs) {
- self.active_measures.push(name);
- break;
- }
- case 'row':
- self.initial_row_groupby.push(name);
- }
- });
- if ((!this.active_measures.length) || this.fields_view.arch.attrs.display_quantity) {
- this.active_measures.push('__count__');
- }
-
- return $.when(fields_def, xlwt_def, this._super()).then(function (fields) {
- self.prepare_fields(fields);
- // 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).
- _.each(self.active_measures, function (m) {
- if (!(m in self.measures)) {
- self.measures[m] = self.fields[m];
- }
- });
- });
- },
- start: function () {
- this.$el.toggleClass('o_enable_linking', this.enable_linking);
- this.$field_selection = this.$('.o_field_selection');
- core.bus.on('click', this, function () {
- this.$field_selection.empty();
- });
- return this._super();
- },
- render_field_selection: function (top, left) {
- var grouped_fields = this.main_row.groupbys
- .concat(this.main_col.groupbys)
- .map(function(f) { return f.split(':')[0];});
-
- var fields = _.chain(this.groupable_fields)
- .pairs()
- .sortBy(function(f) { return f[1].string; })
- .map(function (f) { return [f[0], f[1], _.contains(grouped_fields, f[0])]; })
- .value();
-
- this.$field_selection.html(QWeb.render('PivotView.FieldSelection', {
- fields: fields
- }));
-
- this.$field_selection.find('ul').first()
- .css({top: top, left: left})
- .show();
- },
- /**
- * Render the buttons according to the PivotView.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 PivotView does nothing
- */
- render_buttons: function ($node) {
- if ($node) {
- var self = this;
-
- var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
- this.$buttons = $(QWeb.render('PivotView.buttons', context));
- this.$buttons.click(this.on_button_click.bind(this));
- this.active_measures.forEach(function (measure) {
- self.$buttons.find('li[data-field="' + measure + '"]').addClass('selected');
- });
- this.$buttons.find('button').tooltip();
-
- this.$buttons.appendTo($node);
- }
- },
- /**
- * Instantiate and render the sidebar.
- * Sets this.sidebar
- * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted
- * $node may be undefined, in which case the PivotView does nothing
- **/
- render_sidebar: function($node) {
- if (this.xlwt_installed && $node && this.options.sidebar) {
- this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')});
- this.sidebar.appendTo($node);
- }
- },
- prepare_fields: function (fields) {
- var self = this,
- groupable_types = ['many2one', 'char', 'boolean',
- 'selection', 'date', 'datetime'];
- this.fields = fields;
- _.each(fields, function (field, name) {
- if ((name !== 'id') && (field.store === true)) {
- if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') {
- self.measures[name] = field;
- }
- if (_.contains(groupable_types, field.type)) {
- self.groupable_fields[name] = field;
- }
- }
- });
- this.measures.__count__ = {string: _t("Count"), type: "integer"};
- },
- do_search: function (domain, context, group_by) {
- if (!this.ready) {
- this.initial_row_groupby = context.pivot_row_groupby || this.initial_row_groupby;
- this.initial_col_groupby = context.pivot_col_groupby || this.initial_col_groupby;
- }
- this.main_row.groupbys = group_by.length ? group_by : (context.pivot_row_groupby || this.initial_row_groupby.slice(0));
- this.main_col.groupbys = context.pivot_column_groupby || this.initial_col_groupby.slice(0);
- this.active_measures = context.pivot_measures || this.active_measures;
-
- this.domain = domain;
- this.context = context;
- if (!this.ready) {
- this.data_loaded = this.load_data(true);
- this.ready = true;
- return;
- }
- this.data_loaded = this.load_data(false);
- return this.do_show();
- },
- do_show: function () {
- var self = this;
- var _super = this._super.bind(this);
- this.do_push_state({});
- return this.data_loaded.done(function () {
- self.display_table();
- self.$buttons.find('.o_pivot_measures_list li').removeClass('selected');
- self.active_measures.forEach(function (measure) {
- self.$buttons.find('li[data-field="' + measure + '"]').addClass('selected');
- });
- _super();
- });
- },
- get_context: function () {
- return !this.ready ? {} : {
- pivot_measures: this.active_measures,
- pivot_column_groupby: this.main_col.groupbys,
- pivot_row_groupby: this.main_row.groupbys,
- };
- },
- on_button_click: function (event) {
- var $target = $(event.target);
- if ($target.hasClass('o_pivot_flip_button')) { return this.flip(); }
- if ($target.hasClass('o_pivot_expand_button')) { return this.expand_all(); }
- if ($target.parents('.o_pivot_measures_list').length) {
- var parent = $target.parent();
- var field = parent.data('field');
- parent.toggleClass('selected');
- event.preventDefault();
- event.stopPropagation();
- return this.toggle_measure(field);
- }
- if ($target.hasClass('o_pivot_download')) {
- return this.download_table();
- }
- },
- on_open_header_click: function (event) {
- var id = $(event.target).data('id'),
- header = this.headers[id];
- header.expanded = false;
- header.children = [];
- var new_groupby_length = this.get_header_depth(header.root) - 1;
- header.root.groupbys.splice(new_groupby_length);
- this.display_table();
- },
- on_closed_header_click: function (event) {
- var $target = $(event.target);
- var id = $target.data('id');
- var header = this.headers[id];
- var groupbys = header.root.groupbys;
-
- if (header.path.length - 1 < groupbys.length) {
- this.expand_header(header, groupbys[header.path.length - 1])
- .then(this.proxy('display_table'));
- } else {
- this.last_header_selected = id;
- var position = $target.position();
- var top = position.top + $target.height();
- var left = position.left + event.offsetX;
- this.render_field_selection(top, left);
- event.stopPropagation();
- }
- },
- on_cell_click: function (event) {
- var $target = $(event.target);
- if ($target.hasClass('o_pivot_header_cell_closed')
- || $target.hasClass('o_pivot_header_cell_opened')
- || $target.hasClass('o_empty')
- || !this.enable_linking) {
- return;
- }
- var row_id = $target.data('id'),
- col_id = $target.data('col_id'),
- row_domain = this.headers[row_id].domain,
- col_domain = this.headers[col_id].domain,
- context = _.omit(_.clone(this.context), 'group_by');
-
- var views = [
- _find_view_info.call(this, "list"),
- _find_view_info.call(this, "form"),
- ];
-
- return this.do_action({
- type: 'ir.actions.act_window',
- name: this.title,
- res_model: this.model,
- views: views,
- view_type : "list",
- view_mode : "list",
- target: 'current',
- context: context,
- domain: this.domain.concat(row_domain, col_domain),
- });
-
- function _find_view_info(view_type) {
- return _.find(this.options.action.views, function (view) {
- return view[1] === view_type;
- }) || [false, view_type];
- }
- },
- on_measure_row_click: function (event) {
- var $target = $(event.target),
- col_id = $target.data('id'),
- measure = $target.data('measure');
-
- this.sort_rows(col_id, measure, $target.hasClass('o_pivot_measure_row_sorted_asc'));
- this.display_table();
- },
- sort_rows: function (col_id, measure, descending) {
- var self = this;
- traverse_tree(this.main_row.root, function (header) {
- header.children.sort(compare);
- });
- this.sorted_column = {
- id: col_id,
- measure: measure,
- order: descending ? 'desc' : 'asc',
- };
-
- function compare (row1, row2) {
- var values1 = self.get_value(row1.id, col_id),
- values2 = self.get_value(row2.id, col_id),
- value1 = values1 ? values1[measure] : 0,
- value2 = values2 ? values2[measure] : 0;
- return descending ? value1 - value2 : value2 - value1;
- }
- },
- on_field_menu_selection: function (event) {
- event.preventDefault();
- var $target = $(event.target);
- if ($target.parent().hasClass('disabled')) {
- event.stopPropagation();
- return;
- }
- var field = $target.parent().data('field'),
- interval = $target.data('interval'),
- header = this.headers[this.last_header_selected];
- if (interval) field = field + ':' + interval;
- this.expand_header(header, field)
- .then(function () {
- header.root.groupbys.push(field);
- })
- .then(this.proxy('display_table'));
- },
- expand_header: function (header, field) {
- var self = this;
-
- var other_root = header.root.other_root,
- other_groupbys = header.root.other_root.groupbys,
- fields = [].concat(field, other_groupbys, this.active_measures),
- groupbys = [];
-
- for (var i = 0; i <= other_groupbys.length; i++) {
- groupbys.push([field].concat(other_groupbys.slice(0,i)));
- }
- return $.when.apply(null, groupbys.map(function (groupby) {
- return self._model.query(fields)
- .filter(header.domain.length ? header.domain : self.domain)
- .context(self.context)
- .lazy(false)
- .group_by(groupby);
- })).then(function () {
- var data = Array.prototype.slice.call(arguments),
- datapt, attrs, j, k, l, row, col, cell_value, field_name;
- for (i = 0; i < data.length; i++) {
- for (j = 0; j < data[i].length; j++){
- datapt = data[i][j];
- attrs = datapt.attributes;
- if (i === 0) attrs.value = [attrs.value];
- for (k = 0; k < attrs.value.length; k++) {
- if (k < 1) field_name = field;
- else field_name = other_groupbys[k - 1];
- attrs.value[k] = self.sanitize_value(attrs.value[k], field_name);
- }
- if (i === 0) {
- row = self.make_header(datapt, header.root, 0, 1, header);
- } else {
- row = self.get_header(datapt, header.root, 0, 1, header);
- }
- col = self.get_header(datapt, other_root, 1, i + 1);
- if (!col) continue;
- for (cell_value = {}, l=0; l < self.active_measures.length; l++) {
- cell_value[self.active_measures[l]] = attrs.aggregates[self.active_measures[l]];
- }
- cell_value.__count__ = attrs.length;
- if (!self.cells[row.id]) self.cells[row.id] = [];
- self.cells[row.id][col.id] = cell_value;
- }
- }
- });
- },
- expand_all: function () {
- this.load_data(false).then(this.proxy('display_table'));
- },
- // returns a deferred that resolve when the data is loaded.
- load_data: function (update) {
- var should_update = (update && this.main_row.root && this.main_col.root);
- var self = this,
- i, j,
- groupbys = [],
- row_gbs = this.main_row.groupbys,
- col_gbs = this.main_col.groupbys,
- fields = [].concat(row_gbs, col_gbs, this.active_measures);
- for (i = 0; i < row_gbs.length + 1; i++) {
- for (j = 0; j < col_gbs.length + 1; j++) {
- groupbys.push(row_gbs.slice(0,i).concat(col_gbs.slice(0,j)));
- }
- }
- return $.when.apply(null, groupbys.map(function (groupby) {
- return self._model.query(fields)
- .filter(self.domain)
- .context(self.context)
- .lazy(false)
- // .order_by(['probable_revenue'])
- .group_by(groupby);
- })).then(function () {
- var data = Array.prototype.slice.call(arguments);
- self.prepare_data(data, should_update);
- });
- },
- prepare_data: function (data, should_update) {
- var i, j, k, l, m,
- index = 0,
- row_gbs = this.main_row.groupbys,
- col_gbs = this.main_col.groupbys,
- main_row_header, main_col_header,
- row, col, attrs, datapt, cell_value,
- field;
-
- for (i = 0; i < row_gbs.length + 1; i++) {
- for (j = 0; j < col_gbs.length + 1; j++) {
- for (k = 0; k < data[index].length; k++) {
- datapt = data[index][k];
- attrs = datapt.attributes;
- if (i + j === 1) {
- attrs.value = [attrs.value];
- }
- for (l = 0; l < attrs.value.length; l++) {
- if (l < i) field = row_gbs[l];
- else field = col_gbs[l - i];
- attrs.value[l] = this.sanitize_value(attrs.value[l], field);
- }
- if (j === 0) {
- row = this.make_header(datapt, main_row_header, 0, i);
- } else {
- row = this.get_header(datapt, main_row_header, 0, i);
- }
- if (i === 0) {
- col = this.make_header(datapt, main_col_header, i, i+j);
- } else {
- col = this.get_header(datapt, main_col_header, i, i+j);
- }
- if (i + j === 0) {
- this.has_data = attrs.length > 0;
- main_row_header = row;
- main_col_header = col;
- }
- if (!this.cells[row.id]) this.cells[row.id] = [];
- for (cell_value = {}, m=0; m < this.active_measures.length; m++) {
- cell_value[this.active_measures[m]] = attrs.aggregates[this.active_measures[m]];
- }
- cell_value.__count__ = attrs.length;
- this.cells[row.id][col.id] = cell_value;
- }
- index++;
- }
- }
- if (should_update) {
- this.update_tree(this.main_row.root, main_row_header);
- var new_groupby_length = this.get_header_depth(main_row_header) - 1;
- main_row_header.groupbys = this.main_row.root.groupbys.slice(0, new_groupby_length);
- this.update_tree(this.main_col.root, main_col_header);
- new_groupby_length = this.get_header_depth(main_col_header) - 1;
- main_col_header.groupbys = this.main_col.root.groupbys.slice(0, new_groupby_length);
- } else {
- main_row_header.groupbys = this.main_row.groupbys;
- main_col_header.groupbys = this.main_col.groupbys;
- }
- main_row_header.other_root = main_col_header;
- main_col_header.other_root = main_row_header;
- this.main_row.root = main_row_header;
- this.main_col.root = main_col_header;
- },
- update_tree: function (old_tree, new_tree) {
- if (!old_tree.expanded) {
- new_tree.expanded = false;
- new_tree.children = [];
- return;
- }
- var tree, j, old_title, new_title;
- for (var i = 0; i < new_tree.children.length; i++) {
- tree = undefined;
- new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1];
- for (j = 0; j < old_tree.children.length; j++) {
- old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1];
- if (old_title === new_title) {
- tree = old_tree.children[j];
- break;
- }
- }
- if (tree) this.update_tree(tree, new_tree.children[i]);
- else {
- new_tree.children[i].expanded = false;
- new_tree.children[i].children = [];
- }
- }
- },
- sanitize_value: function (value, field) {
- if (value === false) return _t("Undefined");
- if (value instanceof Array) return this.get_numbered_value(value, field);
- if (field && this.fields[field] && (this.fields[field].type === 'selection')) {
- var selected = _.where(this.fields[field].selection, {0: value})[0];
- return selected ? selected[1] : value;
- }
- return value;
- },
- get_numbered_value: function(value, field) {
- var id= value[0];
- var name= value[1]
- this.numbering[field] = this.numbering[field] || {};
- this.numbering[field][name] = this.numbering[field][name] || {};
- var numbers = this.numbering[field][name];
- numbers[id] = numbers[id] || _.size(numbers) + 1;
- return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : "");
- },
- make_header: function (data_pt, root, i, j, parent_header) {
- var total = _t("Total");
- var attrs = data_pt.attributes,
- value = attrs.value,
- title = value.length ? value[value.length - 1] : total;
- var path, parent;
- if (parent_header) {
- path = parent_header.path.concat(title);
- parent = parent_header;
- } else {
- path = [total].concat(value.slice(i,j-1));
- parent = value.length ? find_path_in_tree(root, path) : null;
- }
- var header = {
- id: utils.generate_id(),
- expanded: false,
- domain: data_pt.model._domain,
- children: [],
- path: value.length ? parent.path.concat(title) : [title]
- };
- this.headers[header.id] = header;
- header.root = root || header;
- if (parent) {
- parent.children.push(header);
- parent.expanded = true;
- }
- return header;
- },
- get_header: function (data_pt, root, i, j, parent) {
- var path;
- var total = _t("Total");
- if (parent) {
- path = parent.path.concat(data_pt.attributes.value.slice(i,j));
- } else {
- path = [total].concat(data_pt.attributes.value.slice(i,j));
- }
- return find_path_in_tree(root, path);
- },
- _clean_table: function () {
- return this.$el
- .find('table')
- .remove()
- .end()
- .find('.oe_view_nocontent')
- .remove()
- .end();
- },
- display_table: function () {
- if (!this.active_measures.length || !this.has_data) {
- return this._clean_table().append(QWeb.render('PivotView.nodata'));
- }
- var $fragment = $(document.createDocumentFragment()),
- $table = $('<table>')
- .addClass('table table-hover table-condensed table-bordered')
- .appendTo($fragment),
- $thead = $('<thead>').appendTo($table),
- $tbody = $('<tbody>').appendTo($table),
- headers = this.compute_headers(),
- rows = this.compute_rows(),
- nbr_measures = this.active_measures.length,
- nbr_cols = (this.main_col.width === 1) ? nbr_measures : (this.main_col.width + 1)*nbr_measures;
- for (var i=0; i < nbr_cols + 1; i++) {
- $table.prepend($('<col>'));
- }
- this.draw_headers($thead, headers);
- this.draw_rows($tbody, rows);
- $table.on('hover', 'td', function () {
- $table.find('col:eq(' + $(this).index()+')').toggleClass('hover');
- });
- this._clean_table().append($fragment);
- this.$('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip();
- },
- draw_headers: function ($thead, headers) {
- var self = this,
- i, j, cell, $row, $cell;
-
- var groupby_labels = _.map(this.main_col.groupbys, function (gb) {
- return self.fields[gb.split(':')[0]].string;
- });
-
- for (i = 0; i < headers.length; i++) {
- $row = $('<tr>');
- for (j = 0; j < headers[i].length; j++) {
- cell = headers[i][j];
- $cell = $('<th>')
- .text(cell.title)
- .attr('rowspan', cell.height)
- .attr('colspan', cell.width);
- if (i > 0) {
- $cell.attr('title', groupby_labels[i-1]);
- }
- if (cell.expanded !== undefined) {
- $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
- $cell.data('id', cell.id);
- }
- if (cell.measure) {
- $cell.addClass('o_pivot_measure_row text-muted')
- .text(this.measures[cell.measure].string);
- $cell.data('id', cell.id).data('measure', cell.measure);
- if (cell.id === this.sorted_column.id && cell.measure === this.sorted_column.measure) {
- $cell.addClass('o_pivot_measure_row_sorted_' + this.sorted_column.order);
- }
- }
- $row.append($cell);
-
- $cell.toggleClass('hidden-xs', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.active_measures.length));
- if (cell.height > 1) {
- $cell.css('padding', 0);
- }
- }
- $thead.append($row);
- }
- },
- draw_rows: function ($tbody, rows) {
- var self = this,
- i, j, value, $row, $cell, $header,
- nbr_measures = this.active_measures.length,
- length = rows[0].values.length,
- display_total = this.main_col.width > 1;
-
- var groupby_labels = _.map(this.main_row.groupbys, function (gb) {
- return self.fields[gb.split(':')[0]].string;
- });
- var measure_types = this.active_measures.map(function (name) {
- return self.measures[name].type;
- });
- var widgets = this.widgets;
- for (i = 0; i < rows.length; i++) {
- $row = $('<tr>');
- $header = $('<td>')
- .text(rows[i].title)
- .data('id', rows[i].id)
- .css('padding-left', (5 + rows[i].indent * 30) + 'px')
- .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
- if (rows[i].indent > 0) $header.attr('title', groupby_labels[rows[i].indent - 1]);
- $header.appendTo($row);
- for (j = 0; j < length; j++) {
- value = formats.format_value(rows[i].values[j], {type: measure_types[j % nbr_measures], widget: widgets[j % nbr_measures]});
- $cell = $('<td>')
- .data('id', rows[i].id)
- .data('col_id', rows[i].col_ids[Math.floor(j / nbr_measures)])
- .toggleClass('o_empty', !value)
- .text(value)
- .addClass('o_pivot_cell_value text-right');
- if (((j >= length - this.active_measures.length) && display_total) || i === 0){
- $cell.css('font-weight', 'bold');
- }
- $row.append($cell);
-
- $cell.toggleClass('hidden-xs', j < length - this.active_measures.length);
- }
- $tbody.append($row);
- }
- },
- compute_headers: function () {
- var self = this,
- main_col_dims = this.get_header_width_depth(this.main_col.root),
- depth = main_col_dims.depth,
- width = main_col_dims.width,
- nbr_measures = this.active_measures.length,
- result = [[{width:1, height: depth + 1}]],
- col_ids = [];
- this.main_col.width = width;
- traverse_tree(this.main_col.root, function (header) {
- var index = header.path.length - 1,
- cell = {
- width: self.get_header_width(header) * nbr_measures,
- height: header.expanded ? 1 : depth - index,
- title: header.path[header.path.length-1],
- id: header.id,
- expanded: header.expanded,
- };
- if (!header.expanded) col_ids.push(header.id);
- if (result[index]) result[index].push(cell);
- else result[index] = [cell];
- });
- col_ids.push(this.main_col.root.id);
- this.main_col.width = width;
- if (width > 1) {
- var total_cell = {width:nbr_measures, height: depth, title:""};
- if (nbr_measures === 1) {
- total_cell.total = true;
- }
- result[0].push(total_cell);
- }
- var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures;
- for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) {
- measure = this.active_measures[i % nbr_measures];
- measure_row.push({
- measure: measure,
- is_bold: (width > 1) && (i >= nbr_measures*width),
- id: col_ids[Math.floor(i / nbr_measures)],
- });
- }
- result.push(measure_row);
- return result;
- },
- get_header_width: function (header) {
- var self = this;
- if (!header.children.length) return 1;
- if (!header.expanded) return 1;
- return header.children.reduce(function (s, c) {
- return s + self.get_header_width(c);
- }, 0);
- },
- get_header_width_depth: function (header) {
- var depth = 1,
- width = 0;
- traverse_tree (header, function (hdr) {
- depth = Math.max(depth, hdr.path.length);
- if (!hdr.expanded) width++;
- });
- return {width: width, depth: depth};
- },
- get_header_depth: function (header) {
- var depth = 1;
- traverse_tree(header, function (hdr) {
- depth = Math.max(depth, hdr.path.length);
- });
- return depth;
- },
- compute_rows: function () {
- var self = this,
- aggregates, i,
- result = [];
- traverse_tree(this.main_row.root, function (header) {
- var values = [],
- col_ids = [];
- result.push({
- id: header.id,
- col_ids: col_ids,
- indent: header.path.length - 1,
- title: header.path[header.path.length-1],
- expanded: header.expanded,
- values: values,
- });
- traverse_tree(self.main_col.root, add_cells, header.id, values, col_ids);
- if (self.main_col.width > 1) {
- aggregates = self.get_value(header.id, self.main_col.root.id);
- for (i = 0; i < self.active_measures.length; i++) {
- values.push(aggregates && aggregates[self.active_measures[i]]);
- }
- col_ids.push( self.main_col.root.id);
- }
- });
- return result;
- function add_cells (col_hdr, row_id, values, col_ids) {
- if (col_hdr.expanded) return;
- col_ids.push(col_hdr.id);
- aggregates = self.get_value(row_id, col_hdr.id);
- for (i = 0; i < self.active_measures.length; i++) {
- values.push(aggregates && aggregates[self.active_measures[i]]);
- }
- }
- },
- get_value: function (id1, id2) {
- if ((id1 in this.cells) && (id2 in this.cells[id1])) {
- return this.cells[id1][id2];
- }
- if (id2 in this.cells) return this.cells[id2][id1];
- },
- flip: function () {
- var temp = this.main_col;
- this.main_col = this.main_row;
- this.main_row = temp;
- this.display_table();
- },
- toggle_measure: function (field) {
- if (_.contains(this.active_measures, field)) {
- this.active_measures = _.without(this.active_measures, field);
- this.display_table();
- } else {
- this.active_measures.push(field);
- this.load_data().then(this.display_table.bind(this));
- }
- },
- download_table: function () {
- framework.blockUI();
- var nbr_measures = this.active_measures.length,
- headers = this.compute_headers(),
- measure_row = nbr_measures > 1 ? _.last(headers) : [],
- rows = this.compute_rows(),
- i, j, value;
- headers[0].splice(0,1);
- // process measure_row
- for (i = 0; i < measure_row.length; i++) {
- measure_row[i].measure = this.measures[measure_row[i].measure].string;
- }
- // process all rows
- for (i =0, j, value; i < rows.length; i++) {
- for (j = 0; j < rows[i].values.length; j++) {
- value = rows[i].values[j];
- rows[i].values[j] = {
- is_bold: (i === 0) || ((this.main_col.width > 1) && (j >= rows[i].values.length - nbr_measures)),
- value: (value === undefined) ? "" : value,
- };
- }
- }
- var table = {
- headers: _.initial(headers),
- measure_row: measure_row,
- rows: rows,
- nbr_measures: nbr_measures,
- title: this.title,
- };
- if(table.measure_row.length + 1 > 256) {
- c.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures."));
- return;
- }
- session.get_file({
- url: '/web/pivot/export_xls',
- data: {data: JSON.stringify(table)},
- complete: framework.unblockUI,
- error: crash_manager.rpc_error.bind(crash_manager)
- });
- },
- destroy: function () {
- if (this.$buttons) {
- this.$buttons.find('button').off(); // remove jquery's tooltip() handlers
- }
- return this._super.apply(this, arguments);
- },
-});
-
-function traverse_tree(root, f, arg1, arg2, arg3) {
- f(root, arg1, arg2, arg3);
- if (!root.expanded) return;
- for (var i = 0; i < root.children.length; i++) {
- traverse_tree(root.children[i], f, arg1, arg2, arg3);
- }
-}
-
-function find_path_in_tree(root, path) {
- var i,
- l = root.path.length;
- if (l === path.length) {
- return (root.path[l-1] === path[l - 1]) ? root : null;
- }
- for (i = 0; i < root.children.length; i++) {
- if (root.children[i].path[l] === path[l]) {
- return find_path_in_tree(root.children[i], path);
- }
- }
- return null;
-}
-
-core.view_registry.add('pivot', PivotView);
-
-return PivotView;
-
-});
diff --git a/addons/web/static/src/js/views/tree_view.js b/addons/web/static/src/js/views/tree_view.js
deleted file mode 100644
index 604c9204b42..00000000000
--- a/addons/web/static/src/js/views/tree_view.js
+++ /dev/null
@@ -1,249 +0,0 @@
-odoo.define('web.TreeView', function (require) {
-"use strict";
-/*---------------------------------------------------------
- * Odoo Tree view
- *---------------------------------------------------------*/
-
-var core = require('web.core');
-var data = require('web.data');
-var formats = require('web.formats');
-var pyeval = require('web.pyeval');
-var session = require('web.session');
-var View = require('web.View');
-
-var _lt = core._lt;
-var QWeb = core.qweb;
-
-var TreeView = View.extend({
- display_name: _lt('Tree'),
- icon: 'fa-align-left',
- // Indicates that this view is not searchable, and thus that no search view should be displayed.
- searchable: false,
-
- /**
- * Genuine tree view (the one displayed as a tree, not the list)
- */
- init: function() {
- this._super.apply(this, arguments);
- this.records = {};
- _.bindAll(this, 'color_for');
- this.fields = this.fields_view.fields;
- this.children_field = this.fields_view.field_parent;
- },
-
- /**
- * Returns the list of fields needed to correctly read objects.
- *
- * Gathers the names of all fields in fields_view_get, and adds the
- * field_parent (children_field in the tree view) if it's not already one
- * of the fields to fetch
- *
- * @returns {Array} an array of fields which can be provided to DataSet.read_slice and others
- */
- fields_list: function () {
- var fields = _.keys(this.fields);
- if (!_(fields).contains(this.children_field)) {
- fields.push(this.children_field);
- }
- return fields;
- },
- willStart: function () {
- _(this.fields_view.arch.children).each(function (field) {
- if (field.attrs.modifiers && typeof field.attrs.modifiers === "string") {
- field.attrs.modifiers = JSON.parse(field.attrs.modifiers);
- }
- });
-
- if (this.fields_view.arch.attrs.colors) {
- this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain()
- .compact()
- .map(function(color_pair) {
- var pair = color_pair.split(':'),
- color = pair[0],
- expr = pair[1];
- return [color, py.parse(py.tokenize(expr)), expr];
- }).value();
- }
-
- return this._super();
- },
- start: function () {
- var self = this;
- var has_toolbar = !!this.fields_view.arch.attrs.toolbar;
- this.hook_row_click();
- this.$el.html(QWeb.render('TreeView', {
- 'title': this.fields_view.arch.attrs.string,
- 'fields_view': this.fields_view.arch.children,
- 'fields': this.fields,
- 'toolbar': has_toolbar
- }));
- this.$el.addClass(this.fields_view.arch.attrs['class']);
-
- this.dataset.read_slice(this.fields_list()).done(function(records) {
- if (!has_toolbar) {
- // WARNING: will do a second read on the same ids, but only on
- // first load so not very important
- self.getdata(null, _(records).pluck('id'));
- return;
- }
-
- var $select = self.$('select')
- .change(function () {
- var $option = $(this).find(':selected');
- self.getdata($option.val(), $option.data('children'));
- });
- _(records).each(function (record) {
- self.records[record.id] = record;
- $('<option>')
- .val(record.id)
- .text(record.name)
- .data('children', record[self.children_field])
- .appendTo($select);
- });
-
- if (!_.isEmpty(records)) {
- $select.change();
- }
- });
-
- // TODO store open nodes in url ?...
- this.do_push_state({});
-
- return this._super();
- },
- /**
- * Returns the color for the provided record in the current view (from the
- * ``@colors`` attribute)
- *
- * @param {Object} record record for the current row
- * @returns {String} CSS color declaration
- */
- color_for: function (record) {
- if (!this.colors) { return ''; }
- var context = _.extend({}, record, {
- uid: session.uid,
- current_date: moment().format('YYYY-MM-DD')
- // TODO: time, datetime, relativedelta
- });
- for(var i=0, len=this.colors.length; i<len; ++i) {
- var pair = this.colors[i],
- color = pair[0],
- expression = pair[1];
- if (py.PY_isTrue(py.evaluate(expression, context))) {
- return 'color: ' + color + ';';
- }
- // TODO: handle evaluation errors
- }
- return '';
- },
- /**
- * Sets up opening a row
- */
- hook_row_click: function () {
- var self = this;
- this.$el.delegate('.treeview-td span, .treeview-tr span', 'click', function (e) {
- e.stopImmediatePropagation();
- self.activate($(this).closest('tr').data('id'));
- });
-
- this.$el.delegate('.treeview-tr', 'click', function () {
- var is_loaded = 0,
- $this = $(this),
- record_id = $this.data('id'),
- row_parent_id = $this.data('row-parent-id'),
- record = self.records[record_id],
- children_ids = record[self.children_field];
-
- _(children_ids).each(function(childid) {
- if (self.$('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').length ) {
- if (self.$('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').is(':hidden')) {
- is_loaded = -1;
- } else {
- is_loaded++;
- }
- }
- });
- if (is_loaded === 0) {
- if (!$this.parent().hasClass('o_open')) {
- self.getdata(record_id, children_ids);
- }
- } else {
- self.showcontent($this, record_id, is_loaded < 0);
- }
- });
- },
- // get child data of selected value
- getdata: function (id, children_ids) {
- var self = this;
-
- self.dataset.read_ids(children_ids, this.fields_list()).done(function(records) {
- _(records).each(function (record) {
- self.records[record.id] = record;
- });
- var $curr_node = self.$('#treerow_' + id);
- var children_rows = QWeb.render('TreeView.rows', {
- 'records': records,
- 'children_field': self.children_field,
- 'fields_view': self.fields_view.arch.children,
- 'fields': self.fields,
- 'level': $curr_node.data('level') || 0,
- 'render': formats.format_value,
- 'color_for': self.color_for,
- 'row_parent_id': id
- });
- if ($curr_node.length) {
- $curr_node.addClass('o_open');
- $curr_node.after(children_rows);
- } else {
- self.$('tbody').html(children_rows);
- }
- });
- },
-
- // Get details in listview
- activate: function(id) {
- var self = this;
- var local_context = {
- active_model: self.model,
- active_id: id,
- active_ids: [id]
- };
- var ctx = pyeval.eval(
- 'context', new data.CompoundContext(
- this.dataset.get_context(), local_context));
- return this.rpc('/web/treeview/action', {
- id: id,
- model: this.model,
- context: ctx
- }).then(function (actions) {
- if (!actions.length) { return; }
- var action = actions[0][2];
- var c = new data.CompoundContext(local_context).set_eval_context(ctx);
- if (action.context) {
- c.add(action.context);
- }
- action.context = c;
- return self.do_action(action);
- });
- },
-
- // show & hide the contents
- showcontent: function (curnode,record_id, show) {
- curnode.parent('tr').toggleClass('o_open', show);
- _(this.records[record_id][this.children_field]).each(function (child_id) {
- var $child_row = this.$('[id=treerow_' + child_id + '][data-row-parent-id='+ curnode.data('id') +']');
- if ($child_row.hasClass('o_open')) {
- $child_row.toggleClass('o_open',show);
- this.showcontent($child_row, child_id, false);
- }
- $child_row.toggle(show);
- }, this);
- },
-});
-
-core.view_registry.add('tree', TreeView);
-
-return TreeView;
-
-});
-
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 00000000000..0310656cd37
--- /dev/null
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -0,0 +1,376 @@
+odoo.define('web.view_dialogs', function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var ListController = require('web.ListController');
+var ListView = require('web.ListView');
+var pyeval = require('web.pyeval');
+var SearchView = require('web.SearchView');
+var view_registry = require('web.view_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.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 || {});
+
+ // FIXME: remove this once a dataset won't be necessary anymore to interact
+ // with data_manager and instantiate views
+ this.dataset = new data.DataSet(this, this.res_model, this.context);
+ },
+
+ //--------------------------------------------------------------------------
+ // 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 {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 {function} [options.on_save] callback to execute when clicking on
+ * 'Save' (form view's 'saveRecord' by default)
+ * @param {function} [options.on_saved] callback executed after on_save
+ * @param {BasicModel} [options.model] if given, it will be used instead of
+ * a new form view model
+ */
+ init: function (parent, options) {
+ var self = this;
+
+ this.res_id = options.res_id || null;
+ this.on_saved = options.on_saved || (function () {});
+ this.context = options.context;
+ this.model = options.model;
+
+ var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection;
+ var readonly = _.isNumber(options.res_id) && options.readonly;
+
+ if (!options || !options.buttons) {
+ options = options || {};
+ options.buttons = [{
+ text: (readonly ? _t("Close") : _t("Discard")),
+ classes: "btn-default o_form_button_cancel",
+ close: true,
+ click: function () {
+ if (!readonly) {
+ self.form_view.model.discardChanges(self.form_view.handle);
+ }
+ },
+ }];
+
+ if (!readonly) {
+ options.buttons.unshift({
+ text: _t("Save") + ((multi_select)? " " + _t(" & Close") : ""),
+ classes: "btn-primary",
+ click: function () {
+ this._save().then(self.close.bind(self));
+ }
+ });
+
+ if (multi_select) {
+ options.buttons.splice(1, 0, {
+ text: _t("Save & New"),
+ classes: "btn-primary",
+ click: function () {
+ this._save().then(self.form_view.createRecord.bind(self.form_view));
+ },
+ });
+ }
+ }
+ }
+ 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 = $.when(this.options.fields_view);
+ } else {
+ fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form');
+ }
+
+ fields_view_def.then(function (fields_view) {
+ var formview = new FormView(fields_view.arch, fields_view.fields, {
+ modelName: self.res_model,
+ context: self.context,
+ ids: self.res_id ? [self.res_id] : [],
+ currentId: self.res_id || undefined,
+ index: 0,
+ mode: self.res_id && self.options.readonly ? 'readonly' : 'edit',
+ footer_to_buttons: true,
+ default_buttons: false,
+ model: self.model,
+ });
+ return formview.createController(self);
+ }).then(function (formView) {
+ self.form_view = formView;
+ var fragment = document.createDocumentFragment();
+ self.form_view.appendTo(fragment)
+ .then(function () {
+ var $buttons = $('<div>');
+ self.form_view.renderButtons($buttons);
+ if ($buttons.children().length) {
+ self.$footer.empty().append($buttons.contents());
+ }
+ _super().$el.append(fragment);
+ self.form_view.autofocus();
+ });
+ });
+
+ return this;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _save: function () {
+ var self = this;
+ var def;
+ if (this.options.on_save) {
+ def = this.options.on_save(this.form_view.model.get(this.form_view.handle));
+ } else {
+ def = this.form_view.saveRecord({stayInEdit: true, reload: false});
+ }
+ return $.when(def).then(function () {
+ // 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
+ self.on_saved(self.form_view.model.get(self.form_view.handle));
+ });
+ },
+});
+
+var SelectCreateListController = ListController.extend({
+ // Override the ListView to handle the custom events 'open_record' (triggered when clicking on a
+ // row of the list) such that it triggers up 'select_record' with its res_id.
+ custom_events: _.extend({}, ListController.prototype.custom_events, {
+ open_record: function (event) {
+ var selected_record = this.model.get(event.data.id);
+ this.trigger_up('select_record', {id: selected_record.res_id});
+ },
+ }),
+});
+
+/**
+ * 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.id]);
+ this.close();
+ }
+ },
+ selection_changed: function (event) {
+ this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length);
+ },
+ search: function (event) {
+ event.stopPropagation(); // prevent this event from bubbling up to the view manager
+ var d = event.data;
+ var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys);
+ this.list_controller.reload(searchData);
+ },
+ }),
+
+ /**
+ * 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
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ _.defaults(this.options, { initial_view: 'search' });
+ this.on_selected = this.options.on_selected || (function () {});
+ this.initial_ids = this.options.initial_ids;
+ },
+
+ open: function () {
+ if(this.options.initial_view !== "search") {
+ return this.create_edit_record();
+ }
+ var user_context = this.getSession().user_context;
+
+ var _super = this._super.bind(this);
+ var context = pyeval.eval_domains_and_contexts({
+ domains: [],
+ contexts: [user_context, this.context]
+ }).context;
+ var search_defaults = {};
+ _.each(context, function (value_, key) {
+ var match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ search_defaults[match[1]] = value_;
+ }
+ });
+ this.loadViews(this.dataset.model, this.dataset.get_context(), [[false, 'list'], [false, 'search']], {})
+ .then(this.setup.bind(this, search_defaults))
+ .then(function (fragment) {
+ _super().$el.append(fragment);
+ });
+ return this;
+ },
+
+ setup: function (search_defaults, fields_views) {
+ var self = this;
+ var fragment = document.createDocumentFragment();
+
+ var searchDef = $.Deferred();
+
+ // Set the dialog's header and its search view
+ var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment);
+ var $pager = $('<div/>').addClass('o_pager').appendTo($header);
+ var options = {
+ $buttons: $('<div/>').addClass('o_search_options').appendTo($header),
+ search_defaults: search_defaults,
+ };
+ var searchview = new SearchView(this, this.dataset, fields_views.search, options);
+ searchview.prependTo($header).done(function () {
+ var d = searchview.build_search_data();
+ d.domains = d.domains.concat([self.domain]);
+ if (self.initial_ids) {
+ d.domains.push([["id", "in", self.initial_ids]]);
+ self.initial_ids = undefined;
+ }
+ var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys);
+ searchDef.resolve(searchData);
+ });
+
+ return $.when(searchDef).then(function (searchResult) {
+ // Set the list view
+ var listView = new ListView(fields_views.list.arch, fields_views.list.fields, _.extend({
+ context: searchResult.context,
+ domain: searchResult.domain,
+ groupBy: searchResult.groupBy,
+ modelName: self.dataset.model,
+ hasSelectors: !self.options.disable_multiple_selection,
+ }, self.options.list_view_options));
+ listView.setController(SelectCreateListController);
+ return listView.createController(self);
+ }).then(function (controller) {
+ self.list_controller = controller;
+ // Set the dialog's buttons
+ var buttons = [{
+ text: _t("Cancel"),
+ classes: "btn-default o_form_button_cancel",
+ close: true,
+ }];
+ if (!self.options.no_create) {
+ buttons.unshift({
+ text: _t("Create"),
+ classes: "btn-primary",
+ click: self.create_edit_record.bind(self)
+ });
+ }
+ if (!self.options.disable_multiple_selection) {
+ buttons.unshift({
+ text: _t("Select"),
+ classes: "btn-primary o_select_button",
+ disabled: true,
+ close: true,
+ click: function () {
+ self.on_selected(self.list_controller.getSelectedIds());
+ },
+ });
+ }
+ self.set_buttons(buttons);
+ return self.list_controller.appendTo(fragment);
+ }).then(function () {
+ searchview.toggle_visibility(true);
+ self.list_controller.do_show();
+ self.list_controller.renderPager($pager);
+ return fragment;
+ });
+ },
+ _process_search_data: function (domains, contexts, groupbys) {
+ var user_context = this.getSession().user_context;
+ contexts = [user_context].concat(contexts);
+ var results = pyeval.eval_domains_and_contexts({
+ domains: domains || [],
+ contexts: contexts || [],
+ group_by_seq: groupbys || []
+ });
+ return {
+ context: results.context,
+ domain: results.domain,
+ groupBy: results.group_by,
+ };
+ },
+ create_edit_record: function () {
+ var self = this;
+ var dialog = new FormViewDialog(this, _.extend({}, this.options, {
+ on_saved: function (record) {
+ self.on_selected([record.res_id]);
+ },
+ })).open();
+ dialog.on('closed', this, this.close.bind(this));
+ },
+});
+
+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 00000000000..f936787ba1a
--- /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/widgets/change_password.js b/addons/web/static/src/js/widgets/change_password.js
index 367cf42e680..56f9603a310 100644
--- a/addons/web/static/src/js/widgets/change_password.js
+++ b/addons/web/static/src/js/widgets/change_password.js
@@ -1,6 +1,11 @@
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 core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
@@ -8,30 +13,49 @@ var web_client = require('web.web_client');
var _t = core._t;
-var ChangePassword = Widget.extend({ // FIXME
+var ChangePassword = Widget.extend({
template: "ChangePassword",
- start: function() {
+
+ /**
+ * @fixme: weird interaction with the parent for the $buttons handling
+ *
+ * @override
+ * @returns {Deferred}
+ */
+ 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(1).click(function () {
+ self.$el.parents('.modal').modal('hide');
+ });
+ $button.eq(0).click(function () {
+ self.performRPC('/web/session/change_password', {
+ fields: $('form[name=change_password_form]').serializeArray(),
+ }).done(function (result) {
+ if (result.error) {
+ self._display_error(result);
+ } else {
+ self.do_action('logout');
+ }
+ });
});
- $button.eq(0).click(function(){
- self.rpc("/web/session/change_password",{
- 'fields': $("form[name=change_password_form]").serializeArray()
- }).done(function(result) {
- if (result.error) {
- self.display_error(result);
- return;
- } else {
- self.do_action('logout');
- }
- });
- });
},
- display_error: function (error) {
+
+ //--------------------------------------------------------------------------
+ // 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,
@@ -43,4 +67,5 @@ var ChangePassword = Widget.extend({ // FIXME
core.action_registry.add("change_password", ChangePassword);
return ChangePassword;
+
});
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index d3135f86ba6..b4ae2023684 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -244,9 +244,9 @@ var DataExport = Dialog.extend({
}).eq(0).change();
waitFor.push(got_fields);
- waitFor.push(this.getParent().get_active_domain().then(function (domain) {
+ waitFor.push(this.getParent().getActiveDomain().then(function (domain) {
if (domain === undefined) {
- self.ids_to_export = self.getParent().get_selected_ids();
+ self.ids_to_export = self.getParent().getSelectedIds();
self.domain = self.dataset.domain;
} else {
self.ids_to_export = false;
diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js
index bd9416a278a..4877a17c3ce 100644
--- a/addons/web/static/src/js/widgets/date_picker.js
+++ b/addons/web/static/src/js/widgets/date_picker.js
@@ -54,8 +54,9 @@ var DateWidget = Widget.extend({
},
set_value: function(value) {
this.set({'value': value});
- this.$input.val((value)? this.format_client(value) : '');
- this.picker.date(this.format_client(value));
+ var formatted_value = value ? this.format_client(value) : null;
+ this.$input.val(formatted_value);
+ this.picker.date(formatted_value);
},
get_value: function() {
return this.get('value');
@@ -103,9 +104,6 @@ var DateWidget = Widget.extend({
this.trigger("datetime_changed");
}
},
- commit_value: function() {
- this.change_datetime();
- },
destroy: function() {
this.picker.destroy();
this._super.apply(this, arguments);
diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js
index 2b207a9bb52..44845f7b9e6 100644
--- a/addons/web/static/src/js/widgets/debug_manager.js
+++ b/addons/web/static/src/js/widgets/debug_manager.js
@@ -2,7 +2,7 @@ odoo.define('web.DebugManager', function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
-var common = require('web.form_common');
+var dialogs = require('web.view_dialogs');
var core = require('web.core');
var Dialog = require('web.Dialog');
var formats = require('web.formats');
@@ -159,7 +159,7 @@ var DebugManager = Widget.extend({
},
select_view: function () {
var self = this;
- new common.SelectCreateDialog(this, {
+ new dialogs.SelectCreateDialog(this, {
res_model: 'ir.ui.view',
title: _t('Select a view'),
disable_multiple_selection: true,
@@ -288,6 +288,9 @@ DebugManager.include({
update: function (tag, descriptor, widget) {
switch (tag) {
case 'action':
+ if (this._view_manager) {
+ this._view_manager.off('switch_mode', this);
+ }
if (!(widget instanceof ViewManager)) {
this._active_view = null;
this._view_manager = null;
@@ -314,11 +317,11 @@ DebugManager.include({
get_metadata: function() {
var ds = this._view_manager.dataset;
- if (!this._active_view.controller.get_selected_ids().length) {
+ if (!this._active_view.controller.getSelectedIds().length) {
console.warn(_t("No metadata available"));
return
}
- ds.call('get_metadata', [this._active_view.controller.get_selected_ids()]).done(function(result) {
+ ds.call('get_metadata', [this._active_view.controller.getSelectedIds()]).done(function(result) {
new Dialog(this, {
title: _.str.sprintf(_t("Metadata (%s)"), ds.model),
size: 'medium',
@@ -335,11 +338,11 @@ DebugManager.include({
fvg: function() {
var dialog = new Dialog(this, { title: _t("Fields View Get") }).open();
$('<pre>').text(utils.json_node_to_xml(
- this._active_view.controller.fields_view.arch, true)
+ this._active_view.controller.renderer.arch, true)
).appendTo(dialog.$el);
},
print_workflow: function() {
- var ids = this._active_view.controller.get_selected_ids();
+ var ids = this._active_view.controller.getSelectedIds();
framework.blockUI();
var action = {
context: { active_ids: ids },
diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js
index eccbd9d7491..81d5047a5bc 100644
--- a/addons/web/static/src/js/widgets/domain_selector.js
+++ b/addons/web/static/src/js/widgets/domain_selector.js
@@ -3,7 +3,7 @@ odoo.define("web.DomainSelector", function (require) {
var core = require("web.core");
var datepicker = require("web.datepicker");
-var domainUtils = require("web.domainUtils");
+var Domain = require("web.Domain");
var formats = require ("web.formats");
var ModelFieldSelector = require("web.ModelFieldSelector");
var Widget = require("web.Widget");
@@ -108,9 +108,9 @@ var DomainTree = DomainNode.extend({
/// 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 (default to "&").
/// @see DomainTree._addFlattenedChildren
- init: function (parent, model, domain, options) {
+ init: function (parent, model, domainStr, options) {
this._super.apply(this, arguments);
- this._initialize(domainUtils.stringToDomain(domain));
+ this._initialize(Domain.prototype.stringToArray(domainStr));
},
/// @see DomainTree.init
_initialize: function (domain) {
@@ -257,14 +257,14 @@ var DomainSelector = DomainTree.extend({
/// the widget is re-rendered and notifies the parents. If not, a warning is shown to the
/// user and the input is ignored.
"change .o_domain_debug_input": function (e) {
- var domain;
+ var currentDomain;
try {
- domain = domainUtils.stringToDomain($(e.currentTarget).val());
+ currentDomain = Domain.prototype.stringToArray($(e.currentTarget).val());
} catch (err) {
this.do_warn(_t("Syntax error"), _t("The domain you entered is not properly formed"));
return;
}
- this._redraw(domain).then((function () {
+ this._redraw(currentDomain).then((function () {
this.trigger_up("domain_changed", {child: this, alreadyRedrawn: true});
}).bind(this));
},
@@ -307,7 +307,7 @@ var DomainSelector = DomainTree.extend({
// Display technical domain if in debug mode
this.$debugInput = this.$(".o_domain_debug_input");
if (this.$debugInput.length) {
- this.$debugInput.val(domainUtils.domainToString(this.getDomain()));
+ this.$debugInput.val(Domain.prototype.arrayToString(this.getDomain()));
}
},
});
@@ -335,13 +335,13 @@ var DomainLeaf = DomainNode.extend({
},
},
/// @see DomainNode.init
- init: function (parent, model, domain, options) {
+ init: function (parent, model, domainStr, options) {
this._super.apply(this, arguments);
- domain = domainUtils.stringToDomain(domain);
- this.chain = domain[0][0];
- this.operator = domain[0][1];
- this.value = domain[0][2];
+ var currentDomain = Domain.prototype.stringToArray(domainStr);
+ this.chain = currentDomain[0][0];
+ this.operator = currentDomain[0][1];
+ this.value = currentDomain[0][2];
this.operator_mapping = operator_mapping;
},
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 00000000000..cf5bc15c950
--- /dev/null
+++ b/addons/web/static/src/js/widgets/iframe_widget.js
@@ -0,0 +1,64 @@
+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 overwritting 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 {Deferred}
+ */
+ 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/priority.js b/addons/web/static/src/js/widgets/priority.js
deleted file mode 100644
index 44ff3b50c1e..00000000000
--- a/addons/web/static/src/js/widgets/priority.js
+++ /dev/null
@@ -1,84 +0,0 @@
-odoo.define('web.Priority', function (require) {
-"use strict";
-
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var Priority = Widget.extend({
- template: "Priority",
- events: {
- 'mouseover > a': function(e) {
- clearTimeout(this.hover_timer);
-
- this.$stars.removeClass('fa-star-o').addClass('fa-star');
- $(e.target).nextAll().removeClass('fa-star').addClass('fa-star-o');
- },
- 'mouseout > a': function(e) {
- clearTimeout(this.hover_timer);
-
- var self = this;
- this.hover_timer = setTimeout(function() {
- self._render_value();
- }, 200);
- },
- 'click > a': function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- var old_value = this.value;
- this.value = String($(e.target).data('value'));
- if(old_value === this.value) {
- this.value = this.empty_value;
- }
-
- this._render_value();
- this.trigger('update', {value: this.value});
- },
- },
- init: function(parent, options) {
- this._super.apply(this, arguments);
-
- options = _.defaults(options || {}, {
- readonly: true,
- value: 0,
- values: [
- [0, _t("Normal")],
- [1, _t("Low")],
- [2, _t("High")],
- [3, _t("Very High")]
- ],
- });
-
- this.readonly = options.readonly;
- this.value = options.value;
- this.values = options.values;
- this.empty_value = this.values[0][0];
- },
- start: function() {
- this._render_value();
- return this._super();
- },
- set_value: function(value) {
- this.value = value;
- this._render_value();
- },
- _render_value: function() {
- var self = this;
- var index_value = this.value ? _.findIndex(this.values, function (v) {
- return v[0] === self.value;
- }) : 0;
- this.$stars = this.$('.o_priority_star');
- this.$stars.each(function(i, el) {
- var $star = $(el);
- $star.toggleClass('fa-star', (i+1 <= index_value))
- .toggleClass('fa-star-o', (i+1 > index_value));
- });
- },
-});
-
-return Priority;
-
-});
diff --git a/addons/web/static/src/js/widgets/progress_bar.js b/addons/web/static/src/js/widgets/progress_bar.js
deleted file mode 100644
index 265388aab7c..00000000000
--- a/addons/web/static/src/js/widgets/progress_bar.js
+++ /dev/null
@@ -1,141 +0,0 @@
-odoo.define('web.ProgressBar', function (require) {
-"use strict";
-
-var core = require('web.core');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * options
- * - readonly
- * - value
- * - max_value
- * - title: title of the gauge, displayed on the left
- */
-var ProgressBar = Widget.extend({
- 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);
- }
- },
- },
-
- init: function (parent, options) {
- this._super(parent);
-
- options = _.defaults(options || {}, {
- readonly: true,
- edit_on_click: false,
- value: 0,
- max_value: 100,
- title: '',
- edit_max_value: false,
- });
-
- this.readonly = options.readonly;
- this.edit_on_click = options.edit_on_click;
- this.value = options.value;
- this.max_value = options.max_value;
- this.title = options.title;
- this.edit_max_value = options.edit_max_value;
- },
-
- start: function() {
- this._render_value();
-
- if(!this.readonly) {
- if(this.edit_on_click) {
- var self = this;
- this.$el.on('click', '.o_progress', function(e) {
- var $target = $(e.currentTarget);
- self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value);
- self._render_value();
- self.trigger('update', {value: self.value, max_value: self.max_value, changed_value: self.value});
- });
- } else {
- this.$el.on('blur', 'input', _.bind(this.on_change_input, this));
- }
-
- this.$('.o_progressbar_value').focus().select()
- }
-
- return this._super();
- },
-
- on_change_input: function(e) {
- var $input = $(e.target);
- if(e.type === 'change' && !$input.is(':focus')) {
- return;
- }
- if(isNaN($input.val())) {
- this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid."));
- } else {
- if(e.type === 'input') {
- this._render_value($input.val());
- if(parseFloat($input.val()) === 0) {
- $input.select();
- }
- } else {
- if(this.edit_max_value) {
- this.max_value = $(e.target).val();
- } else {
- this.value = $(e.target).val() || 0;
- }
-
- this._render_value();
- this.trigger('update', {value: this.value, max_value: this.max_value, changed_value: (this.edit_max_value)? this.max_value : this.value});
- }
- }
- },
-
- set_value: function(v) {
- this.value = v;
- this._render_value();
- },
-
- _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);
- this.$('.o_progressbar_complete').css('width', widthComplete + '%');
-
- if(this.readonly) {
- if(max_value !== 100) {
- this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value));
- } else {
- this.$('.o_progressbar_value').html(utils.human_number(value) + "%");
- }
- } else if(isNaN(v)) {
- this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value);
- }
- }
-});
-
-return ProgressBar;
-
-});
diff --git a/addons/web/static/src/js/widgets/user_menu.js b/addons/web/static/src/js/widgets/user_menu.js
deleted file mode 100644
index d1cd0eb842b..00000000000
--- a/addons/web/static/src/js/widgets/user_menu.js
+++ /dev/null
@@ -1,81 +0,0 @@
-odoo.define('web.UserMenu', function (require) {
-"use strict";
-
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var framework = require('web.framework');
-var Model = require('web.Model');
-var session = require('web.session');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-var UserMenu = Widget.extend({
- template: "UserMenu",
- start: function() {
- var self = this;
- this.$el.on('click', '.dropdown-menu li a[data-menu]', function(ev) {
- ev.preventDefault();
- var f = self['on_menu_' + $(this).data('menu')];
- if (f) {
- f($(this));
- }
- });
- return this._super.apply(this, arguments).then(function () {
- return self.do_update();
- });
- },
- do_update: function () {
- var $avatar = this.$('.oe_topbar_avatar');
- if (!session.uid) {
- $avatar.attr('src', $avatar.data('default-src'));
- return $.when();
- }
- var topbar_name = session.name;
- if(session.debug) {
- topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db);
- }
- this.$('.oe_topbar_name').text(topbar_name);
- var avatar_src = session.url('/web/image', {model:'res.users', field: 'image_small', id: session.uid});
- $avatar.attr('src', avatar_src);
- },
- on_menu_documentation: function () {
- window.open('https://www.odoo.com/documentation/user', '_blank');
- },
- on_menu_support: function () {
- window.open('https://www.odoo.com/buy', '_blank');
- },
- on_menu_settings: function() {
- var self = this;
- this.trigger_up('clear_uncommitted_changes', {
- callback: function() {
- self.rpc("/web/action/load", { action_id: "base.action_res_users_my" }).done(function(result) {
- result.res_id = session.uid;
- self.do_action(result);
- });
- },
- });
- },
- on_menu_account: function() {
- var self = this;
- this.trigger_up('clear_uncommitted_changes', {
- callback: function() {
- self.rpc('/web/session/account').then(function(url) {
- framework.redirect(url);
- }).fail(function(result, ev){
- ev.preventDefault();
- framework.redirect('https://accounts.odoo.com/account');
- });
- },
- });
- },
- on_menu_logout: function() {
- this.trigger_up('clear_uncommitted_changes', {
- callback: this.do_action.bind(this, 'logout'),
- });
- },
-});
-
-return UserMenu;
-});