diff options
| author | Olivier Dony <odo@openerp.com> | 2017-06-01 01:46:13 +0200 |
|---|---|---|
| committer | Olivier Dony <odo@openerp.com> | 2017-06-01 01:46:13 +0200 |
| commit | 5d2869cbc812f716e3115b5efb9bd1c3082f47b7 (patch) | |
| tree | 364844fc326f0d095997120d10d83e67fdf341e9 /addons/web/static/src | |
| parent | 04111cae39e36015d3c8494f52b110ab5ab6ad80 (diff) | |
| parent | ba15df47cb25625cb0921cba554aa0879048c90a (diff) | |
[MERGE] Forward-port saas-16 up to ba15df47cb25625cb0921cba554aa0879048c90a
Diffstat (limited to 'addons/web/static/src')
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 + "­" + 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> |
