summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
authorOlivier Dony <odo@openerp.com>2017-06-01 01:46:13 +0200
committerOlivier Dony <odo@openerp.com>2017-06-01 01:46:13 +0200
commit5d2869cbc812f716e3115b5efb9bd1c3082f47b7 (patch)
tree364844fc326f0d095997120d10d83e67fdf341e9 /addons/web/static/src
parent04111cae39e36015d3c8494f52b110ab5ab6ad80 (diff)
parentba15df47cb25625cb0921cba554aa0879048c90a (diff)
[MERGE] Forward-port saas-16 up to ba15df47cb25625cb0921cba554aa0879048c90a
Diffstat (limited to 'addons/web/static/src')
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js4
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js28
-rw-r--r--addons/web/static/src/js/chrome/view_manager.js15
-rw-r--r--addons/web/static/src/js/core/mixins.js1
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js11
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js41
-rw-r--r--addons/web/static/src/js/fields/field_utils.js70
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js124
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js78
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js399
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js24
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js27
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js2
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js2
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js59
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js25
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js6
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js19
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js20
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js127
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js64
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js15
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js27
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js6
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js8
-rw-r--r--addons/web/static/src/js/widgets/data_export.js5
-rw-r--r--addons/web/static/src/less/form_view.less1
-rw-r--r--addons/web/static/src/less/layout_background.less2
-rw-r--r--addons/web/static/src/less/list_view.less21
-rw-r--r--addons/web/static/src/xml/base.xml16
30 files changed, 828 insertions, 419 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 982fd05b1e0..9f56cd01ef4 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -76,6 +76,10 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, {
if (event.data.on_success) {
event.data.on_success(result);
}
+ }).fail(function (result) {
+ if (event.data.on_fail) {
+ event.data.on_fail(result);
+ }
});
},
},
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index b02376ef6b7..c81479ec1e0 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -182,19 +182,35 @@ var ViewManagerAction = WidgetAction.extend({
},
/**
* Sets the scroll position of the widgets's active_view
- * @param {int} [scrollTop] the number of pixels to scroll
+ * @todo: replace this with a generic get/set local state mechanism.
+ * @see getScrollTop
+ *
+ * @override
+ * @param {integer} [scrollTop] the number of pixels to scroll
*/
- setScrollTop: function(scrollTop) {
- var viewController = this.widget.active_view.controller;
+ setScrollTop: function (scrollTop) {
+ var activeView = this.widget.active_view;
+ var viewController = activeView && activeView.controller;
if (viewController) {
viewController.setScrollTop(scrollTop);
}
},
/**
- * @return {int} the number of pixels the webclient is scrolled when leaving the action
+ * Returns the current scrolling offset for the current action. We have to
+ * ask nicely the question to the active view, because the answer depends
+ * on the view.
+ *
+ * @todo: replace this mechanism with a generic getLocalState and
+ * getLocalState. Scrolling behaviour is only a part of what we might want
+ * to restore.
+ *
+ * @override
+ * @returns {integer} the number of pixels the webclient is currently
+ * scrolled
*/
- getScrollTop: function() {
- var viewController = this.widget.active_view.controller;
+ getScrollTop: function () {
+ var activeView = this.widget.active_view;
+ var viewController = activeView && activeView.controller;
return viewController ? viewController.getScrollTop() : 0;
},
/**
diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js
index bc441f7accf..8d760c568b0 100644
--- a/addons/web/static/src/js/chrome/view_manager.js
+++ b/addons/web/static/src/js/chrome/view_manager.js
@@ -20,7 +20,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
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)
+ this.do_execute_action(data.action_data, data.model, data.res_ids, data.on_closed)
.then(data.on_success, data.on_fail);
},
search: function(event) {
@@ -551,13 +551,14 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
* @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 {integer[]} [res_ids] the res_ids of the objects on which the action is to be applied (fallback on env.ids if not set)
* @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) {
+ do_execute_action: function (action_data, model, res_ids, on_closed) {
var self = this;
var result_handler = on_closed || function () {};
var context = new Context(this.env.context, action_data.context || {});
+ var record_id = res_ids && res_ids[0];
// response handler
var handler = function (action) {
@@ -572,10 +573,10 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
);
ncontext.add(action_data.context || {});
ncontext.add({active_model: self.env.modelName});
- if (record_id) {
+ if (res_ids) {
ncontext.add({
active_id: record_id,
- active_ids: [record_id],
+ active_ids: res_ids,
});
}
ncontext.add(action.context || {});
@@ -592,7 +593,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
if (action_data.special) {
return handler({"type":"ir.actions.act_window_close"});
} else if (action_data.type === "object") {
- var args = record_id ? [[record_id]] : [this.env.ids];
+ var args = res_ids ? [res_ids] : [this.env.ids];
if (action_data.args) {
try {
// Warning: quotes and double quotes problem due to json and xml clash
@@ -610,7 +611,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
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
+ active_id: record_id,
})).then(handler);
}
},
diff --git a/addons/web/static/src/js/core/mixins.js b/addons/web/static/src/js/core/mixins.js
index 9c643597f04..cc99cc1aaa4 100644
--- a/addons/web/static/src/js/core/mixins.js
+++ b/addons/web/static/src/js/core/mixins.js
@@ -511,6 +511,7 @@ var ServicesMixin = {
action: action,
options: options,
on_success: function (result) { def.resolve(result); },
+ on_fail: function (result) { def.reject(result); },
});
return def;
},
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
index 725a237750d..d09174b2b20 100644
--- a/addons/web/static/src/js/fields/abstract_field.js
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -348,8 +348,11 @@ var AbstractField = Widget.extend({
*
* @private
* @param {any} value
+ * @param {Object} [options]
+ * @param {boolean} [options.forceChange=false] if true, the change event will be
+ * triggered even if the new value is the same as the old one
*/
- _setValue: function (value) {
+ _setValue: function (value, options) {
// we try to avoid doing useless work, if the value given has not
// changed. Note that we compare the unparsed values.
if (this.lastSetValue === value || (this.value === false && value === '')) {
@@ -363,7 +366,7 @@ var AbstractField = Widget.extend({
this._isValid = false;
return;
}
- if (this._isSameValue(value)) {
+ if (!(options && options.forceChange) && this._isSameValue(value)) {
return;
}
var changes = {};
@@ -371,6 +374,7 @@ var AbstractField = Widget.extend({
this.trigger_up('field_changed', {
dataPointID: this.dataPointID,
changes: changes,
+ viewType: this.viewType,
});
},
@@ -404,6 +408,9 @@ var AbstractField = Widget.extend({
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'next_line'});
break;
+ case $.ui.keyCode.ESCAPE:
+ this.trigger_up('navigation_move', {direction: 'cancel'});
+ break;
case $.ui.keyCode.UP:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'up'});
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 49bcccd2640..a5ceba50d3f 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -83,10 +83,10 @@ var DebouncedField = AbstractField.extend({
init: function () {
this._super.apply(this, arguments);
- // _debouncedStarted is used to detect that the user interacted at least
+ // _isDirty is used to detect that the user interacted at least
// once with the widget, so that we can prevent it from triggering a
// field_changed in commitChanges if the user didn't change anything
- this._debouncedStarted = false;
+ this._isDirty = false;
if (this.mode === 'edit') {
if (this.DEBOUNCE) {
this._doDebouncedAction = _.debounce(this._doAction, this.DEBOUNCE);
@@ -97,7 +97,7 @@ var DebouncedField = AbstractField.extend({
var self = this;
var debouncedFunction = this._doDebouncedAction;
this._doDebouncedAction = function () {
- self._debouncedStarted = true;
+ self._isDirty = true;
debouncedFunction.apply(self, arguments);
};
}
@@ -115,7 +115,7 @@ var DebouncedField = AbstractField.extend({
* @override
*/
commitChanges: function () {
- if (this._debouncedStarted && this.mode === 'edit') {
+ if (this._isDirty && this.mode === 'edit') {
this._doAction();
}
},
@@ -132,7 +132,13 @@ var DebouncedField = AbstractField.extend({
* @private
*/
_doAction: function () {
- this._setValue(this._getValue());
+ // as _doAction may be debounced, it may happen that it is called after
+ // the widget has been destroyed, and in this case, we don't want it to
+ // do anything (commitChanges ensures that if it has local changes, they
+ // are triggered up before the widget is destroyed, if necessary).
+ if (!this.isDestroyed()) {
+ this._setValue(this._getValue());
+ }
},
/**
* Should return the current value of the field, in the DOM (for example,
@@ -159,6 +165,7 @@ var InputField = DebouncedField.extend({
*/
init: function () {
this._super.apply(this, arguments);
+ this.nodeOptions.isPassword = 'password' in this.attrs;
if (this.mode === 'edit') {
this.tagName = 'input';
}
@@ -216,8 +223,11 @@ var InputField = DebouncedField.extend({
this.$input = $input || $("<input/>");
this.$input.addClass('o_input');
this.$input.attr({
- type: 'text',
+ type: this.nodeOptions.isPassword ? 'password' : 'text',
placeholder: this.attrs.placeholder || "",
+ autocomplete: this.nodeOptions.isPassword ?
+ 'new-password' :
+ this.attrs.autocomplete,
});
this.$input.val(this._formatValue(this.value));
return this.$input;
@@ -240,7 +250,7 @@ var InputField = DebouncedField.extend({
* @private
*/
_renderReadonly: function () {
- this.$el.html(this._formatValue(this.value));
+ this.$el.text(this._formatValue(this.value));
},
//--------------------------------------------------------------------------
@@ -524,6 +534,13 @@ var FieldMonetary = InputField.extend({
}
},
/**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.html(this._formatValue(this.value));
+ },
+ /**
* Re-gets the currency as its value may have changed.
* @see FieldMonetary.resetOnAnyFieldChange
*
@@ -541,7 +558,7 @@ var FieldMonetary = InputField.extend({
* @private
*/
_setCurrency: function () {
- var currencyField = this.attrs.currency_field || this.field.currency_field || 'currency_id';
+ var currencyField = this.nodeOptions.currency_field || this.field.currency_field || 'currency_id';
var currencyID = this.record.data[currencyField] && this.record.data[currencyField].res_id;
this.currency = session.get_currency(currencyID);
},
@@ -885,8 +902,8 @@ var FieldPhone = FieldEmail.extend({
_renderReadonly: function () {
this._super();
if (this._canCall()) {
- var text = this.$el.text();
// Split phone number into two to prevent Skype app from finding it
+ var text = this.$el.text();
var part1 = _.escape(text.substr(0, text.length/2));
var part2 = _.escape(text.substr(text.length/2));
this.$el.html(part1 + "&shy;" + part2);
@@ -1543,7 +1560,7 @@ var FieldPercentPie = AbstractField.extend({
this.$leftMask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg});
this.$rightMask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg});
- this.$pieValue.html(Math.round(value) + '%');
+ this.$pieValue.text(Math.round(value) + '%');
},
});
@@ -1684,9 +1701,9 @@ var FieldProgressBar = AbstractField.extend({
if (!this.write_mode) {
if (max_value !== 100) {
- this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value));
+ this.$('.o_progressbar_value').text(utils.human_number(value) + " / " + utils.human_number(max_value));
} else {
- this.$('.o_progressbar_value').html(utils.human_number(value) + "%");
+ this.$('.o_progressbar_value').text(utils.human_number(value) + "%");
}
} else if (isNaN(v)) {
this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value);
diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js
index ed3014d9bb6..9a6734b7baf 100644
--- a/addons/web/static/src/js/fields/field_utils.js
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -52,10 +52,23 @@ function formatBoolean(value) {
* an empty string.
*
* @param {string|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ * @param {boolean} [options.isPassword=false] if true, returns '********'
+ * instead of the formatted value
* @returns {string}
*/
-function formatChar(value) {
- return typeof value === 'string' ? value : '';
+function formatChar(value, field, options) {
+ value = typeof value === 'string' ? value : '';
+ if (options && options.isPassword) {
+ return _.str.repeat('*', value ? value.length : 0);
+ }
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
}
/**
@@ -169,9 +182,16 @@ function formatFloatTime(value) {
* return an empty string.
*
* @param {integer|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.isPassword=false] if true, returns '********'
* @returns {string}
*/
-function formatInteger(value) {
+function formatInteger(value, field, options) {
+ if (options && options.isPassword) {
+ return _.str.repeat('*', String(value).length);
+ }
if (!value && value !== 0) {
// previously, it returned 'false'. I don't know why. But for the Pivot
// view, I want to display the concept of 'no value' with an empty
@@ -189,10 +209,18 @@ function formatInteger(value) {
* case, we assume that it is a record from a BasicModel.
*
* @param {Array|Object|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
* @returns {string}
*/
-function formatMany2one(value) {
- return value && (_.isArray(value) ? value[1] : value.data.display_name) || '';
+function formatMany2one(value, field, options) {
+ value = value && (_.isArray(value) ? value[1] : value.data.display_name) || '';
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
}
/**
@@ -270,14 +298,27 @@ function formatMonetary(value, field, options) {
}
}
-function formatSelection(value, field) {
- if (!value) {
- return '';
- }
+/**
+ * Returns a string representing the value of the selection.
+ *
+ * @param {string|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ */
+function formatSelection(value, field, options) {
var val = _.find(field.selection, function (option) {
return option[0] === value;
});
- return val[1];
+ if (!val) {
+ return '';
+ }
+ value = val[1];
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
}
////////////////////////////////////////////////////////////////////////////////
@@ -318,7 +359,7 @@ function parseDate(value, field, options) {
}
if (date.year() >= 1900) {
date.toJSON = function () {
- return this.format('YYYY-MM-DD');
+ return this.clone().locale('en').format('YYYY-MM-DD');
};
return date;
}
@@ -365,7 +406,7 @@ function parseDateTime(value, field, options) {
}
if (datetime.year() >= 1900) {
datetime.toJSON = function () {
- return this.format('YYYY-MM-DD HH:mm:ss');
+ return this.clone().locale('en').format('YYYY-MM-DD HH:mm:ss');
};
return datetime;
}
@@ -374,7 +415,10 @@ function parseDateTime(value, field, options) {
}
function parseFloat(value) {
- value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), '');
+ if (core._t.database.parameters.thousands_sep) {
+ var escapedSep = _.str.escapeRegExp(core._t.database.parameters.thousands_sep);
+ value = value.replace(new RegExp(escapedSep, 'g'), '');
+ }
value = value.replace(core._t.database.parameters.decimal_point, '.');
var parsed = Number(value);
if (isNaN(parsed)) {
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 10d72a1398d..a5f9b443ae0 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -88,6 +88,7 @@ var FieldMany2One = AbstractField.extend({
'click .o_external_button': '_onExternalButtonClick',
'click': '_onClick',
}),
+ AUTOCOMPLETE_DELAY: 200,
init: function () {
this._super.apply(this, arguments);
@@ -147,6 +148,11 @@ var FieldMany2One = AbstractField.extend({
});
},
select: function (event, ui) {
+ // we do not want the select event to trigger any additional
+ // effect, such as navigating to another field.
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
var item = ui.item;
self.floating = false;
if (item.id) {
@@ -159,10 +165,17 @@ var FieldMany2One = AbstractField.extend({
focus: function (event) {
event.preventDefault(); // don't automatically select values on focus
},
+ close: function (event) {
+ // it is necessary to prevent ESC key from propagating to field
+ // root, to prevent unwanted discard operations.
+ if (event.which === $.ui.keyCode.ESCAPE) {
+ event.stopPropagation();
+ }
+ },
autoFocus: true,
html: true,
minLength: 0,
- delay: 200,
+ delay: this.AUTOCOMPLETE_DELAY,
});
this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
this.autocomplete_bound = true;
@@ -250,6 +263,7 @@ var FieldMany2One = AbstractField.extend({
*/
_reset: function () {
this._super.apply(this, arguments);
+ this.floating = false;
this.m2o_value = field_utils.format.many2one(this.value);
},
/**
@@ -427,6 +441,7 @@ var FieldMany2One = AbstractField.extend({
view_id: view_id,
readonly: !self.can_write,
on_saved: function () {
+ self._setValue(self.value.data, {forceChange: true});
self.trigger_up('reload', {db_id: self.value.id});
},
}).open();
@@ -462,6 +477,14 @@ var FieldMany2One = AbstractField.extend({
}
},
/**
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this.floating = false;
+ this._super.apply(this, arguments);
+ },
+ /**
* Stops the left/right navigation move event if the cursor is not at the
* start/end of the input element. Stops any navigation move event if the
* user is selecting text.
@@ -531,6 +554,7 @@ var FieldX2Many = AbstractField.extend({
tagName: 'div',
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
add_record: '_onAddRecord',
+ discard_changes: '_onDiscardChanges',
edit_line: '_onEditLine',
field_changed: '_onFieldChanged',
kanban_record_delete: '_onDeleteRecord',
@@ -740,7 +764,7 @@ var FieldX2Many = AbstractField.extend({
this.renderer.commitChanges(recordID).then(function () { // TODO wrong as no mutex protection
var fieldNames = self.renderer.canBeSaved(recordID);
if (fieldNames.length) {
- self.trigger_up('discard_x2m_changes', {
+ self.trigger_up('discard_changes', {
recordID: recordID,
onSuccess: def.resolve.bind(def),
onFailure: def.reject.bind(def),
@@ -783,6 +807,18 @@ var FieldX2Many = AbstractField.extend({
});
},
/**
+ * When the discard_change event go through this field, we can just decorate
+ * the data with the name of the field. The origin field ignore this
+ * information (it is a subfield in a o2m), and the controller will need to
+ * know which field needs to be handled.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ ev.data.fieldName = this.name;
+ },
+ /**
* Called when the renderer asks to edit a line, in that case simply tells
* him back to toggle the mode of this row.
*
@@ -837,15 +873,30 @@ var FieldX2Many = AbstractField.extend({
* changes; if the row could be saved, we make the row readonly. Otherwise,
* we trigger a new event for the view to tell it to discard the changes
* made to that row.
+ * Note that we do that in the controller mutex to ensure that the check on
+ * the row (whether or not it can be saved) is done once all potential
+ * onchange RPCs are done (those RPCs being executed in the same mutex).
+ * This particular handling is done in this handler, instead of in the
+ * _saveLine function directly, because _saveLine is also called from
+ * the controller (via commitChanges), and in this case, it is already
+ * executed in the mutex.
*
* @private
* @param {OdooEvent} ev
+ * @param {string} ev.recordID
+ * @param {function} ev.onSuccess success callback (see '_saveLine')
+ * @param {function} ev.onFailure fail callback (see '_saveLine')
*/
_onSaveLine: function (ev) {
+ var self = this;
ev.stopPropagation();
- this._saveLine(ev.data.recordID)
- .done(ev.data.onSuccess)
- .fail(ev.data.onFailure);
+ this.trigger_up('mutexify', {
+ action: function () {
+ return self._saveLine(ev.data.recordID)
+ .done(ev.data.onSuccess)
+ .fail(ev.data.onFailure);
+ },
+ });
},
/**
* Adds field name information to the event, so that the view upstream is
@@ -863,6 +914,16 @@ var FieldOne2Many = FieldX2Many.extend({
className: 'o_field_one2many',
supportedFieldTypes: ['one2many'],
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ // boolean used to prevent concurrent record creation
+ this.creatingRecord = false;
+ },
+
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -880,6 +941,7 @@ var FieldOne2Many = FieldX2Many.extend({
var index = self.editable === 'top' ? 0 : self.value.data.length - 1;
var newID = self.value.data[index].id;
self.renderer.editRecord(newID);
+ self.creatingRecord = false;
}
}
});
@@ -933,10 +995,13 @@ var FieldOne2Many = FieldX2Many.extend({
ev.stopPropagation();
if (this.editable) {
- this._setValue({
- operation: 'CREATE',
- position: this.editable,
- });
+ if (!this.creatingRecord) {
+ this.creatingRecord = true;
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable,
+ });
+ }
} else {
var self = this;
this._openFormDialog({
@@ -962,7 +1027,7 @@ var FieldOne2Many = FieldX2Many.extend({
this._openFormDialog({
id: ev.data.id,
- on_saved: this._setValue.bind(this, { operation: 'NOOP' }),
+ on_saved: this._setValue.bind(this, { operation: 'NOOP' }, {}),
readonly: this.mode === 'readonly',
});
},
@@ -1026,11 +1091,15 @@ var FieldMany2Many = FieldX2Many.extend({
* @param {OdooEvent} ev
*/
_onOpenRecord: function (ev) {
+ var self = this;
_.extend(ev.data, {
context: this.record.getContext(this.recordParams),
domain: this.record.getDomain(this.recordParams),
fields_view: this.attrs.views && this.attrs.views.form,
- on_saved: this.trigger_up.bind(this, 'reload', {db_id: ev.data.id}),
+ on_saved: function () {
+ self._setValue({operation: 'TRIGGER_ONCHANGE'}, {forceChange: true});
+ self.trigger_up('reload', {db_id: ev.data.id});
+ },
readonly: this.mode === 'readonly',
string: this.string,
});
@@ -1671,7 +1740,7 @@ var FieldSelection = AbstractField.extend({
},
//--------------------------------------------------------------------------
- // Private
+ // Public
//--------------------------------------------------------------------------
/**
@@ -1681,6 +1750,12 @@ var FieldSelection = AbstractField.extend({
getFocusableElement: function () {
return this.$el.is('select') ? this.$el : $();
},
+ /**
+ * @override
+ */
+ isSet: function () {
+ return this.value !== false;
+ },
//--------------------------------------------------------------------------
// Private
@@ -1710,7 +1785,7 @@ var FieldSelection = AbstractField.extend({
* @private
*/
_renderReadonly: function () {
- this.$el.empty().html(this._formatValue(this.value));
+ this.$el.empty().text(this._formatValue(this.value));
},
//--------------------------------------------------------------------------
@@ -1779,14 +1854,17 @@ var FieldRadio = FieldSelection.extend({
* @override
*/
_renderEdit: function () {
- this.$("input").prop("checked", false);
- var key;
+ var value;
if (this.field.type === 'many2one') {
- key = this.value && this.value.data.id;
+ value = this.value && this.value.data.id;
} else {
- key = this.value;
+ value = this.value;
}
- this.$('input[data-value="' + key + '"]').prop('checked', true);
+ var index = _.findIndex(this.values, function (option) {
+ return option[0] === value;
+ });
+ this.$("input").prop("checked", false);
+ this.$('input[data-index="' + index + '"]').prop('checked', true);
},
//--------------------------------------------------------------------------
@@ -1798,14 +1876,12 @@ var FieldRadio = FieldSelection.extend({
* @param {MouseEvent} event
*/
_onInputClick: function (event) {
- var res_id = $(event.target).data('value');
+ var index = $(event.target).data('index');
+ var value = this.values[index];
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]});
+ this._setValue({id: value[0], display_name: value[1]});
} else {
- this._setValue(res_id);
+ this._setValue(value[0]);
}
},
});
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 1e0187b348e..53e5be8ff6e 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -18,6 +18,8 @@ var _t = core._t;
var BasicController = AbstractController.extend(FieldManagerMixin, {
custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, {
+ discard_changes: '_onDiscardChanges',
+ mutexify: '_onMutexify',
reload: '_onReload',
sidebar_data_asked: '_onSidebarDataAsked',
translate: '_onTranslate',
@@ -247,9 +249,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
var self = this;
var def = $.Deferred();
var reload = function () {
- if (!self.isDestroyed()) {
- self.reload();
- }
+ return self.isDestroyed() ? $.when() : self.reload();
};
record = record || this.model.get(this.handle);
var recordID = record.data.id;
@@ -258,13 +258,17 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
context: record.getContext({additionalContext: attrs.context}),
}),
model: record.model,
- record_id: recordID,
+ res_ids: [recordID],
on_closed: function (reason) {
if (!_.isObject(reason)) {
- reload();
+ reload(reason);
}
},
- on_fail: reload,
+ on_fail: function (reason) {
+ reload().always(function() {
+ def.reject(reason);
+ })
+ },
on_success: def.resolve.bind(def),
});
return this.alive(def);
@@ -306,6 +310,26 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
},
/**
+ * Disables buttons so that they can't be clicked anymore.
+ *
+ * @private
+ */
+ _disableButtons: function () {
+ if (this.$buttons) {
+ this.$buttons.find('button').attr('disabled', true);
+ }
+ },
+ /**
+ * Enables buttons so they can be clicked again.
+ *
+ * @private
+ */
+ _enableButtons: function () {
+ if (this.$buttons) {
+ this.$buttons.find('button').removeAttr('disabled');
+ }
+ },
+ /**
* Returns the new sidebar env
*
* @private
@@ -385,18 +409,19 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
// Note: it is the model's job to do nothing if there is nothing to save
if (this.canBeSaved(recordID)) {
var self = this;
- var def = this.model.save(recordID, { // Save then leave edit mode
+ var saveDef = this.model.save(recordID, { // Save then leave edit mode
reload: options.reload,
savePoint: options.savePoint,
});
if (!options.stayInEdit) {
- def = def.then(function (fieldNames) {
- return self._confirmSave(recordID).then(function () {
+ saveDef = saveDef.then(function (fieldNames) {
+ var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly');
+ return def.then(function () {
return fieldNames;
});
});
}
- return def;
+ return saveDef;
} else {
return $.Deferred().reject(); // Cannot be saved
}
@@ -457,6 +482,31 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
//--------------------------------------------------------------------------
/**
+ * Called when a list element asks to discard the changes made to one of
+ * its rows. It can happen with a x2many (if we are in a form view) or with
+ * a list view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ var recordID = ev.data.recordID;
+ this.discardChanges(recordID)
+ .done(function () {
+ if (self.model.isNew(recordID)) {
+ self._abandonRecord(recordID);
+ }
+ // TODO this will tell the renderer to rerender the widget that
+ // asked for the discard but will unfortunately lose the click
+ // made on another row if any
+ self._confirmChange(self.handle, [ev.data.fieldName], ev)
+ .always(ev.data.onSuccess);
+ })
+ .fail(ev.data.onFailure);
+ },
+ /**
* Forces to save directly the changes if the controller is in readonly,
* because in that case the changes come from widgets that are editable even
* in readonly (e.g. Priority).
@@ -471,6 +521,14 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
FieldManagerMixin._onFieldChanged.apply(this, arguments);
},
/**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.action the function to execute in the mutex
+ */
+ _onMutexify: function (ev) {
+ this.mutex.exec(ev.data.action);
+ },
+ /**
* When a reload event triggers up, we need to reload the full view.
* For example, after a form view dialog saved some data.
*
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 5e5b36472f5..742e59a61c5 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -27,7 +27,6 @@ odoo.define('web.BasicModel', function (require) {
* fieldsInfo: {Object},
* getContext: {function},
* getDomain: {function},
- * getEvalContext: {function},
* getFieldNames: {function},
* groupedBy: {string[]},
* id: {integer},
@@ -228,7 +227,7 @@ var BasicModel = AbstractModel.extend({
options = options || {};
var element = this.localData[id];
var isNew = this.isNew(id);
- var rollback = options.rollback || isNew;
+ var rollback = 'rollback' in options ? options.rollback : isNew;
this._visitChildren(element, function (elem) {
if (rollback && elem._savePoint) {
if (elem._savePoint instanceof Array) {
@@ -284,8 +283,6 @@ var BasicModel = AbstractModel.extend({
* @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
- * @param {boolean} [options.noUnsetNumeric=false] if true, will set numeric
- * values to 0 if not set
* @returns {Object}
*/
get: function (id, options) {
@@ -308,7 +305,6 @@ var BasicModel = AbstractModel.extend({
return env;
}
-
if (element.type === 'record') {
var data = _.extend({}, element.data, element._changes);
@@ -321,13 +317,6 @@ var BasicModel = AbstractModel.extend({
if (!field) {
continue;
}
- if (options.noUnsetNumeric) {
- if (field.type === 'float' ||
- field.type === 'integer' ||
- field.type === 'monetary') {
- data[fieldName] = data[fieldName] || 0;
- }
- }
// get relational datapoint
if (field.type === 'many2one') {
@@ -356,11 +345,11 @@ var BasicModel = AbstractModel.extend({
count: element.count,
data: data,
domain: element.domain.slice(0),
+ evalModifiers: element.evalModifiers,
fields: element.fields,
fieldsInfo: element.fieldsInfo,
getContext: element.getContext,
getDomain: element.getDomain,
- getEvalContext: element.getEvalContext,
getFieldNames: element.getFieldNames,
id: element.id,
limit: element.limit,
@@ -410,7 +399,6 @@ var BasicModel = AbstractModel.extend({
fields: element.fields,
getContext: element.getContext,
getDomain: element.getDomain,
- getEvalContext: element.getEvalContext,
getFieldNames: element.getFieldNames,
groupedBy: element.groupedBy,
id: element.id,
@@ -615,10 +603,12 @@ var BasicModel = AbstractModel.extend({
*
* @param {string} record_id
* @param {Object} changes a map field => new value
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {string[]} list of changed fields
*/
- notifyChanges: function (record_id, changes) {
- return this.mutex.exec(this._applyChange.bind(this, record_id, changes));
+ notifyChanges: function (record_id, changes, viewType) {
+ return this.mutex.exec(this._applyChange.bind(this, record_id, changes, viewType));
},
/**
* Reload all data for a given resource
@@ -644,7 +634,7 @@ var BasicModel = AbstractModel.extend({
return this._makeDefaultRecord(element.model, params);
}
if (!options.keepChanges) {
- this.discardChanges(id);
+ this.discardChanges(id, {rollback: false});
}
}
@@ -788,7 +778,7 @@ var BasicModel = AbstractModel.extend({
_.extend(record.data, _changes);
def.resolve(changedFields);
}
- });
+ }).fail(def.reject.bind(def));
} else {
def.resolve(changedFields);
}
@@ -902,9 +892,11 @@ var BasicModel = AbstractModel.extend({
*
* @param {string} recordID
* @param {Object} changes
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred}
*/
- _applyChange: function (recordID, changes) {
+ _applyChange: function (recordID, changes, viewType) {
var self = this;
var record = this.localData[recordID];
var field;
@@ -916,7 +908,7 @@ var BasicModel = AbstractModel.extend({
for (var fieldName in changes) {
field = record.fields[fieldName];
if (field.type === 'one2many' || field.type === 'many2many') {
- defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName]));
+ defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], viewType));
} else if (field.type === 'many2one') {
defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName]));
} else {
@@ -937,7 +929,7 @@ var BasicModel = AbstractModel.extend({
}
var onchangeDef;
if (onChangeFields.length) {
- onchangeDef = self._performOnChange(record, onChangeFields).then(function (result) {
+ onchangeDef = self._performOnChange(record, onChangeFields, viewType).then(function (result) {
delete record._warning;
return _.keys(changes).concat(Object.keys(result && result.value || {}));
});
@@ -976,6 +968,17 @@ var BasicModel = AbstractModel.extend({
record._changes[fieldName] = false;
return $.when();
}
+
+ // here, we check that the many2one really changed. If the res_id is the
+ // same, we do not need to do any extra work. It can happen when the
+ // user edited a manyone (with the small form view button) with an
+ // onchange. In that case, the onchange is triggered, but the actual
+ // value did not change.
+ var relatedID = (record._changes && record._changes[fieldName]) || record.data[fieldName];
+ var relatedRecord = this.localData[relatedID];
+ if (relatedRecord && (data.id === this.localData[relatedID].res_id)) {
+ return $.when();
+ }
var rel_data = _.pick(data, 'id', 'display_name');
var def;
if (rel_data.display_name === undefined) {
@@ -1093,14 +1096,8 @@ var BasicModel = AbstractModel.extend({
});
defs.push(def);
}
- } else if (field.type === 'date') {
- // process data: convert into a moment instance
- record._changes[name] = fieldUtils.parse.date(val);
- } else if (field.type === 'datetime') {
- // process datetime: convert into a moment instance
- record._changes[name] = fieldUtils.parse.datetime(val);
} else {
- record._changes[name] = val;
+ record._changes[name] = self._parseServerValue(field, val);
}
});
return $.when.apply($, defs);
@@ -1115,13 +1112,23 @@ var BasicModel = AbstractModel.extend({
* @param {string} fieldName
* @param {Object} command A command object. It should have a 'operation'
* key. For example, it looks like {operation: ADD, id: 'partner_1'}
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred}
*/
- _applyX2ManyChange: function (record, fieldName, command) {
+ _applyX2ManyChange: function (record, fieldName, command, viewType) {
+ if (command.operation === 'TRIGGER_ONCHANGE') {
+ // the purpose of this operation is to trigger an onchange RPC, so
+ // there is no need to apply any change on the record (the changes
+ // have probably been already applied and saved, usecase: many2many
+ // edition in a dialog)
+ return $.when();
+ }
+
var self = this;
var list = this.localData[record._changes[fieldName] || record.data[fieldName]];
var field = record.fields[fieldName];
- var fieldInfo = record.fieldsInfo[record.viewType][fieldName];
+ var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName];
var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
var rec;
var defs = [];
@@ -1144,6 +1151,7 @@ var BasicModel = AbstractModel.extend({
newRecord.fieldsInfo = list.fieldsInfo;
newRecord.viewType = list.viewType;
list._changes.push(newRecord.id);
+ this._sortList(list);
break;
case 'ADD_M2M':
// force to use link command instead of create command
@@ -1163,6 +1171,7 @@ var BasicModel = AbstractModel.extend({
});
list_records[d.id] = rec;
list._changes.push(rec.id);
+ self._sortList(list);
});
// 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
@@ -1243,22 +1252,65 @@ var BasicModel = AbstractModel.extend({
* @see _performOnChange
*
* @param {Object} record resource object of type 'record'
- * @returns {Object} an onchange spec
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @returns {Object|false} an onchange spec, or false if no onchange should
+ * be applied
*/
- _buildOnchangeSpecs: function (record) {
- // TODO: replace this function by some generic tree function in utils
+ _buildOnchangeSpecs: function (record, viewType) {
+ var hasOnchange = false;
var specs = {};
- _.each(record.getFieldNames(), function (name) {
+ var fieldsInfo = record.fieldsInfo[viewType || record.viewType];
+
+ _.each(Object.keys(fieldsInfo), function (name) {
var field = record.fields[name];
- var fieldInfo = record.fieldsInfo[record.viewType][name];
+ var fieldInfo = fieldsInfo[name];
specs[name] = (field.onChange) || "";
+ if (field.onChange) {
+ hasOnchange = true;
+ }
_.each(fieldInfo.views, function (view) {
_.each(view.fieldsInfo[view.type], function (field, subname) {
- specs[name + '.' + subname] = (view.fields[subname].onChange) || "";
+ var onChange = view.fields[subname].onChange;
+ specs[name + '.' + subname] = onChange || "";
+ if (onChange) {
+ hasOnchange = true;
+ }
});
});
});
- return specs;
+ return hasOnchange ? specs : false;
+ },
+ /**
+ * Evaluate modifiers
+ *
+ * @private
+ * @param {Object} element a valid element object, which will serve as eval
+ * context.
+ * @param {Object} modifiers
+ * @returns {Object}
+ */
+ _evalModifiers: function (element, modifiers) {
+ var result = {};
+ var self = this;
+ var evalContext;
+ function evalModifier (mod) {
+ if (mod === undefined || mod === false || mod === true) {
+ return !!mod;
+ }
+ evalContext = evalContext || self._getEvalContext(element);
+ return new Domain(mod, evalContext).compute(evalContext);
+ }
+ if ('invisible' in modifiers) {
+ result.invisible = evalModifier(modifiers.invisible);
+ }
+ if ('readonly' in modifiers) {
+ result.readonly = evalModifier(modifiers.readonly);
+ }
+ if ('required' in modifiers) {
+ result.required = evalModifier(modifiers.required);
+ }
+ return result;
},
/**
* Fetch all name_gets for the many2ones in a group
@@ -1899,8 +1951,8 @@ var BasicModel = AbstractModel.extend({
// generate update commands for records that have been
// updated (it may happen with editable lists)
_.each(relData, function (relRecord) {
- if (!_.isEmpty(relRecord._changes)) {
- var changes = self._generateChanges(relRecord);
+ var changes = self._generateChanges(relRecord);
+ if (!_.isEmpty(changes)) {
var command = x2ManyCommands.update(relRecord.res_id, changes);
commands[fieldName].push(command);
}
@@ -1918,8 +1970,8 @@ var BasicModel = AbstractModel.extend({
if (_.contains(keptIds, relIds[i])) {
// this is an id that already existed
relRecord = _.findWhere(relData, {res_id: relIds[i]});
- if (!_.isEmpty(relRecord._changes)) {
- changes = this._generateChanges(relRecord);
+ changes = this._generateChanges(relRecord);
+ if (!_.isEmpty(changes)) {
command = x2ManyCommands.update(relRecord.res_id, changes);
didChange = true;
} else {
@@ -2046,19 +2098,24 @@ var BasicModel = AbstractModel.extend({
* @returns {Object}
*/
_getEvalContext: function (element) {
- var evalContext = this.get(element.id, {raw: true, noUnsetNumeric: true}).data;
- evalContext.active_model = element.model;
- evalContext.id = evalContext.id || false;
- evalContext.active_id = evalContext.id;
- evalContext.active_ids = evalContext.id ? [evalContext.id] : [];
+ var evalContext = element.type === 'record' ? this._getRecordEvalContext(element) : {};
+
if (element.parentID) {
- var parent = this.get(element.parentID, {raw: true});
- if (parent.type === 'list' && this.localData[element.parentID].parentID) {
- parent = this.get(this.localData[element.parentID].parentID, {raw: true});
+ var parent = this.localData[element.parentID];
+ if (parent.type === 'list' && parent.parentID) {
+ parent = this.localData[parent.parentID];
+ }
+ if (parent.type === 'record') {
+ evalContext.parent = this._getRecordEvalContext(parent);
}
- _.extend(evalContext, {parent: parent.data});
}
- return _.extend({}, session.user_context, element.context, evalContext);
+ return _.extend({
+ active_id: evalContext.id || false,
+ active_ids: evalContext.id ? [evalContext.id] : [],
+ active_model: element.model,
+ current_date: moment().format('YYYY-MM-DD'),
+ id: evalContext.id || false,
+ }, session.user_context, element.context, evalContext);
},
/**
* Returns the list of field names of the given element according to its
@@ -2072,6 +2129,67 @@ var BasicModel = AbstractModel.extend({
return Object.keys(fieldsInfo && fieldsInfo[element.viewType] || {});
},
/**
+ * Evaluate the record evaluation context. This method is supposed to be
+ * called by _getEvalContext. It basically only generates a dictionary of
+ * current values for the record, with commands for x2manys fields.
+ *
+ * @param {Object} record an element of type 'record'
+ * @returns Object
+ */
+ _getRecordEvalContext: function (record) {
+ var self = this;
+ var relDataPoint;
+ var context = _.extend({}, record.data, record._changes);
+ for (var fieldName in context) {
+ var field = record.fields[fieldName];
+ if (context[fieldName] === null) {
+ context[fieldName] = false;
+ }
+ if (!field) {
+ continue;
+ }
+ if (field.type === 'float' ||
+ field.type === 'integer' ||
+ field.type === 'monetary') {
+ context[fieldName] = context[fieldName] || 0;
+ continue;
+ }
+ if (field.type === 'date' || field.type === 'datetime') {
+ if (context[fieldName]) {
+ context[fieldName] = JSON.parse(JSON.stringify(context[fieldName]));
+ }
+ continue;
+ }
+ if (field.type === 'many2one') {
+ relDataPoint = this.localData[context[fieldName]];
+ context[fieldName] = relDataPoint ? relDataPoint.res_id : false;
+ continue;
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ relDataPoint = this.localData[context[fieldName]];
+ var relData = relDataPoint._changes || relDataPoint.data;
+ var ids = _.map(relData, function (id) {
+ return self.localData[id].res_id;
+ });
+
+ ids.toJSON = function () {
+ return _.map(relData, function (id) {
+ var resID = self.localData[id].res_id;
+ if (typeof resID === 'string') {
+ var changes = self._generateChanges(self.localData[id]);
+ return x2ManyCommands.create(changes);
+ } else {
+ return x2ManyCommands.link_to(resID);
+ }
+ });
+ };
+ context[fieldName] = ids;
+ }
+
+ }
+ return context;
+ },
+ /**
* Returns true iff value is considered to be set for the given field's type.
*
* @private
@@ -2104,11 +2222,17 @@ var BasicModel = AbstractModel.extend({
_isX2ManyValid: function (id) {
var self = this;
var isValid = true;
- var element = this.get(id, {raw: true});
- _.each(element.getFieldNames(), function (fieldName) {
- var field = element.fields[fieldName];
- _.each(element.data, function (rec) {
- if (field.required && !self._isFieldSet(rec.data[fieldName], field.type)) {
+ var element = this.localData[id];
+ _.each(element._changes || element.data, function (recordID) {
+ var recordData = self.get(recordID, {raw: true}).data;
+ var record = self.localData[recordID];
+ _.each(element.getFieldNames(), function (fieldName) {
+ var field = element.fields[fieldName];
+ var fieldInfo = element.fieldsInfo[element.viewType][fieldName];
+ var rawModifiers = JSON.parse(fieldInfo.modifiers || "{}");
+ var modifiers = self._evalModifiers(record, rawModifiers);
+ var required = 'required' in modifiers ? modifiers.required : field.required;
+ if (required && !self._isFieldSet(recordData[fieldName], field.type)) {
isValid = false;
}
});
@@ -2196,9 +2320,9 @@ var BasicModel = AbstractModel.extend({
viewType: params.viewType,
};
+ dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint);
dataPoint.getContext = this._getContext.bind(this, dataPoint);
dataPoint.getDomain = this._getDomain.bind(this, dataPoint);
- dataPoint.getEvalContext = this._getEvalContext.bind(this, dataPoint);
dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint);
this.localData[dataPoint.id] = dataPoint;
@@ -2300,60 +2424,48 @@ var BasicModel = AbstractModel.extend({
record._changes[name] = x2manyList.id;
var many2ones = {};
var r;
+ var isCommandList = result[name].length && _.isArray(result[name][0]);
+ if (!isCommandList) {
+ result[name] = [[6, false, result[name]]];
+ }
_.each(result[name], function (value) {
- if (_.isArray(value)) {
- // value is a command
- if (value[0] === 0) {
- // CREATE
- r = self._makeDataPoint({
- modelName: x2manyList.model,
- context: x2manyList.context,
- fieldsInfo: fieldsInfo,
- fields: fields,
- viewType: viewType,
- });
- x2manyList._changes = x2manyList._changes || [];
- x2manyList._changes.push(r.id);
-
- // this is necessary so the fields are initialized
- _.each(r.getFieldNames(), function (fieldName) {
- r.data[fieldName] = null;
- });
-
- r._changes = _.defaults(value[2], r.data);
- for (var name in r._changes) {
- var isFieldInView = name in r.fields;
- if (isFieldInView && 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 (value[0] === 6) {
- // REPLACE_WITH
- x2manyList.res_ids = value[2];
- x2manyList.count = x2manyList.res_ids.length;
- defs.push(self._readUngroupedList(x2manyList));
- }
- } else {
- // value is an id
+ // value is a command
+ if (value[0] === 0) {
+ // CREATE
r = self._makeDataPoint({
modelName: x2manyList.model,
context: x2manyList.context,
fieldsInfo: fieldsInfo,
fields: fields,
- res_id: value,
viewType: viewType,
});
- if (!x2manyList._changes) {
- x2manyList._changes = [];
- }
+ x2manyList._changes = x2manyList._changes || [];
x2manyList._changes.push(r.id);
+
+ // this is necessary so the fields are initialized
+ _.each(r.getFieldNames(), function (fieldName) {
+ r.data[fieldName] = null;
+ });
+
+ r._changes = _.defaults(value[2], r.data);
+ for (var name in r._changes) {
+ var isFieldInView = name in r.fields;
+ if (isFieldInView && 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 (value[0] === 6) {
+ // REPLACE_WITH
+ x2manyList.res_ids = value[2];
+ x2manyList.count = x2manyList.res_ids.length;
+ defs.push(self._readUngroupedList(x2manyList));
}
});
@@ -2361,35 +2473,17 @@ var BasicModel = AbstractModel.extend({
_.each(_.keys(many2ones), function (name) {
defs.push(self._fetchNameGets(x2manyList, name));
});
- } else if (field.type === 'date') {
- // process date: convert into a moment instance
- record._changes[name] = fieldUtils.parse.date(result[name], field, {isUTC: true});
- } else if (field.type === 'datetime') {
- // process datetime: convert into a moment instance
- record._changes[name] = fieldUtils.parse.datetime(result[name], field, {isUTC: true});
} else {
- record._changes[name] = result[name];
+ record._changes[name] = self._parseServerValue(field, 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;
+ return self._performOnChange(record, fields_key).then(function () {
+ if (record._warning) {
+ return $.Deferred().reject();
}
- }
- if (shouldApplyOnchange) {
- return self._performOnChange(record, fields_key).then(function () {
- if (record._warning) {
- return $.Deferred().reject();
- }
- });
- } else {
- return $.when();
- }
+ });
})
.then(function () {
return self._fetchRelationalData(record);
@@ -2438,27 +2532,52 @@ var BasicModel = AbstractModel.extend({
// no value for the many2one
record[fieldName] = false;
}
- } else if (field.type === 'date') {
- // process data: convert into a moment instance
- record[fieldName] = fieldUtils.parse.date(val, field, {isUTC: true});
- } else if (field.type === 'datetime') {
- // process datetime: convert into a moment instance
- record[fieldName] = fieldUtils.parse.datetime(val, field, {isUTC: true});
+ } else {
+ record[fieldName] = self._parseServerValue(field, val);
}
});
},
/**
+ * Processes date(time) and selection field values sent by the server.
+ * Converts data(time) values to moment instances.
+ * Converts false values of selection fields to 0 if 0 is a valid key,
+ * because the server doesn't make a distinction between false and 0, and
+ * always sends false when value is 0.
+ *
+ * @param {Object} field the field description
+ * @param {*} value
+ * @returns {*} the processed value
+ */
+ _parseServerValue: function (field, value) {
+ if (field.type === 'date' || field.type === 'datetime') {
+ // process date(time): convert into a moment instance
+ value = fieldUtils.parse[field.type](value, field, {isUTC: true});
+ } else if (field.type === 'selection' && value === false) {
+ // process selection: convert false to 0, if 0 is a valid key
+ var hasKey0 = _.find(field.selection, function (option) {
+ return option[0] === 0;
+ });
+ value = hasKey0 ? 0 : value;
+ }
+ return value;
+ },
+ /**
* 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
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred} The returned deferred can fail, in which case the
* fail value will be the warning message received from the server
*/
- _performOnChange: function (record, fields) {
+ _performOnChange: function (record, fields, viewType) {
var self = this;
- var onchange_spec = this._buildOnchangeSpecs(record);
+ var onchangeSpec = this._buildOnchangeSpecs(record, viewType);
+ if (!onchangeSpec) {
+ return $.when();
+ }
var idList = record.data.id ? [record.data.id] : [];
var options = {
full: true,
@@ -2474,7 +2593,7 @@ var BasicModel = AbstractModel.extend({
return self._rpc({
model: record.model,
method: 'onchange',
- args: [idList, currentData, fields, onchange_spec, context],
+ args: [idList, currentData, fields, onchangeSpec, context],
})
.then(function (result) {
if (!record._changes) {
@@ -2580,7 +2699,7 @@ var BasicModel = AbstractModel.extend({
var choice = _.find(list.fields[rawGroupBy].selection, function (c) {
return c[0] === value;
});
- value = choice[1];
+ value = choice ? choice[1] : false;
}
var newGroup = self._makeDataPoint({
modelName: list.model,
@@ -2595,6 +2714,7 @@ var BasicModel = AbstractModel.extend({
orderedBy: list.orderedBy,
limit: list.limit,
openGroupByDefault: list.openGroupByDefault,
+ parentID: list.id,
type: 'list',
viewType: list.viewType,
});
@@ -2787,9 +2907,12 @@ var BasicModel = AbstractModel.extend({
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;
+ var data = list._changes || list.data;
+ data.sort(function (id1, id2) {
+ var r1 = self.localData[id1];
+ var r2 = self.localData[id2];
+ var data1 = r1._changes || r1.data;
+ var data2 = r2._changes || r2.data;
if (data1[order.name] < data2[order.name]) {
return order.asc ? -1 : 1;
}
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index 907ea7ddc07..8bbf9ae45a4 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -91,7 +91,7 @@ var BasicRenderer = AbstractRenderer.extend({
var record = state.id === id ? state : _.findWhere(state.data, {id: id});
if (!record) {
- return this._render();
+ return this._render().then(_.constant([]));
}
var defs = [];
@@ -269,22 +269,6 @@ var BasicRenderer = AbstractRenderer.extend({
return widget.isValid() && (widget.isSet() || !modifiers.required);
},
/**
- * Updates the modifiers evaluation associated to a given modifiers data and
- * a given record. This only updates the modifiers values. To see associated
- * DOM updates: @see _updateAllModifiers @see _applyModifiers.
- *
- * @private
- * @param {Object} modifiersData
- * @param {Object} record
- */
- _computeModifiers: function (modifiersData, record) {
- var evalContext = record.evalContext;
- modifiersData.evaluatedModifiers[record.id]
- = _.mapObject(modifiersData.modifiers, function (modifier) {
- return new Domain(modifier, evalContext).compute(evalContext);
- });
- },
- /**
* Destroys a given widget associated to the given record and removes it
* from internal referencing.
*
@@ -342,7 +326,6 @@ var BasicRenderer = AbstractRenderer.extend({
* record. This allows nodes that will produce an AbstractField instance
* to have their modifiers registered before this field creation as we
* need the readonly modifier to be able to instantiate the AbstractField.
- * (@see _computeModifiers).
*
* - On additional registrations, if the node was already registered but the
* record is different, we evaluate the modifiers for this record and
@@ -399,7 +382,7 @@ var BasicRenderer = AbstractRenderer.extend({
// Evaluate if necessary
if (!modifiersData.evaluatedModifiers[record.id]) {
- this._computeModifiers(modifiersData, record);
+ modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
}
// Element might not be given yet (a second call to the function can
@@ -569,7 +552,6 @@ var BasicRenderer = AbstractRenderer.extend({
* 2) Updates the rendering of the view elements associated to the given
* record to match the new modifiers.
*
- * @see _computeModifiers
* @see _applyModifiers
*
* @private
@@ -582,7 +564,7 @@ var BasicRenderer = AbstractRenderer.extend({
var defs = [];
this.defs = defs; // Potentially filled by widget rerendering
_.each(this.allModifiersData, function (modifiersData) {
- self._computeModifiers(modifiersData, record);
+ modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
self._applyModifiers(modifiersData, record);
});
delete this.defs;
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index e38b7b4584e..736416dea27 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -67,21 +67,36 @@ var BasicView = AbstractView.extend({
var viewType = this.viewType;
var viewFields = Object.keys(record.fieldsInfo[viewType]);
var fieldNames = _.difference(viewFields, Object.keys(record.data));
+ var fieldsInfo = record.fieldsInfo[viewType];
+
// Suppose that in a form view, there is an x2many list view with
- // an x2many field F of the related record, and that F is also
- // displayed in the x2many form view (e.g. as a list or a kanban).
- // In this case, F is represented in record.data
- // (as it is known by the x2many list view), but its related fields
- // (those displayed in the list or kanban) still need to be fetched.
+ // a field F, and that F is also displayed in the x2many form view.
+ // In this case, F is represented in record.data (as it is known by
+ // the x2many list view), but the loaded information may not suffice
+ // in the form view (e.g. if field is a many2many list in the form
+ // view, or if it is displayed by a widget requiring specialData).
// So when this happens, F is added to the list of fieldNames to fetch.
_.each(viewFields, function (name) {
if (!_.contains(fieldNames, name)) {
var fieldType = record.fields[name].type;
+ var fieldInfo = fieldsInfo[name];
+
+ // SpecialData case: field requires specialData that haven't
+ // been fetched yet.
+ if (fieldInfo.Widget) {
+ var requiresSpecialData = fieldInfo.Widget.prototype.specialData;
+ if (requiresSpecialData && !(name in record.specialData)) {
+ fieldNames.push(name);
+ return;
+ }
+ }
+
+ // X2Many case: field is an x2many displayed as a list or
+ // kanban view, but the related fields haven't been loaded yet.
if ((fieldType === 'one2many' || fieldType === 'many2many')) {
if (!('fieldsInfo' in record.data[name])) {
fieldNames.push(name);
} else {
- var fieldInfo = record.fieldsInfo[viewType][name];
var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {};
var fieldViewTypes = Object.keys(fieldViews);
var recordViewTypes = Object.keys(record.data[name].fieldsInfo);
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
index 64eae1f746a..939e44446a8 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -16,7 +16,7 @@ var scales = [
];
function dateToServer (date) {
- return date.clone().utc().format('YYYY-MM-DD HH:mm:ss');
+ return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss');
}
return AbstractModel.extend({
diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js
index ceedbb2df6e..9578e568558 100644
--- a/addons/web/static/src/js/views/field_manager_mixin.js
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -43,7 +43,7 @@ var FieldManagerMixin = {
*/
_applyChanges: function (dataPointID, changes, event) {
var self = this;
- return this.model.notifyChanges(dataPointID, changes)
+ return this.model.notifyChanges(dataPointID, changes, event.data.viewType)
.then(function (result) {
if (event.data.force_save) {
return self.model.save(dataPointID).then(function () {
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 4239edbb199..ce449f2dcc9 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -15,7 +15,6 @@ var FormController = BasicController.extend({
open_one2many_record: '_onOpenOne2ManyRecord',
bounce_edit: '_onBounceEdit',
button_clicked: '_onButtonClicked',
- discard_x2m_changes: '_onDiscardX2MChanges',
open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder',
}),
@@ -51,9 +50,11 @@ var FormController = BasicController.extend({
* This method switches the form view in edit mode, with a new record.
*
* @todo make record creation a basic controller feature
+ * @param {string} [parentID] if given, the parentID will be used as parent
+ * for the new record.
* @returns {Deferred}
*/
- createRecord: function () {
+ createRecord: function (parentID) {
var self = this;
var record = this.model.get(this.handle, {raw: true});
return this.model.load({
@@ -61,6 +62,7 @@ var FormController = BasicController.extend({
fields: record.fields,
fieldsInfo: record.fieldsInfo,
modelName: this.modelName,
+ parentID: parentID,
res_ids: record.res_ids,
type: 'record',
viewType: 'form',
@@ -231,6 +233,26 @@ var FormController = BasicController.extend({
}
},
/**
+ * Override to disable buttons in the renderer.
+ *
+ * @override
+ * @private
+ */
+ _disableButtons: function () {
+ this._super.apply(this, arguments);
+ this.renderer.disableButtons();
+ },
+ /**
+ * Override to enable buttons in the renderer.
+ *
+ * @override
+ * @private
+ */
+ _enableButtons: function () {
+ this._super.apply(this, arguments);
+ this.renderer.enableButtons();
+ },
+ /**
* Hook method, called when record(s) has been deleted.
*
* @override
@@ -325,6 +347,8 @@ var FormController = BasicController.extend({
var self = this;
var def;
+ this._disableButtons();
+
var attrs = event.data.attrs;
if (attrs.confirm) {
var d = $.Deferred();
@@ -349,15 +373,14 @@ var FormController = BasicController.extend({
return self._callButtonAction(attrs, record);
});
}
- def.then(function () {
- self.reload();
- });
if (event.data.show_wow) {
def.then(function () {
self.show_wow();
});
}
+
+ def.always(this._enableButtons.bind(this));
},
/**
* Called when the user wants to create a new record -> @see createRecord
@@ -377,32 +400,6 @@ var FormController = BasicController.extend({
this.discardChanges();
},
/**
- * Called when a x2m asks to discard the changes made to one of its row.
- *
- * @todo find a better way to handle this... this could also be used outside
- * of form views
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onDiscardX2MChanges: function (ev) {
- var self = this;
- ev.stopPropagation();
- var recordID = ev.data.recordID;
- this.discardChanges(recordID)
- .done(function () {
- if (self.model.isNew(recordID)) {
- self._abandonRecord(recordID);
- }
- // TODO this will tell the renderer to rerender the widget that
- // asked for the discard but will unfortunately lose the click
- // made on another row if any
- self._confirmChange(self.handle, [ev.target.name], ev)
- .always(ev.data.onSuccess);
- })
- .fail(ev.data.onFailure);
- },
- /**
* Called when the user clicks on 'Duplicate Record' in the sidebar
*
* @private
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index a2c0d2129ab..ddd77ee5dc6 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -84,10 +84,27 @@ var FormRenderer = BasicRenderer.extend({
_.each(resetWidgets, function (widget) {
self._setIDForLabel(widget, self.idsForLabels[widget.name]);
});
+ if (self.$('.o_field_invalid').length) {
+ self.canBeSaved(self.state.id);
+ }
return resetWidgets;
});
},
/**
+ * Disable stat buttons so that they can't be clicked anymore
+ *
+ */
+ disableButtons: function() {
+ this.$('.oe_button_box button').attr('disabled', true);
+ },
+ /**
+ * Enable stat buttons so they can be clicked again
+ *
+ */
+ enableButtons: function() {
+ this.$('.oe_button_box button').removeAttr('disabled');
+ },
+ /**
* returns the active tab pages for each notebook
*
* @todo currently, this method is unused...
@@ -679,10 +696,12 @@ var FormRenderer = BasicRenderer.extend({
},
});
});
- return $('<div class="o_notebook">')
+ var $notebook = $('<div class="o_notebook">')
.data('name', node.attrs.name || '_default_')
- .append($headers)
- .append($pages);
+ .append($headers, $pages);
+ this._registerModifiers(node, this.state, $notebook);
+ this._handleAttributes($notebook, node);
+ return $notebook;
},
/**
* @private
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 54e078ab368..b5c60fe9ab3 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -131,7 +131,8 @@ var KanbanController = BasicController.extend({
_updateButtons: function () {
if (this.$buttons) {
var data = this.model.get(this.handle, {raw: true});
- var createMuted = data.count === 0 && this.createColumnEnabled;
+ var grouped = data.groupedBy.length;
+ var createMuted = grouped && data.data.length === 0 && this.createColumnEnabled;
this.$buttons.find('.o-kanban-button-new')
.toggleClass('btn-primary', !createMuted)
.toggleClass('btn-default', createMuted);
@@ -260,7 +261,7 @@ var KanbanController = BasicController.extend({
this.trigger_up('execute_action', {
action_data: attrs,
model: record.model,
- record_id: record.res_id,
+ res_ids: [record.res_id],
on_closed: function () {
self.model.reload(record.id).then(function (db_id) {
var data = self.model.get(db_id);
@@ -328,6 +329,7 @@ var KanbanController = BasicController.extend({
return self.model
.addRecordToGroup(columnState.id, records[0])
.then(function (db_id) {
+ self._updateEnv();
column.addRecord(self.model.get(db_id), {position: 'before'});
});
}
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index f3d4d5b3d91..c606776ccc0 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -31,12 +31,20 @@ var KanbanModel = BasicModel.extend({
viewType: group.viewType,
});
group.data.unshift(new_record.id);
+ group.res_ids.unshift(resId);
group.count++;
- return this._fetchRecord(new_record)
- // .then(this._fetch_relational_data.bind(this))
- .then(function (result) {
- return result.id;
- });
+
+ // update the res_ids and count of the parent
+ var self = this;
+ var parent = this.localData[group.parentID];
+ parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) {
+ return self.localData[dataPointID].res_ids;
+ }));
+ parent.count++;
+
+ return this._fetchRecord(new_record).then(function (result) {
+ return result.id;
+ });
},
/**
* Creates a new group from a name (performs a name_create).
@@ -78,7 +86,6 @@ var KanbanModel = BasicModel.extend({
// newGroup.is_open = true;
parent.data.push(newGroup.id);
- parent.count++;
return newGroup.id;
});
},
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 21e29780d09..7c83022cd60 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -64,7 +64,10 @@ var ListController = BasicController.extend({
return $.when();
}
}
- return this._super(recordID);
+ var self = this;
+ return this._super(recordID).then(function () {
+ self._updateButtons('readonly');
+ });
},
/**
* Calculate the active domain of the list view. This should be done only
@@ -89,7 +92,8 @@ var ListController = BasicController.extend({
contexts: [userContext].concat(searchData.contexts),
group_by_seq: searchData.groupbys || []
});
- return $.when(self.dataset.domain.concat(results.domain || []));
+ var record = self.model.get(self.handle, {raw: true});
+ return $.when(record.getDomain().concat(results.domain || []));
} else {
return $.Deferred().resolve();
}
@@ -190,19 +194,25 @@ var ListController = BasicController.extend({
}
},
/**
- * Add a record to the list
+ * Adds a record to the list.
+ * Disables the buttons to prevent concurrent record creation or edition.
*
* @todo make record creation a basic controller feature
* @private
*/
_addRecord: function () {
var self = this;
- this.model.addDefaultRecord(this.handle, {position: this.editable}).then(function (recordID) {
+ this._disableButtons();
+ return this.renderer.unselectRow().then(function () {
+ return self.model.addDefaultRecord(self.handle, {
+ position: self.editable,
+ });
+ }).then(function (recordID) {
self._toggleNoContentHelper(false);
var state = self.model.get(self.handle);
self.renderer.updateState(state, {});
self.renderer.editRecord(recordID);
- });
+ }).always(this._enableButtons.bind(this));
},
/**
* Archive the current selection
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
index a857fee7103..a037c12a96e 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -84,6 +84,26 @@ ListRenderer.include({
return this._super(recordID);
},
/**
+ * We need to override the confirmChange method from BasicRenderer to
+ * reevaluate the row decorations. Since they depends on the current value
+ * of the row, they might have changed between each edit.
+ *
+ * @override
+ */
+ confirmChange: function (state, id) {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (widgets) {
+ if (widgets.length) {
+ var rowIndex = _.findIndex(state.data, function (r) {
+ return r.id === id;
+ });
+ var $row = self.$('.o_data_row:nth(' + rowIndex + ')');
+ self._setDecorationClasses(state.data[rowIndex], $row);
+ }
+ return widgets;
+ });
+ },
+ /**
* Edit a given record in the list
*
* @param {string} recordID
@@ -113,13 +133,15 @@ ListRenderer.include({
*/
removeLine: function (state, recordID) {
var rowIndex = _.findIndex(this.state.data, {id: recordID});
+ this.state = state;
+ if (rowIndex === -1) {
+ return;
+ }
if (rowIndex === this.currentRow) {
this.currentRow = null;
}
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
$row.remove();
-
- this.state = state;
},
/**
* Updates the already rendered row associated to the given recordID so that
@@ -202,6 +224,44 @@ ListRenderer.include({
return $.when.apply($, defs);
},
+ /**
+ * This method is called whenever we click/move outside of a row that was
+ * in edit mode. This is the moment we save all accumulated changes on that
+ * row, if needed (@see BasicController.saveRecord).
+ *
+ * Note that we have to disable the focusable elements (inputs, ...) to
+ * prevent subsequent editions. These edits would be lost, because the list
+ * view only saves records when unselecting a row.
+ *
+ * @returns {Deferred} The deferred resolves if the row was unselected (and
+ * possibly removed). If may be rejected, when the row is dirty and the
+ * user refuses to discard its changes.
+ */
+ unselectRow: function () {
+ // Protect against calling this method when no row is selected
+ if (this.currentRow === null) {
+ return $.when();
+ }
+
+ var record = this.state.data[this.currentRow];
+ var recordWidgets = this.allFieldWidgets[record.id];
+ toggleWidgets(true);
+
+ var def = $.Deferred();
+ this.trigger_up('save_line', {
+ recordID: record.id,
+ onSuccess: def.resolve.bind(def),
+ onFailure: def.reject.bind(def),
+ });
+ return def.fail(toggleWidgets.bind(null, false));
+
+ function toggleWidgets(disabled) {
+ _.each(recordWidgets, function (widget) {
+ var $el = widget.getFocusableElement();
+ $el.prop('disabled', disabled);
+ });
+ }
+ },
//--------------------------------------------------------------------------
// Private
@@ -241,7 +301,7 @@ ListRenderer.include({
if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.columns.length - 1);
} else {
- this._unselectRow().then(this.trigger_up.bind(this, 'add_record'));
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record'));
}
},
/**
@@ -251,10 +311,16 @@ ListRenderer.include({
* @private
*/
_moveToNextLine: function () {
+ var record = this.state.data[this.currentRow];
+ var fieldNames = this.canBeSaved(record.id);
+ if (fieldNames.length) {
+ return;
+ }
+
if (this.currentRow < this.state.data.length - 1) {
this._selectCell(this.currentRow + 1, 0);
} else {
- this._unselectRow().then(this.trigger_up.bind(this, 'add_record'));
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record'));
}
},
/**
@@ -399,7 +465,7 @@ ListRenderer.include({
// To select a row, the currently selected one must be unselected first
var self = this;
- return this._unselectRow().then(function () {
+ return this.unselectRow().then(function () {
// Notify the controller we want to make a record editable
var record = self.state.data[rowIndex];
var def = $.Deferred();
@@ -410,44 +476,6 @@ ListRenderer.include({
return def;
});
},
- /**
- * This method is called whenever we click/move outside of a row that was
- * in edit mode. This is the moment we save all accumulated changes on that
- * row, if needed (@see BasicController.saveRecord).
- *
- * Note that we have to disable the focusable elements (inputs, ...) to
- * prevent subsequent editions. These edits would be lost, because the list
- * view only saves records when unselecting a row.
- *
- * @returns {Deferred} The deferred resolves if the row was unselected (and
- * possibly removed). If may be rejected, when the row is dirty and the
- * user refuses to discard its changes.
- */
- _unselectRow: function () {
- // Protect against calling this method when no row is selected
- if (this.currentRow === null) {
- return $.when();
- }
-
- var record = this.state.data[this.currentRow];
- var recordWidgets = this.allFieldWidgets[record.id];
- toggleWidgets(true);
-
- var def = $.Deferred();
- this.trigger_up('save_line', {
- recordID: record.id,
- onSuccess: def.resolve.bind(def),
- onFailure: def.reject.bind(def),
- });
- return def.fail(toggleWidgets.bind(null, false));
-
- function toggleWidgets(disabled) {
- _.each(recordWidgets, function (widget) {
- var $el = widget.getFocusableElement();
- $el.prop('disabled', disabled);
- });
- }
- },
//--------------------------------------------------------------------------
// Handlers
@@ -469,7 +497,7 @@ ListRenderer.include({
// but we do want to unselect current row
var self = this;
- this._unselectRow().then(function () {
+ this.unselectRow().then(function () {
self.trigger_up('add_record'); // TODO write a test, the deferred was not considered
});
},
@@ -495,7 +523,7 @@ ListRenderer.include({
* We need to manually unselect row, because noone else would do it
*/
_onEmptyRowClick: function () {
- this._unselectRow();
+ this.unselectRow();
},
/**
* Clicking on a footer should unselect (and save) the currently selected
@@ -503,7 +531,7 @@ ListRenderer.include({
* and _onWindowClicked ignore those clicks.
*/
_onFooterClick: function () {
- this._unselectRow();
+ this.unselectRow();
},
/**
* Handles the keyboard navigation according to events triggered by field
@@ -568,6 +596,11 @@ ListRenderer.include({
case 'next_line':
this._moveToNextLine();
break;
+ case 'cancel':
+ this.trigger_up('discard_changes', {
+ recordID: ev.target.dataPointID,
+ });
+ break;
}
},
/**
@@ -644,7 +677,7 @@ ListRenderer.include({
return;
}
- this._unselectRow();
+ 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
index 21028b99e02..e589c0d430a 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -6,7 +6,6 @@ var config = require('web.config');
var core = require('web.core');
var field_utils = require('web.field_utils');
var Pager = require('web.Pager');
-var session = require('web.session');
var utils = require('web.utils');
var _t = core._t;
@@ -147,32 +146,6 @@ var ListRenderer = BasicRenderer.extend({
}
},
/**
- * 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.
- *
- * @private
- * @param {Object} record a basic model record
- * @returns {string[]} a list of css classes
- */
- _computeDecorationClassNames: function (record) {
- var data = JSON.parse(JSON.stringify(record.data));
- var context = _.extend({}, 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();
- },
- /**
* 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
@@ -201,7 +174,9 @@ var ListRenderer = BasicRenderer.extend({
var field = self.state.fields[column.attrs.name];
var value = aggregateValues[column.attrs.name].value;
var help = aggregateValues[column.attrs.name].help;
- var formattedValue = field_utils.format[field.type](value, field, {});
+ var formattedValue = field_utils.format[field.type](value, field, {
+ escape: true,
+ });
$cell.addClass('o_list_number').attr('title', help).html(formattedValue);
}
return $cell;
@@ -276,7 +251,11 @@ var ListRenderer = BasicRenderer.extend({
var name = node.attrs.name;
var field = this.state.fields[name];
var value = record.data[name];
- var formattedValue = field_utils.format[field.type](value, field, { data: record.data });
+ var formattedValue = field_utils.format[field.type](value, field, {
+ data: record.data,
+ escape: true,
+ isPassword: 'password' in node.attrs,
+ });
return $td.html(formattedValue);
},
/**
@@ -539,17 +518,14 @@ var ListRenderer = BasicRenderer.extend({
return self._renderBodyCell(record, node, index, {mode: 'readonly'});
});
delete this.defs;
- var className = 'o_data_row';
- var decorations = this._computeDecorationClassNames(record);
- if (decorations.length) {
- className += (' ' + decorations.join(' '));
- }
- var $tr = $('<tr/>', {class: className})
+
+ var $tr = $('<tr/>', {class: 'o_data_row'})
.data('id', record.id)
.append($cells);
if (this.hasSelectors) {
$tr.prepend(this._renderSelector('td'));
}
+ this._setDecorationClasses(record, $tr);
return $tr;
},
/**
@@ -621,6 +597,24 @@ var ListRenderer = BasicRenderer.extend({
return this._super();
},
/**
+ * 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.
+ *
+ * @private
+ * @param {Object} record a basic model record
+ * @param {jQueryElement} $tr a jquery <tr> element (the row to add decoration)
+ */
+ _setDecorationClasses: function (record, $tr) {
+ _.each(this.rowDecorations, function (expr, decoration) {
+ var cssClass = decoration.replace('decoration', 'text');
+ $tr.toggleClass(cssClass, py.PY_isTrue(py.evaluate(expr, record.evalContext)));
+ });
+ },
+ /**
* Whenever we change the state of the selected rows, we need to call this
* method to keep the this.selection variable in sync, and also to recompute
* the aggregates.
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index f132704a475..fb175aa1934 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -74,8 +74,9 @@ var PivotController = AbstractController.extend({
//--------------------------------------------------------------------------
/**
- * We save the current measure and group bys, so we can restore the view
- * when we save the current state in the search view.
+ * Returns the current measures and groupbys, so we can restore the view
+ * when we save the current state in the search view, or when we add it to
+ * the dashboard.
*
* @override method from AbstractController
* @returns {Object}
@@ -84,8 +85,8 @@ var PivotController = AbstractController.extend({
var state = this.model.get();
return {
pivot_measures: state.measures,
- pivot_column_groupby: state.data.main_col.groupbys,
- pivot_row_groupby: state.data.main_row.groupbys,
+ pivot_column_groupby: state.colGroupBys,
+ pivot_row_groupby: state.rowGroupBys,
};
},
/**
@@ -126,6 +127,7 @@ var PivotController = AbstractController.extend({
return;
}
framework.blockUI();
+ table.title = this.title;
session.get_file({
url: '/web/pivot/export_xls',
data: {data: JSON.stringify(table)},
@@ -218,8 +220,9 @@ var PivotController = AbstractController.extend({
this.update({}, {reload: false});
}
if ($target.hasClass('o_pivot_expand_button')) {
- this.model.expandAll();
- this.update({reload: false});
+ this.model
+ .expandAll()
+ .then(this.update.bind(this, {}, {reload: false}));
}
if ($target.parents('.o_pivot_measures_list').length) {
var parent = $target.parent();
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index ab4a2eb0314..6bbadd56ed8 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -127,20 +127,21 @@ var PivotModel = AbstractModel.extend({
});
},
/**
+ * Export the current pivot view in a simple JS object.
+ *
* @returns {Object}
*/
exportData: function () {
- var record = this.data;
- var measureNbr = record.measures.length;
- var headers = this.renderer._computeHeaders();
+ var measureNbr = this.data.measures.length;
+ var headers = this._computeHeaders();
var measureRow = measureNbr > 1 ? _.last(headers) : [];
- var rows = this.renderer._computeRows();
+ var rows = this._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;
+ measureRow[i].measure = this.fields[measureRow[i].measure].string;
}
// process all rows
for (i =0, j, value; i < rows.length; i++) {
@@ -148,7 +149,7 @@ var PivotModel = AbstractModel.extend({
value = rows[i].values[j];
rows[i].values[j] = {
is_bold: (i === 0) ||
- ((record.data.main_col.width > 1) &&
+ ((this.data.main_col.width > 1) &&
(j >= rows[i].values.length - measureNbr)),
value: (value === undefined) ? "" : value,
};
@@ -159,7 +160,6 @@ var PivotModel = AbstractModel.extend({
measure_row: measureRow,
rows: rows,
nbr_measures: measureNbr,
- title: this.title,
};
},
/**
@@ -221,17 +221,14 @@ var PivotModel = AbstractModel.extend({
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,
+ groupedBy: params.rowGroupBys,
+ colGroupBys: params.colGroupBys,
+ measures: params.measures,
sorted_column: {},
};
return this._loadData();
@@ -514,7 +511,7 @@ var PivotModel = AbstractModel.extend({
_loadData: function () {
var self = this;
var groupBys = [];
- var rowGroupBys = this.data.groupedBy;
+ var rowGroupBys = this.data.groupedBy.length ? this.data.groupedBy : this.initialRowGroupBys;
var colGroupBys = this.data.colGroupBys;
var fields = [].concat(rowGroupBys, colGroupBys, this.data.measures);
@@ -591,7 +588,7 @@ var PivotModel = AbstractModel.extend({
});
var index = 0;
- var rowGroupBys = this.data.groupedBy;
+ var rowGroupBys = this.data.groupedBy.length ? this.data.groupedBy : this.initialRowGroupBys;
var colGroupBys = this.data.colGroupBys;
var datapt, row, col, attrs, cell_value;
var main_row_header, main_col_header;
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index 83bd32de830..d90c0d21780 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -87,9 +87,9 @@ var PivotView = AbstractView.extend({
activeMeasures.push('__count');
}
- this.loadParams.measures = activeMeasures;
- this.loadParams.colGroupBys = colGroupBys;
- this.loadParams.rowGroupBys = rowGroupBys;
+ this.loadParams.measures = params.context.pivot_measures || activeMeasures;
+ this.loadParams.colGroupBys = params.context.pivot_column_groupby || colGroupBys;
+ this.loadParams.rowGroupBys = params.context.pivot_row_groupby || rowGroupBys;
this.loadParams.fields = fields;
this.controllerParams.title = params.title || arch.attrs.string || _t("Untitled");
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index 7e20cd74d04..b84d60d5b7c 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -133,7 +133,7 @@ var FormViewDialog = ViewDialog.extend({
text: _t("Save & New"),
classes: "btn-primary",
click: function () {
- this._save().then(self.form_view.createRecord.bind(self.form_view));
+ this._save().then(self.form_view.createRecord.bind(self.form_view, self.parentID));
},
});
}
@@ -375,11 +375,9 @@ var SelectCreateDialog = ViewDialog.extend({
});
},
_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 || [],
+ domains: [this.domain].concat(domains),
+ contexts: [this.context].concat(contexts),
group_by_seq: groupbys || []
});
return {
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index 13ab8fd0892..c11551ca2cc 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -295,7 +295,10 @@ var DataExport = Dialog.extend({
}
var self = this;
- return this.exports.read_slice(['name'], {
+ return this._rpc({
+ model: 'ir.exports',
+ method: 'search_read',
+ fields: ['name'],
domain: [['resource', '=', this.record.model]]
}).then(function (export_list) {
if (!export_list.length) {
diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less
index 3b191a0837a..34ce34e6ad8 100644
--- a/addons/web/static/src/less/form_view.less
+++ b/addons/web/static/src/less/form_view.less
@@ -435,6 +435,7 @@
.o_field_widget {
text-align: right;
+ .o-justify-content(flex-end);
width: 100%;
}
}
diff --git a/addons/web/static/src/less/layout_background.less b/addons/web/static/src/less/layout_background.less
index 7ff90a68921..6750705c35b 100644
--- a/addons/web/static/src/less/layout_background.less
+++ b/addons/web/static/src/less/layout_background.less
@@ -34,7 +34,7 @@
background-size: cover;
background-position: bottom center;
background-repeat: no-repeat;
- min-height: 920px;
+ min-height: 900px;
thead tr th, h2 {
color: @odoo-brand-primary;
}
diff --git a/addons/web/static/src/less/list_view.less b/addons/web/static/src/less/list_view.less
index eaf0f1bfba8..a09b8e7ac0c 100644
--- a/addons/web/static/src/less/list_view.less
+++ b/addons/web/static/src/less/list_view.less
@@ -91,17 +91,16 @@
}
// Values styling
- .o_data_cell {
- &.o_list_number {
- text-align: right;
- white-space: nowrap;
- }
- &.o_list_text {
- white-space: pre-wrap;
- }
- &.o_invisible_modifier {
- display: initial!important; // Override invisible to keep the cells aligned
- }
+ .o_list_number {
+ text-align: right;
+ white-space: nowrap;
+ }
+ .o_list_text {
+ white-space: pre-wrap;
+ }
+
+ .o_data_cell.o_invisible_modifier {
+ display: initial!important; // Override invisible to keep the cells aligned
}
//--------------------------------------------------------------------------
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 50e59713562..394abe18c7c 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -604,7 +604,7 @@
<div t-if="!widget.readonly" class="o_domain_leaf_edition">
<!-- field selector will be instantiated here -->
<div> <!-- used for flex stretching -->
- <select class="o_domain_leaf_operator_select">
+ <select class="o_domain_leaf_operator_select o_input">
<option t-foreach="widget.operators" t-as="key"
t-att-value="key"
t-att-selected="widget.displayOperator === key ? 'selected' : None">
@@ -614,7 +614,7 @@
</div>
<div t-attf-class="o_ds_value_cell#{_.contains(['set', 'not set'], widget.displayOperator) ? ' hidden' : ''}">
<t t-if="widget.selectionChoices !== null">
- <select class="o_domain_leaf_value_input">
+ <select class="o_domain_leaf_value_input o_input">
<option t-foreach="widget.selectionChoices" t-as="val"
t-att-value="val[0]"
t-att-selected="_.contains(val, widget.displayValue) ? 'selected' : None">
@@ -630,12 +630,12 @@
</span>
</div>
<div class="o_domain_leaf_value_tags">
- <input placeholder="Add new value" type="text"/>
+ <input placeholder="Add new value" type="text" class="o_input"/>
<button class="btn btn-xs btn-primary fa fa-plus o_domain_leaf_value_add_tag_button"/>
</div>
</t>
<t t-else="">
- <input class="o_domain_leaf_value_input" type="text" t-att-value="widget.displayValue"/>
+ <input class="o_domain_leaf_value_input o_input" type="text" t-att-value="widget.displayValue"/>
</t>
</t>
</div>
@@ -730,7 +730,8 @@
<div t-if="widget.mode !== 'readonly'" t-attf-class="o_field_radio #{widget.nodeOptions.horizontal ? 'o_horizontal' : 'o_vertical'}">
<div t-foreach="widget.values" t-as="value" class="o_radio_item">
- <input type="radio" class="o_radio_input" t-att-id="widget.unique_id + '_' + value[0]" t-att-name="widget.unique_id" t-att-data-value="value[0]"/>
+ <input type="radio" class="o_radio_input" t-att-id="widget.unique_id + '_' + value[0]"
+ t-att-name="widget.unique_id" t-att-data-value="value[0]" t-att-data-index="value_index"/>
<label t-if="!widget.nodeOptions.no_radiolabel" class="o_form_label" t-att-for="widget.unique_id + '_' + value[0]"><t t-esc="value[1]"/></label>
</div>
</div>
@@ -1180,11 +1181,12 @@
<t t-name="FieldMany2ManyCheckBoxes">
<div>
<div t-foreach="widget.m2mValues" t-as="m2m_value">
+ <t t-set="id_for_label" t-value="'o_many2many_checkbox_' + _.uniqueId()"/>
<div class="o_checkbox">
- <input type="checkbox" t-att-data-record-id="JSON.stringify(m2m_value[0])"/>
+ <input type="checkbox" t-att-id="id_for_label" t-att-data-record-id="JSON.stringify(m2m_value[0])"/>
<span/>
</div>
- <label class="o_form_label"><t t-esc="m2m_value[1]"/></label>
+ <label t-att-for="id_for_label" class="o_form_label"><t t-esc="m2m_value[1]"/></label>
</div>
</div>
</t>