summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
authorqsm-odoo <qsm@odoo.com>2017-04-18 10:43:10 +0200
committerqsm-odoo <qsm@odoo.com>2017-04-19 11:42:01 +0200
commit7ff8aa73e17b281b3e2bc4c7275d020eab0204e7 (patch)
treed45b9ea0299c66bc1bb448634163d8aa686050b5 /addons/web/static/src/js
parentff86e5b9862deab9a9772774e941140966a9ab7c (diff)
[REF] web, *: implement modifiers behaviors
* account, mail, sale Since the views refactoring, modifiers were not properly handled anymore (readonly/required not recomputed, use of readonly/required of "python field" instead of "view field", ...). This commit tries to implement a system which allows to handle the modifiers (re)computation the same way for all the basic views. For this to work, many specialized renderers behaviors have been moved to the basic renderer so that more mechanisms are shared (especially by the form renderer and the list editable renderer). The list editable renderer is hugely impacted by this commit. Here is a list of key changes: - The basic renderer now has a `_renderFieldWidget` function which is used without modification by the form and the list renderers. - Modifiers have to be registered thanks to the basic renderer `_registerModifiersData` function (this is done automatically for fields by the `_renderFieldWidget` function for example). - All instantiated widgets are accessible, organized by record, and ordered in a special basic renderer variable. - The code which resets the widgets of the form view and the code which updates the row of a list editable view is now shared in the basic renderer and now also automatically updates the DOM according to the reevaluated modifiers. - The last point has an huge impact on list editable renderer: all the widgets have to be instantiated when editing a row (even readonly and invisible ones) as they can be switched to editable/visible during edition (after a modifiers update). - `replace_element` and `readonly` options of `AbstractField` are useless: widgets are now always replacing the list editable cells in edition and the `readonly` widget mechanism is replaced by the notion of 'focusable' widgets. (see `AbstractField.getFocusableElement`) - The 'tab' navigation mechanism is impacted by the previous point, some code sharing have been done in basic renderer for this too. - `AbstractField` does not care anymore of the 'required' status, this is the view responsability to check that a required field has a set value on save. - Some list editable bug fixing (e.g. it was possible to edit multiple rows at the same time, it was not possible to navigate towards the previous field, crash on o2m add an item click, ...) - The mockserver now properly simulates server modifiers computation. - ... Note: list editable style may be worse than before this commit but a CSS update is coming in a few days.
Diffstat (limited to 'addons/web/static/src/js')
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js83
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js38
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js58
-rw-r--r--addons/web/static/src/js/views/abstract_renderer.js1
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js15
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js4
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js520
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js3
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js15
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js15
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js265
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js1
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js13
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js477
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js146
15 files changed, 990 insertions, 664 deletions
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
index f36418e3b8b..0d7ed2f4fe2 100644
--- a/addons/web/static/src/js/fields/abstract_field.js
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -32,7 +32,6 @@ odoo.define('web.AbstractField', function (require) {
var field_utils = require('web.field_utils');
var Widget = require('web.Widget');
-var Domain = require('web.Domain');
var AbstractField = Widget.extend({
className: 'o_field_widget',
@@ -40,22 +39,12 @@ var AbstractField = Widget.extend({
'keydown': '_onKeydown',
},
/**
- * if this flag is set to true, the rest of the web client will assume that
- * it is not editable. For example, the list view in editable mode will
- * skip the widget when pressing tab
- */
- readonly: false,
- /**
* If this flag is set to true, the field widget will be reset on every
* change which is made in the view (if the view supports it). This is
* currently a form view feature.
*/
resetOnAnyFieldChange: false,
/**
- * if true, the widget will replace a cell in an editable list view
- */
- replace_element: false,
- /**
* If this flag is given a string, the related BasicModel will be used to
* initialize specialData the field might need. This data will be available
* through this.record.specialData[this.name].
@@ -77,8 +66,6 @@ var AbstractField = Widget.extend({
* @param {Object} record A record object (result of the get method of a basic model)
* @param {Object} [options]
* @param {string} [options.mode=readonly] should be 'readonly' or 'edit'
- * @param {string} [options.required=false]
- * @param {string} [options.idForLabel]
*/
init: function (parent, name, record, options) {
this._super(parent);
@@ -103,9 +90,6 @@ var AbstractField = Widget.extend({
var fieldsInfo = record.fieldsInfo[this.viewType];
this.attrs = fieldsInfo && fieldsInfo[name] || {};
- // modifiers
- this.modifiers = JSON.parse(this.attrs.modifiers || "{}");
-
// this property tracks the current (parsed if needed) value of the field.
// Note that we don't use an event system anymore, using this.get('value')
// is no longer valid.
@@ -153,15 +137,6 @@ var AbstractField = Widget.extend({
// a string.
this._isValid = true;
- // the 'required' flag is basically only needed to determine if the widget
- // is in a valid state (not valid if empty and required)
- this.required = options.required || this.field.required;
-
- // the 'idForLabel' is the (html) id that should be set to the relevent
- // dom entity. If done correctly, clicking on the corresponding label
- // (in form view) will focus and select the value.
- this.idForLabel = options.idForLabel;
-
// this is the last value that was set by the user, unparsed. This is
// used to avoid setting the value twice in a row with the exact value.
this.lastSetValue = undefined;
@@ -175,8 +150,8 @@ var AbstractField = Widget.extend({
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
- self._render();
self.$el.attr('name', self.name);
+ return self._render();
});
},
@@ -185,11 +160,22 @@ var AbstractField = Widget.extend({
//--------------------------------------------------------------------------
/**
- * Right now, this function is only used in editable list view when the user
- * select a cell. The corresponding widget will be activated, which in general
- * means that the input text will be focused and selected
+ * Activates the field widget. By default, activation means focusing and
+ * selecting the associated focusable element.
+ *
+ * @returns {boolean} true if the widget was activated, false if the
+ * focusable element was not found or invisible
*/
activate: function () {
+ var $focusable = this.getFocusableElement();
+ if ($focusable.length && $focusable.is(':visible')) {
+ $focusable.focus();
+ if ($focusable.is('input[type="text"], textarea')) {
+ setTimeout($focusable.select.bind($focusable), 0);
+ }
+ return true;
+ }
+ return false;
},
/**
* This function should be implemented by widgets that are not able to
@@ -202,6 +188,15 @@ var AbstractField = Widget.extend({
commitChanges: function () {
},
/**
+ * Returns the main field's DOM element (jQuery form) which can be focused
+ * by the browser.
+ *
+ * @returns {jQuery} main focusable element inside the widget
+ */
+ getFocusableElement: function () {
+ return $();
+ },
+ /**
* this method is used to determine if the field value is set to a meaningful
* value. This is useful to determine if a field should be displayed as empty
*
@@ -211,23 +206,23 @@ var AbstractField = Widget.extend({
return !!this.value;
},
/**
- * a field widget is valid if its value is valid and if there is a value when
- * it is required. This is checked before saving a record, by the form view.
+ * A field widget is valid if it was checked as valid the last time its
+ * value was changed by the user. This is checked before saving a record, by
+ * the view.
*
- * Note: the return value may be a boolean. It is necessary for some fields
- * (x2many) to sometimes ask confirmation to the user if the current dirty
- * invalid line can be discarded. This is an asynchronous operation, so we
- * need the expressivity of something like a deferred.
+ * Note: the return value may not be a boolean. It is necessary for some
+ * fields (x2many) to sometimes ask confirmation to the user if the current
+ * dirty invalid line can be discarded. This is an asynchronous operation,
+ * so we need the expressivity of something like a deferred.
+ *
+ * Note2: this is the responsability of the view to check that required
+ * fields have a set value.
*
* @returns {boolean|Deferred} true/false if the widget is valid, or a
* deferred which resolves to true/false if the widget is valid or not
*/
isValid: function () {
- var is_required = this.required;
- if ('required' in this.modifiers) {
- is_required = new Domain(this.modifiers.required).compute(this.recordData);
- }
- return this._isValid && !(is_required && !this.isSet());
+ return this._isValid;
},
/**
* this method is supposed to be called from the outside of field widgets.
@@ -285,12 +280,6 @@ var AbstractField = Widget.extend({
* @returns {Deferred}
*/
_render: function () {
- var is_required = this.required;
- if ('required' in this.modifiers) {
- is_required = new Domain(this.modifiers.required).compute(this.recordData);
- }
- this.$el.toggleClass('o_form_required', is_required);
-
if (this.mode === 'edit') {
return this._renderEdit();
} else if (this.mode === 'readonly') {
@@ -347,7 +336,7 @@ var AbstractField = Widget.extend({
try {
value = this._parseValue(value);
this._isValid = true;
- } catch(e) {
+ } catch (e) {
this._isValid = false;
}
var changes = {};
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index d2649195111..9f37240e0cc 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -122,13 +122,12 @@ var InputField = DebouncedField.extend({
//--------------------------------------------------------------------------
/**
- * Automatically selects the content of the field.
+ * Returns the associated <input/> element.
*
* @override
*/
- activate: function () {
- this.$input.focus();
- setTimeout(this.$input.select.bind(this.$input), 0);
+ getFocusableElement: function () {
+ return this.$input || $();
},
/**
* Do not re-render this field if it was the origin of the onchange call.
@@ -171,7 +170,6 @@ var InputField = DebouncedField.extend({
this.$input.addClass('o_form_input');
this.$input.attr({
type: 'text',
- id: this.idForLabel,
placeholder: this.attrs.placeholder || "",
});
// Save cursor position to restore it after updating value
@@ -253,7 +251,6 @@ var FieldChar = InputField.extend({
var FieldDate = InputField.extend({
className: "o_form_field_date",
tagName: "span",
- replace_element: true,
supportedFieldTypes: ['date'],
/**
@@ -274,8 +271,7 @@ var FieldDate = InputField.extend({
});
def = this.datewidget.appendTo('<div>').done(function () {
self.datewidget.$el.addClass(self.$el.attr('class'));
- self.datewidget.$input.addClass('o_form_input');
- self.datewidget.$input.attr('id', self.idForLabel);
+ self._prepareInput(self.datewidget.$input);
self.replaceElement(self.datewidget.$el);
});
}
@@ -371,7 +367,6 @@ var FieldDateTime = FieldDate.extend({
var FieldMonetary = InputField.extend({
className: 'o_form_field_monetary o_list_number',
- replace_element: true,
supportedFieldTypes: ['float', 'monetary'],
resetOnAnyFieldChange: true, // Have to listen to currency changes
@@ -484,7 +479,6 @@ var FieldBoolean = AbstractField.extend({
events: _.extend({}, AbstractField.prototype.events, {
change: '_onChange',
}),
- replace_element: true,
supportedFieldTypes: ['boolean'],
//--------------------------------------------------------------------------
@@ -492,15 +486,12 @@ var FieldBoolean = AbstractField.extend({
//--------------------------------------------------------------------------
/**
- * Automatically selects the field.
- *
* @override
+ * @returns {jQuery} the focusable checkbox input
*/
- activate: function () {
- this.$input.focus();
- setTimeout(this.$input.select.bind(this.$input), 0);
+ getFocusableElement: function () {
+ return this.$input || $();
},
-
/**
* A boolean field is always set since false is a valid value.
*
@@ -686,7 +677,6 @@ var FieldText = DebouncedField.extend({
*/
start: function () {
this.$el.addClass('o_list_text o_form_textarea');
- this.$el.attr('id', this.idForLabel);
if (this.mode === 'edit') {
this.$textarea = $('<textarea>').appendTo(this.$el);
@@ -704,14 +694,12 @@ var FieldText = DebouncedField.extend({
//--------------------------------------------------------------------------
/**
- * Automatically selects the content of the field.
+ * Returns the associated <textarea/> element.
*
* @override
*/
- activate: function () {
- var $textarea = this.$('textarea');
- $textarea.focus();
- setTimeout($textarea.select.bind($textarea), 0);
+ getFocusableElement: function () {
+ return this.$textarea || $();
},
//--------------------------------------------------------------------------
@@ -753,8 +741,6 @@ var HandleWidget = AbstractField.extend({
tagName: 'span',
className: 'o_row_handle fa fa-arrows ui-sortable-handle',
description: "",
- readonly: true,
- replace_element: true,
supportedFieldTypes: ['integer'],
});
@@ -1093,7 +1079,6 @@ var PriorityWidget = AbstractField.extend({
'mouseout > a': '_onMouseOut',
'click > a': '_onClick',
},
- readonly: true,
supportedFieldTypes: ['selection'],
//--------------------------------------------------------------------------
@@ -1195,7 +1180,6 @@ var PriorityWidget = AbstractField.extend({
this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star');
$(event.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o');
},
-
});
var AttachmentImage = AbstractField.extend({
@@ -1210,7 +1194,6 @@ var StateSelectionWidget = AbstractField.extend({
},
'click li': '_setSelection'
},
- readonly: true,
supportedFieldTypes: ['selection'],
//--------------------------------------------------------------------------
@@ -1300,7 +1283,6 @@ var StateSelectionWidget = AbstractField.extend({
});
var LabelSelection = AbstractField.extend({
- readonly: true,
supportedFieldTypes: ['selection'],
//--------------------------------------------------------------------------
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 06f38abffbb..abe3de97779 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -116,18 +116,16 @@ var FieldMany2One = AbstractField.extend({
// Public
//--------------------------------------------------------------------------
- activate: function () {
- this.$input.focus();
- setTimeout(this.$input.select.bind(this.$input), 0);
- },
/**
- * Focuses the input.
+ * @override
+ * @returns {jQuery}
*/
- focus: function () {
- if (this.mode === "edit") {
- this.$input.focus();
- }
+ getFocusableElement: function () {
+ return this.$input || $();
},
+ /**
+ * TODO
+ */
reinitialize: function (value) {
this.floating = false;
this._setValue(value);
@@ -366,7 +364,7 @@ var FieldMany2One = AbstractField.extend({
disable_multiple_selection: true,
on_selected: function (element_ids) {
self.reinitialize({id: element_ids[0]});
- self.focus();
+ self.activate();
}
})).open();
},
@@ -409,7 +407,7 @@ var FieldMany2One = AbstractField.extend({
*/
_onExternalButtonClick: function () {
if (!this.value) {
- this.focus();
+ this.activate();
return;
}
var self = this;
@@ -578,8 +576,7 @@ var FieldX2Many = AbstractField.extend({
}
var def = $.Deferred();
this.renderer.canBeSaved()
- .then(def.resolve.bind(def, true))
- .fail(def.resolve.bind(def, false));
+ .then(def.resolve.bind(def, true), def.resolve.bind(def, false));
return def;
},
@@ -610,6 +607,7 @@ var FieldX2Many = AbstractField.extend({
mode: this.mode,
addCreateLine: !this.isReadonly && this.activeActions.create,
addTrashIcon: !this.isReadonly,
+ viewType: 'list',
});
}
if (arch.tag === 'kanban') {
@@ -620,7 +618,8 @@ var FieldX2Many = AbstractField.extend({
};
this.renderer = new KanbanRenderer(this, this.value, {
arch: arch,
- record_options: record_options
+ record_options: record_options,
+ viewType: 'kanban',
});
}
return this.renderer ? this.renderer.appendTo(this.$el) : this._super();
@@ -940,7 +939,6 @@ var FieldMany2Many = FieldX2Many.extend({
var FieldMany2ManyTags = AbstractField.extend({
tag_template: "FieldMany2ManyTag",
className: "o_form_field o_form_field_many2manytags",
- replace_element: true,
supportedFieldTypes: ['many2many'],
custom_events: {
field_changed: '_onFieldChanged',
@@ -957,9 +955,23 @@ var FieldMany2ManyTags = AbstractField.extend({
// Public
//--------------------------------------------------------------------------
+ /**
+ * @override
+ */
activate: function () {
- this.many2one.focus();
+ return this.many2one ? this.many2one.activate() : false;
+ },
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.many2one ? this.many2one.getFocusableElement() : $();
},
+ /**
+ * @override
+ * @returns {boolean}
+ */
isSet: function () {
return !!this.value && this.value.count;
},
@@ -1012,7 +1024,6 @@ var FieldMany2ManyTags = AbstractField.extend({
this.many2one.destroy();
}
this.many2one = new FieldMany2One(this, this.name, this.record, {
- idForLabel: this.idForLabel,
mode: 'edit',
viewType: this.viewType,
});
@@ -1343,7 +1354,6 @@ var FieldSelection = AbstractField.extend({
events: _.extend({}, AbstractField.prototype.events, {
'change': '_onChange',
}),
- replace_element: true,
/**
* @override
*/
@@ -1365,6 +1375,18 @@ var FieldSelection = AbstractField.extend({
/**
* @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.$el.is('select') ? this.$el : $();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
* @private
*/
_renderEdit: function () {
diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js
index 83aafdfe310..70ad5350670 100644
--- a/addons/web/static/src/js/views/abstract_renderer.js
+++ b/addons/web/static/src/js/views/abstract_renderer.js
@@ -93,4 +93,3 @@ return Widget.extend({
});
});
-
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 c21d7a43335..5293ee6f4b6 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -88,6 +88,21 @@ return AbstractController.extend(FieldManagerMixin, {
//--------------------------------------------------------------------------
/**
+ * Called by the field manager mixin to confirm that a change just occured
+ * (after that potential onchanges have been applied).
+ *
+ * Basically, this only relays the notification to the renderer with the
+ * new state.
+ *
+ * @param {string} id - the id of one of the view's records
+ * @param {string[]} fields - the changed fields
+ * @param {OdooEvent} e - the event that triggered the change
+ */
+ _confirmChange: function (id, fields, e) {
+ var state = this.model.get(this.handle);
+ this.renderer.confirmChange(state, id, fields, e);
+ },
+ /**
* Delete records (and ask for confirmation if necessary)
*
* @param {string[]} ids list of local record ids
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 cd1dedb2f6c..ba51819b218 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -27,6 +27,8 @@ odoo.define('web.BasicModel', function (require) {
* fieldsInfo: {Object},
* getContext: {function},
* getDomain: {function},
+ * getEvalContext: {function},
+ * getFieldNames: {function},
* groupedBy: {string[]},
* id: {integer},
* isOpen: {boolean},
@@ -2001,8 +2003,8 @@ var BasicModel = AbstractModel.extend({
};
dataPoint.getContext = this._getContext.bind(this, dataPoint);
- dataPoint.getEvalContext = this._getEvalContext.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;
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 1db0718545e..a18a82e5745 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -3,27 +3,86 @@ odoo.define('web.BasicRenderer', function (require) {
/**
* The BasicRenderer is an abstract class designed to share code between all
- * views that uses a BasicModel. The main goal is to keep track of all field
- * widgets, and properly destroy them whenever a rerender is done.
- *
- * When a concrete view extends a BasicRenderer, it should add each field
- * widgets in the this.widget list.
+ * views that uses a BasicModel. The main goal is to keep track of all field
+ * widgets, and properly destroy them whenever a rerender is done. The widgets
+ * and modifiers updates mechanism is also shared in the BasicRenderer.
*/
-
var AbstractRenderer = require('web.AbstractRenderer');
var config = require('web.config');
var core = require('web.core');
+var Domain = require('web.Domain');
var qweb = core.qweb;
-return AbstractRenderer.extend({
+var BasicRenderer = AbstractRenderer.extend({
+ custom_events: {
+ /**
+ * All basic renderers should be able to handle widget navigation
+ * through the TAB key.
+ */
+ move_next: '_onMoveNext',
+ move_previous: '_onMovePrevious',
+ },
/**
+ * Basic renderers implements the concept of "mode", they can either be in
+ * readonly mode or editable mode.
+ *
* @override
*/
- init: function (paret, state, params) {
+ init: function (parent, state, params) {
this._super.apply(this, arguments);
- this.widgets = [];
this.activeActions = params.activeActions;
+ this.viewType = params.viewType;
+ this.mode = params.mode || 'readonly';
+ },
+ /**
+ * Updates the internal state of the renderer to the new state. By default,
+ * this also implements the recomputation of the modifiers and their
+ * application to the DOM and the reset of the field widgets if needed.
+ *
+ * In case the given record is not found anymore, a whole re-rendering is
+ * completed (possible if a change in a record caused an onchange which
+ * erased the current record).
+ *
+ * We could always rerender the view from scratch, but then it would not be
+ * as efficient, and we might lose some local state, such as the input focus
+ * cursor, or the scrolling position.
+ *
+ * @param {Object} state
+ * @param {string} id
+ * @param {string[]} fields
+ * @param {OdooEvent} ev
+ * @returns {Deferred<AbstractField[]>} resolved with the list of widgets
+ * that have been reset
+ */
+ confirmChange: function (state, id, fields, ev) {
+ this.state = state;
+
+ var record = state.id === id ? state : _.findWhere(state.data, {id: id});
+ if (!record) {
+ return this._render();
+ }
+
+ var defs = [];
+
+ // Reset all the field widgets that are marked as changed and the ones
+ // which are configured to always be reset on any change
+ var resetWidgets = [];
+ _.each(this.allFieldWidgets[id], function (widget) {
+ if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) {
+ defs.push(widget.reset(record, ev));
+ resetWidgets.push(widget);
+ }
+ });
+
+ // The modifiers update is done after widget resets as modifiers
+ // associated callbacks need to have all the widgets with the proper
+ // state before evaluation
+ defs.push(this._updateAllModifiers(record));
+
+ return $.when.apply($, defs).then(function () {
+ return resetWidgets;
+ });
},
//--------------------------------------------------------------------------
@@ -52,15 +111,358 @@ return AbstractRenderer.extend({
});
},
/**
+ * Activates the widget at the given index for the given record if possible
+ * or the "next" possible one.
+ *
+ * @private
+ * @param {Object} record
+ * @param {integer} currentIndex
+ * @param {integer} [inc=1] - the increment to use when searching for the
+ * "next" possible one
+ * @returns {integer} the index of the widget that was activated or -1 if
+ * none was possible to activate
+ */
+ _activateFieldWidget: function (record, currentIndex, inc) {
+ inc = inc === undefined ? 1 : inc;
+
+ var activated;
+ var recordWidgets = this.allFieldWidgets[record.id] || [];
+ for (var i = 0 ; i < recordWidgets.length ; i++) {
+ activated = recordWidgets[currentIndex].activate();
+ if (activated) {
+ return currentIndex;
+ }
+
+ currentIndex += inc;
+ if (currentIndex >= recordWidgets.length) {
+ currentIndex -= recordWidgets.length;
+ } else if (currentIndex < 0) {
+ currentIndex += recordWidgets.length;
+ }
+ }
+ return -1;
+ },
+ /**
+ * This is a wrapper of the {@see _activateFieldWidget} function to select
+ * the next possible widget instead of the given one.
+ *
+ * @private
+ * @param {Object} record
+ * @param {integer} currentIndex
+ * @return {integer}
+ */
+ _activateNextFieldWidget: function (record, currentIndex) {
+ currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length;
+ return this._activateFieldWidget(record, currentIndex, +1);
+ },
+ /**
+ * This is a wrapper of the {@see _activateFieldWidget} function to select
+ * the previous possible widget instead of the given one.
+ *
+ * @private
+ * @param {Object} record
+ * @param {integer} currentIndex
+ * @return {integer}
+ */
+ _activatePreviousFieldWidget: function (record, currentIndex) {
+ currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1);
+ return this._activateFieldWidget(record, currentIndex, -1);
+ },
+ /**
+ * Does the necessary DOM updates to match the given modifiers data. The
+ * modifiers data is supposed to contain the properly evaluated modifiers
+ * associated to the given records and elements.
+ *
+ * @param {Object} modifiersData
+ * @param {Object} record
+ * @param {Object} [element] - do the update only on this element if given
+ * @returns {Deferred} resolved once finished
+ */
+ _applyModifiers: function (modifiersData, record, element) {
+ var self = this;
+ var defs = [];
+ var modifiers = modifiersData.evaluatedModifiers[record.id] || {};
+
+ this.defs = defs; // Potentially filled by widget rerendering
+ if (element) {
+ _apply(element);
+ } else {
+ // Clone is necessary as the list might change during _.each
+ _.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply);
+ }
+ delete this.defs;
+
+ return $.when.apply($, defs);
+
+ function _apply(element) {
+ // If the view is in edit mode and that a widget have to switch
+ // its "readonly" state, we have to re-render it completely
+ if ('readonly' in modifiers
+ && self.mode === "edit"
+ && element.widget
+ && (element.widget.mode === 'readonly') !== modifiers.readonly)
+ {
+ self._rerenderFieldWidget(element.widget, record);
+ return; // Rerendering already applied the modifiers, no need to go further
+ }
+
+ // Toggle modifiers CSS classes if necessary
+ element.$el.toggleClass("o_form_invisible", !!modifiers.invisible);
+ element.$el.toggleClass("o_readonly", !!modifiers.readonly);
+ element.$el.toggleClass("o_form_required", !!modifiers.required);
+
+ // Call associated callback
+ if (element.callback) {
+ element.callback(element, modifiers, record);
+ }
+ }
+ },
+ /**
+ * Determines if a given field widget value can be saved. For this to be
+ * true, the widget must be valid (properly parsed value) and have a value
+ * if the associated view field is required.
+ *
+ * @private
+ * @param {AbstractField} widget
+ * @returns {boolean|Deferred<boolean>} @see AbstractField.isValid
+ */
+ _canWidgetBeSaved: function (widget) {
+ var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record);
+ var isSetOrNotRequired = (widget.isSet() || !modifiers.required);
+ var isValid = widget.isValid();
+ if (isValid instanceof $.Deferred) {
+ return isValid.then(function (isValid) {
+ return isValid && isSetOrNotRequired;
+ });
+ }
+ return isValid && isSetOrNotRequired;
+ },
+ /**
+ * 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.getEvalContext();
+ 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.
+ *
+ * @private
+ * @param {Object} record
+ * @param {AbstractField} widget
+ * @returns {integer} the index of the removed widget
+ */
+ _destroyFieldWidget: function (record, widget) {
+ var recordWidgets = this.allFieldWidgets[record.id];
+ var index = recordWidgets.indexOf(widget);
+ if (index >= 0) {
+ recordWidgets.splice(index, 1);
+ }
+ this._unregisterModifiersElement(widget.__node, record, widget);
+ widget.$el.destroy();
+ return index;
+ },
+ /**
+ * Searches for the last evaluation of the modifiers associated to the given
+ * data (modifiers evaluation are supposed to always be up-to-date as soon
+ * as possible).
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} record
+ * @returns {Object} the evaluated modifiers associated to the given node
+ * and record (not recomputed by the call)
+ */
+ _getEvaluatedModifiers: function (node, record) {
+ var element = this._getModifiersData(node);
+ if (!element) {
+ return {};
+ }
+ return element.evaluatedModifiers[record.id] || {};
+ },
+ /**
+ * Searches through the registered modifiers data for the one which is
+ * related to the given node.
+ *
+ * @private
+ * @param {Object} node
+ * @returns {Object|undefined} related modifiers data if any
+ * undefined otherwise
+ */
+ _getModifiersData: function (node) {
+ return _.findWhere(this.allModifiersData, {node: node});
+ },
+ /**
+ * Registers or updates the modifiers data associated to the given node.
+ * This method is quiet complex as it handles all the needs of the basic
+ * renderers:
+ *
+ * - On first registration, the modifiers are evaluated thanks to the given
+ * record. This allows nodes that will produce an AbstractField instance
+ * to have their modifiers registered before this field creation as we
+ * need the readonly modifier to be able to instantiate the AbstractField.
+ * (@see _computeModifiers).
+ *
+ * - On additional registrations, if the node was already registered but the
+ * record is different, we evaluate the modifiers for this record and
+ * saves them in the same object (without reparsing the modifiers).
+ *
+ * - On additional registrations, the modifiers are not reparsed (or
+ * reevaluated for an already seen record) but the given widget or DOM
+ * element is associated to the node modifiers.
+ *
+ * - The new elements are immediately adapted to match the modifiers and the
+ * given associated callback is called even if there is no modifiers on
+ * the node (@see _applyModifiers). This is indeed necessary as the
+ * callback is a description of what to do when a modifier changes. Even
+ * if there is no modifiers, this action must be performed on first
+ * rendering to avoid code duplication. If there is no modifiers, they
+ * will however not be registered for modifiers updates.
+ *
+ * - When a new element is given, it does not replace the old one, it is
+ * added as an additional element. This is indeed useful for nodes that
+ * will produce multiple DOM (as a list cell and its internal widget or
+ * a form field and its associated label).
+ * (@see _unregisterModifiersElement for removing an associated element.)
+ *
+ * Note: also on view rerendering, all the modifiers are forgotten so that
+ * the renderer only keeps the ones associated to the current DOM state.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} record
+ * @param {jQuery|AbstractField} [element]
+ * @param {Object} [options]
+ * @param {Object} [options.callback] - the callback to call on registration
+ * and on modifiers updates
+ * @returns {Object} for code efficiency, returns the last evaluated
+ * modifiers for the given node and record.
+ */
+ _registerModifiers: function (node, record, element, options) {
+ // Check if we already registered the modifiers for the given node
+ // If yes, this is simply an update of the related element
+ // If not, check the modifiers to see if it needs registration
+ var modifiersData = this._getModifiersData(node);
+ if (!modifiersData) {
+ var modifiers = JSON.parse(node.attrs.modifiers || "{}"); // FIXME parsed multiple times (record switching, no modifiers, ...)
+ modifiersData = {
+ node: node,
+ modifiers: modifiers,
+ evaluatedModifiers: {},
+ elementsByRecord: {},
+ };
+ if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here)
+ this.allModifiersData.push(modifiersData);
+ }
+ }
+
+ // Evaluate if necessary
+ if (!modifiersData.evaluatedModifiers[record.id]) {
+ this._computeModifiers(modifiersData, record);
+ }
+
+ // Element might not be given yet (a second call to the function can
+ // update the registration with the element)
+ if (element) {
+ var newElement = {};
+ if (element instanceof jQuery) {
+ newElement.$el = element;
+ } else {
+ newElement.widget = element;
+ newElement.$el = element.$el;
+ }
+ if (options && options.callback) {
+ newElement.callback = options.callback;
+ }
+
+ if (!modifiersData.elementsByRecord[record.id]) {
+ modifiersData.elementsByRecord[record.id] = [];
+ }
+ modifiersData.elementsByRecord[record.id].push(newElement);
+
+ this._applyModifiers(modifiersData, record, newElement);
+ }
+
+ return modifiersData.evaluatedModifiers[record.id];
+ },
+ /**
* Render the view
*
* @override
* @returns {Deferred}
*/
_render: function () {
- var oldWidgets = this.widgets;
- this.widgets = [];
- return this._renderView().then(_.invoke.bind(_, oldWidgets, 'destroy'));
+ var oldAllFieldWidgets = this.allFieldWidgets;
+ this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great
+ this.allModifiersData = [];
+ return this._renderView().then(function () {
+ _.each(oldAllFieldWidgets, function (recordWidgets) {
+ _.each(recordWidgets, function (widget) {
+ widget.destroy();
+ });
+ });
+ });
+ },
+ /**
+ * Instantiates the appropriate AbstractField specialization for the given
+ * node and prepares its rendering and addition to the DOM. Indeed, the
+ * rendering of the widget will be started and the associated deferred will
+ * be added to the 'defs' attribute. This is supposed to be created and
+ * deleted by the calling code if necessary.
+ * Note: for this implementation to work, AbstractField willStart methods
+ * *must* be synchronous.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} record
+ * @param {Object} [options]
+ * @param {Object} [modifiersOptions]
+ * @returns {AbstractField}
+ */
+ _renderFieldWidget: function (node, record, options, modifiersOptions) {
+ var fieldName = node.attrs.name;
+
+ // Register the node-associated modifiers
+ var modifiers = this._registerModifiers(node, record);
+
+ // Initialize and register the widget
+ // Readonly status is known as the modifiers have just been registered
+ var Widget = record.fieldsInfo[this.viewType][fieldName].Widget;
+ var widget = new Widget(this, fieldName, record, _.extend({
+ mode: modifiers.readonly ? 'readonly' : this.mode,
+ viewType: this.viewType,
+ }, options || {}));
+
+ // Register the widget so that it can easily be found again
+ if (this.allFieldWidgets[record.id] === undefined) {
+ this.allFieldWidgets[record.id] = [];
+ }
+ this.allFieldWidgets[record.id].push(widget);
+
+ widget.__node = node; // TODO get rid of this if possible one day
+
+ // Prepare widget rendering and save the related deferred
+ var def = widget.__widgetRenderAndInsert(function () {});
+ if (def.state() === 'pending') {
+ this.defs.push(def);
+ }
+
+ // Update the modifiers registration by associating the widget and by
+ // giving the modifiers options now (as the potential callback is
+ // associated to new widget)
+ this._registerModifiers(node, record, widget, modifiersOptions);
+
+ return widget;
},
/**
* Actual rendering. Supposed to be overridden by concrete renderers.
@@ -69,7 +471,7 @@ return AbstractRenderer.extend({
* - instantiate a widget from the registry for each field in the arch
*
* Note that the 'state' field should contains all necessary information
- * for the rendering. The field widgets should be as synchronous as
+ * for the rendering. The field widgets should be as synchronous as
* possible.
*
* @abstract
@@ -78,6 +480,98 @@ return AbstractRenderer.extend({
_renderView: function () {
return $.when();
},
+ /**
+ * Rerenders a given widget and make sure the associated data which
+ * referenced the old one is updated.
+ *
+ * @private
+ * @param {Widget} widget
+ * @param {Object} record
+ * @returns {AbstractField}
+ */
+ _rerenderFieldWidget: function (widget, record) {
+ // Render the new field widget
+ var newWidget = this._renderFieldWidget(widget.__node, record);
+ widget.$el.replaceWith(newWidget.$el);
+
+ // Destroy the old widget and position the new one at the old one's
+ var oldIndex = this._destroyFieldWidget(record, widget);
+ var recordWidgets = this.allFieldWidgets[record.id];
+ recordWidgets.splice(oldIndex, 0, newWidget);
+ recordWidgets.pop();
+
+ return newWidget;
+ },
+ /**
+ * Unregisters an element of the modifiers data associated to the given
+ * node and record.
+ *
+ * @param {Object} node
+ * @param {Object} record
+ * @param {jQuery|AbstractField} element
+ */
+ _unregisterModifiersElement: function (node, record, element) {
+ var modifiersData = this._getModifiersData(node);
+ if (modifiersData) {
+ var elements = modifiersData.elementsByRecord[record.id];
+ var index = _.findIndex(elements, function (oldElement) {
+ return oldElement.widget === element
+ || oldElement.$el[0] === element[0];
+ });
+ if (index >= 0) {
+ elements.splice(index, 1);
+ }
+ }
+ },
+ /**
+ * Does two actions, for each registered modifiers:
+ * 1) Recomputes the modifiers associated to the given record and saves them
+ * (as boolean values) in the appropriate modifiers data.
+ * 2) Updates the rendering of the view elements associated to the given
+ * record to match the new modifiers.
+ *
+ * @see _computeModifiers
+ * @see _applyModifiers
+ *
+ * @private
+ * @param {Object} record
+ * @returns {Deferred} resolved once finished
+ */
+ _updateAllModifiers: function (record) {
+ var self = this;
+
+ var defs = _.flatten(_.map(this.allModifiersData, function (modifiersData) {
+ self._computeModifiers(modifiersData, record);
+ return self._applyModifiers(modifiersData, record);
+ }));
+
+ return $.when.apply($, defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * When someone presses TAB in a widget, it is nice to be able to go to the
+ * next widget, especially since the TAB key event is preventDefaulted
+ *
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onMoveNext: function (ev) {},
+ /**
+ * When someone presses SHIFT+TAB in a widget, it is nice to be able to go
+ * back to the previous widget, especially since the TAB key event is
+ * preventDefaulted
+ *
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onMovePrevious: function (ev) {},
});
+return BasicRenderer;
});
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index d850b2b7a2f..2604dcf0aa1 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -19,6 +19,7 @@ var BasicView = AbstractView.extend({
Model: BasicModel,
Controller: BasicController,
}),
+ viewType: undefined,
/**
* process the fields_view to find all fields appearing in the views.
* list those fields' name in this.fields_name, which will be the list
@@ -34,6 +35,8 @@ var BasicView = AbstractView.extend({
init: function (viewInfo, params) {
this._super.apply(this, arguments);
+ this.rendererParams.viewType = this.viewType;
+
this.controllerParams.confirmOnDelete = true;
this.controllerParams.archiveEnabled = 'active' in viewInfo.fields;
this.controllerParams.hasButtons =
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 9fcd34ffee2..06997c1c374 100644
--- a/addons/web/static/src/js/views/field_manager_mixin.js
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -12,6 +12,7 @@ var BasicModel = require('web.BasicModel');
var FieldManagerMixin = {
custom_events: {
+ discard_changes: '_onDiscardChanges',
discard_line: '_onDiscardLine',
field_changed: '_onFieldChanged',
load: '_onLoad',
@@ -60,6 +61,20 @@ var FieldManagerMixin = {
//--------------------------------------------------------------------------
/**
+ * Controlled elements might want to ask for a discard of the changes made
+ * to a whole record (see editable list view). In that case, this handler
+ * is called and call the appropriate @see BasicModel.discardChanges
+ * function. The result is then notified thanks to a call to
+ * @see _confirmChange.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ this.model.discardChanges(ev.data.id);
+ this._confirmChange(ev.data.id, [], ev); // TODO get real list of changes ?
+ },
+ /**
* In some case, we may need to remove an element from a list, without going
* through the notifyChanges machinery. The motivation for this is when the
* user click on 'Add an item' in a field one2many with a required field,
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 f97a93e14e8..f83fdd78bcd 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -295,17 +295,6 @@ var FormController = BasicController.extend({
return this.alive(def);
},
/**
- * @override method from FieldManagerMixin
- * @private
- * @param {string} id it is ignored, because we already know it.
- * @param {string[]} fields
- * @param {OdooEvent} event the event that triggered the change
- */
- _confirmChange: function (id, fields, event) {
- var record = this.model.get(this.handle);
- this.renderer.updateWidgets(fields, record, event);
- },
- /**
* When a save operation has been confirmed from the model, this method is
* called.
*
@@ -326,7 +315,7 @@ var FormController = BasicController.extend({
return _.isObject(d) &&
(d.id === id || _.findWhere(d.data, {id: id}));
});
- this.renderer.updateWidgets([fieldsChanged], record);
+ this.renderer.confirmChange(record, record.id, [fieldsChanged]);
}
},
/**
@@ -588,7 +577,7 @@ var FormController = BasicController.extend({
this.model.setSort(event.data.id, event.data.name);
var field = event.data.field;
var state = this.model.get(this.handle);
- this.renderer.updateWidgets([field], state);
+ this.renderer.confirmChange(state, state.id, [field]);
},
});
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 ff02fc85337..05d8dd2d5b2 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -4,7 +4,6 @@ odoo.define('web.FormRenderer', function (require) {
var BasicRenderer = require('web.BasicRenderer');
var config = require('web.config');
var core = require('web.core');
-var Domain = require('web.Domain');
var _t = core._t;
var qweb = core.qweb;
@@ -13,23 +12,14 @@ var FIELD_CLASSES = {
'one2many': 'o_field_one2many',
};
-return BasicRenderer.extend({
+var FormRenderer = BasicRenderer.extend({
className: "o_form_view",
- custom_events: {
- move_next: '_onMoveNext',
- move_previous: '_onMovePrevious',
- },
/**
- * @override method from BasicRenderer
- * @param {Object} params
- * @param {string} params.mode either 'readonly' on 'edit'
+ * @override
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
this.idsForLabels = {};
- this.widgets = [];
- this.mode = params.mode;
- this.field_values = this.state.getEvalContext();
},
//--------------------------------------------------------------------------
@@ -60,8 +50,8 @@ return BasicRenderer.extend({
$label.toggleClass('o_form_invalid', !isValid);
}
- _.each(this.widgets, function (widget) {
- var isValid = widget.isValid();
+ _.each(this.allFieldWidgets[this.state.id], function (widget) {
+ var isValid = self._canWidgetBeSaved(widget);
if (isValid instanceof $.Deferred) {
defs.push(isValid.then(function (isValid) {
markWidget(widget, isValid);
@@ -81,8 +71,10 @@ return BasicRenderer.extend({
* environment with their current value (useful for widgets that can't
* detect when their value changes, e.g. field 'html').
*/
- commitChanges: function () {
- _.invoke(this.widgets, 'commitChanges');
+ commitChanges: function () { // TODO add a test
+ _.each(this.allFieldWidgets, function (recordWidgets) {
+ _.invoke(recordWidgets, 'commitChanges');
+ });
},
/**
* returns the active tab pages for each notebook
@@ -139,38 +131,13 @@ return BasicRenderer.extend({
// if fieldNames are given, we update the corresponding field widget.
// I think this is wrong, and the caller could directly call the
- // updateWidgets method
+ // confirmChange method
if (params.fieldNames) {
// only update the given fields
- return this.updateWidgets(params.fieldNames, state);
+ return this.confirmChange(state, state.id, params.fieldNames);
}
- this.field_values = state.getEvalContext();
return this._super.apply(this, arguments);
},
- /**
- * Update some field widgets with a new state. We could rerender the form
- * view from scratch, but then it would not be as efficient, and we might
- * lose some local state, such as the input focus cursor, or the scrolling
- * position.
- *
- * @param {string[]} fields list of fields to be updated
- * @param {Object} state new state
- * @param {OdooEvent} [event] the event that triggered the change
- * @returns {Deferred}
- */
- updateWidgets: function (fields, state, event) {
- this.state = state;
- this.field_values = this.state.getEvalContext();
- var defs = [];
- _.each(this.widgets, function (widget) {
- if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) {
- defs.push(widget.reset(state, event));
- }
- });
- // re-evalute the modifiers with the new state
- this._evaluateModifiers();
- return $.when.apply($, defs);
- },
//--------------------------------------------------------------------------
// Private
@@ -181,16 +148,9 @@ return BasicRenderer.extend({
*
* @private
* @param {Object} widget a field widget
- * @param {Object} field description of the field
- */
- _addFieldClassNames: function (widget, field) {
- widget.$el.addClass('o_form_field');
- // classname 'o_form_field_empty' hides the widget, so we apply it in
- // on existing records for fields with no value, in readonly mode or if the
- // field is readonly
- if (this.state.data.id && !widget.isSet() && (this.mode === 'readonly' || field.readonly)) {
- widget.$el.addClass('o_form_field_empty');
- }
+ */
+ _addFieldClassNames: function (widget) {
+ widget.$el.addClass('o_form_field'); // TODO will be removed in the CSS update
},
/**
* @private
@@ -209,21 +169,6 @@ return BasicRenderer.extend({
},
/**
* @private
- */
- _evaluateModifiers: function () {
- var self = this;
- _.each(this.nodeModifiers, function (d) {
- if ('invisible' in d.modifiers) {
- var is_invisible = new Domain(d.modifiers.invisible).compute(self.field_values);
- d.$el.toggleClass('o_form_invisible', is_invisible);
- if (d.onEvalCallback) {
- d.onEvalCallback({invisible: is_invisible});
- }
- }
- });
- },
- /**
- * @private
* @param {string} name
* @returns {string}
*/
@@ -239,16 +184,8 @@ return BasicRenderer.extend({
* @private
* @param {jQueryElement} $el
* @param {Object} node
- * @param {function} onEvalCallback
- */
- _handleAttributes: function ($el, node, onEvalCallback) {
- // register the modifiers with their corresponding $el so that they can
- // be re-evaluated at each field changed
- this.nodeModifiers.push({
- $el: $el,
- modifiers: JSON.parse(node.attrs.modifiers || "{}"),
- onEvalCallback: onEvalCallback,
- });
+ */
+ _handleAttributes: function ($el, node) {
if (node.attrs.class) {
$el.addClass(node.attrs.class);
}
@@ -287,7 +224,7 @@ return BasicRenderer.extend({
// Get the folded buttons
var folded_buttons = visible_buttons.slice(nb_buttons);
- if(folded_buttons.length === 1) {
+ if (folded_buttons.length === 1) {
unfolded_buttons = buttons;
folded_buttons = [];
}
@@ -302,7 +239,7 @@ return BasicRenderer.extend({
});
// Add the dropdown with folded buttons if any
- if(folded_buttons.length) {
+ if (folded_buttons.length) {
$result.append($("<button>", {
type: 'button',
'class': "btn btn-sm oe_stat_button o_button_more dropdown-toggle",
@@ -318,36 +255,35 @@ return BasicRenderer.extend({
}
this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
return $result;
},
/**
+ * @override
* @private
- * @param {Object} node
- * @returns {FieldWidget}
+ * @param {string} node
+ * @param {Object} record
+ * @param {Object} [options]
+ * @returns {AbstractField}
*/
- _renderFieldWidget: function (node) {
- var name = node.attrs.name;
- var field = this.state.fields[name];
+ _renderFieldWidget: function (node, record, options, modifiersOptions) {
+ var self = this;
+ modifiersOptions = _.extend({
+ callback: function (element, modifiers, record) {
+ element.$el.toggleClass('o_form_field_empty', !!( // FIXME condition is evaluated twice (label AND widget...)
+ record.data.id
+ && (modifiers.readonly || self.mode === 'readonly')
+ && !element.widget.isSet()
+ ));
+ },
+ }, modifiersOptions || {});
- var modifiers = JSON.parse(node.attrs.modifiers || "{}");
- var is_readonly = new Domain(modifiers.readonly).compute(this.field_values);
- var is_required = field.required || new Domain(modifiers.required).compute(this.field_values);
- var options = {
- mode: is_readonly ? 'readonly' : this.mode,
- required: is_required,
- idForLabel: this._getIDForLabel(name),
- viewType: 'form',
- };
- var attrs = this.state.fieldsInfo.form[name];
- var widget = new attrs.Widget(this, name, this.state, options);
- this.widgets.push(widget);
- var def = widget.__widgetRenderAndInsert(function () {});
- if (def.state() === 'pending') {
- this.defs.push(def);
- }
- widget.$el.addClass(FIELD_CLASSES[field.type]);
+ var widget = this._super(node, record, options, modifiersOptions);
+ widget.getFocusableElement().attr('id', this._getIDForLabel(node.attrs.name));
+
+ widget.$el.addClass(FIELD_CLASSES[record.fields[node.attrs.name].type]);
+ this._addFieldClassNames(widget);
this._handleAttributes(widget.$el, node);
- this._addFieldClassNames(widget, field);
return widget;
},
@@ -357,13 +293,9 @@ return BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderGenericTag: function (node) {
- var $result = $('<' + node.tag + '>');
- _.each(node.attrs, function (attr, name) {
- if (name !== 'class' && name !== 'modifiers') {
- $result.attr(name, attr);
- }
- });
+ var $result = $('<' + node.tag + '>', _.omit(node.attrs, 'modifiers'));
this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
$result.append(_.map(node.children, this._renderNode.bind(this)));
return $result;
},
@@ -378,6 +310,7 @@ return BasicRenderer.extend({
.addClass('btn btn-sm btn-default');
this._addOnClickAction($button, node);
this._handleAttributes($button, node);
+ this._registerModifiers(node, this.state, $button);
return $button;
},
/**
@@ -386,26 +319,35 @@ return BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderInnerField: function (node) {
- var field = this.state.fields[node.attrs.name];
+ var self = this;
+ var fieldName = node.attrs.name;
+ var field = this.state.fields[fieldName];
var fieldDescr = node.attrs.string || field.string;
- var hasLabel = !node.attrs.nolabel;
- var widget = this._renderFieldWidget(node);
- if (hasLabel) {
- var $label = $('<label>')
- .addClass('o_form_label')
- .attr('for', this._getIDForLabel(node.attrs.name))
- .text(fieldDescr);
- var isReadOnly = this.mode === 'readonly' || field.readonly;
- if (this.state.data.id && !widget.isSet() && isReadOnly) {
- $label.addClass('o_form_label_empty');
- }
- this._handleAttributes($label, node);
+ var widget = this._renderFieldWidget(node, this.state);
+
+ if (!node.attrs.nolabel) {
+ var $label = $('<label>', {
+ class: 'o_form_label',
+ for: this._getIDForLabel(node.attrs.name),
+ text: fieldDescr,
+ });
+ this._registerModifiers(node, this.state, $label, {
+ callback: function (element, modifiers, record) {
+ var recordWidgets = self.allFieldWidgets[record.id];
+ var widget = _.findWhere(recordWidgets, {name: fieldName});
+ element.$el.toggleClass('o_form_label_empty', !!(
+ record.data.id
+ && (modifiers.readonly || self.mode === 'readonly')
+ && !widget.isSet() // getting like this because it could have been re-rendered...
+ ));
+ },
+ });
return $('<tr>')
.append($('<td class="o_td_label">').append($label))
.append($('<td style="width: 100%">').append(widget.$el));
} else {
var style = {
- width: 100/2 + '%'
+ width: 100/2 + '%',
};
return $('<tr>')
.append($('<td colspan="1">').css(style).append(widget.$el));
@@ -419,6 +361,7 @@ return BasicRenderer.extend({
_renderInnerGroup: function (node) {
var $result = $('<table class="o_group o_inner_group"/>');
this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
if (node.attrs.string) {
var $sep = $('<tr><td colspan="2" style="width:100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>');
$result.append($sep);
@@ -522,6 +465,7 @@ return BasicRenderer.extend({
$button.append(_.map(node.children, this._renderNode.bind(this)));
this._addOnClickAction($button, node);
this._handleAttributes($button, node);
+ this._registerModifiers(node, this.state, $button);
return $button;
},
/**
@@ -568,6 +512,7 @@ return BasicRenderer.extend({
$button.append(_.map(node.children, this._renderNode.bind(this)));
this._addOnClickAction($button, node);
this._handleAttributes($button, node);
+ this._registerModifiers(node, this.state, $button);
return $button;
},
/**
@@ -576,7 +521,7 @@ return BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderTagField: function (node) {
- return this._renderFieldWidget(node).$el;
+ return this._renderFieldWidget(node, this.state).$el;
},
/**
* @private
@@ -617,6 +562,7 @@ return BasicRenderer.extend({
$child.appendTo($result);
});
this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
return $result;
},
/**
@@ -633,7 +579,7 @@ return BasicRenderer.extend({
if (child.tag === 'button') {
$buttons.append(self._renderHeaderButton(child));
} else if (child.tag === 'field') {
- var widget = self._renderFieldWidget(child);
+ var widget = self._renderFieldWidget(child, self.state);
$statusbar.append(widget.$el);
}
});
@@ -658,6 +604,7 @@ return BasicRenderer.extend({
.attr('for', this._getIDForLabel(node.attrs.for))
.text(text);
this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
return $result;
},
/**
@@ -677,15 +624,18 @@ return BasicRenderer.extend({
$header.addClass('active');
$page.addClass('active');
}
- self._handleAttributes($header, child, function onEvalCallback (attrs) {
- // if the active tab is invisible, activate the first visible tab instead
- if (attrs.invisible && $header.hasClass('active')) {
- $header.removeClass('active');
- $page.removeClass('active');
- var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()');
- $firstVisibleTab.addClass('active');
- $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active');
- }
+ self._handleAttributes($header, child);
+ self._registerModifiers(child, self.state, $header, {
+ callback: function (element, modifiers, record) {
+ // if the active tab is invisible, activate the first visible tab instead
+ if (modifiers.invisible && element.$el.hasClass('active')) {
+ element.$el.removeClass('active');
+ $page.removeClass('active');
+ var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()');
+ $firstVisibleTab.addClass('active');
+ $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active');
+ }
+ },
});
$headers.append($header);
$pages.append($page);
@@ -727,34 +677,31 @@ return BasicRenderer.extend({
*/
_renderView: function () {
var self = this;
- this.defs = [];
- this.nodeModifiers = [];
// render the form and evaluate the modifiers
+ var defs = [];
+ this.defs = defs;
var $form = this._renderNode(this.arch).addClass(this.className);
- this._evaluateModifiers();
-
- var defs = this.defs;
delete this.defs;
+
return $.when.apply($, defs).then(function () {
self.$el.html($form.contents());
- // necessary to allow all sub widgets to use their dimensions in layout
- // related activities, such as autoresize on fieldtexts
+ // Necessary to allow all sub widgets to use their dimensions in
+ // layout related activities, such as autoresize on fieldtexts
core.bus.trigger('DOM_updated');
self.$el.toggleClass('o_form_nosheet', !self.has_sheet);
self.$el.toggleClass('o_form_editable', self.mode === 'edit');
self.$el.toggleClass('o_form_readonly', self.mode === 'readonly');
// Attach the tooltips on the fields' label
- _.each(self.widgets, function (widget) {
+ _.each(self.allFieldWidgets[self.state.id], function (widget) {
if (core.debug || widget.attrs.help || widget.field.help) {
var idForLabel = self.idsForLabels[widget.name];
var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
self._addFieldTooltip(widget, $label);
}
});
-
});
},
@@ -763,34 +710,26 @@ return BasicRenderer.extend({
//--------------------------------------------------------------------------
/**
- * When someone presses TAB in a widget, it is nice to be able to go to the
- * next widget, especially since the TAB key event is preventDefaulted
- *
+ * @override
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onMoveNext: function (event) {
- event.stopPropagation();
- var index = this.widgets.indexOf(event.target);
- if (index + 1 < this.widgets.length) {
- this.widgets[index + 1].activate();
- }
+ _onMoveNext: function (ev) {
+ ev.stopPropagation();
+ var index = this.allFieldWidgets[this.state.id].indexOf(ev.target);
+ this._activateNextFieldWidget(this.state, index);
},
/**
- * When someone presses SHIFT+TAB in a widget, it is nice to be able to go
- * back to the previous widget, especially since the TAB key event is
- * preventDefaulted
- *
+ * @override
* @private
* @param {OdooEvent} event
*/
- _onMovePrevious: function (event) {
- event.stopPropagation();
- var index = this.widgets.indexOf(event.target);
- if (index >= 1) {
- this.widgets[index - 1].activate();
- }
+ _onMovePrevious: function (ev) {
+ ev.stopPropagation();
+ var index = this.allFieldWidgets[this.state.id].indexOf(ev.target);
+ this._activatePreviousFieldWidget(this.state, index);
},
});
+return FormRenderer;
});
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
index 9e4e6865c0f..5c976bba903 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -89,6 +89,7 @@ var KanbanRenderer = BasicRenderer.extend({
init: function (parent, state, params) {
this._super.apply(this, arguments);
+ this.widgets = [];
this.qweb = new QWeb(session.debug, {_s: session.origin});
var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';});
transformQwebTemplate(templates, state.fields);
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 50bb74334c4..5dcc2300eac 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -159,19 +159,6 @@ var ListController = BasicController.extend({
},
/**
* This function is the hook called by the field manager mixin to confirm
- * that a change just occured (after the eventual onchange have been
- * applied).
- *
- * @param {string} id a basicmodel id, which is a record in the list view
- * @param {string[]} fields the changed fields
- * @param {OdooEvent} event the event that triggered the change
- */
- _confirmChange: function (id, fields, event) {
- var state = this.model.get(this.handle);
- this.renderer.confirmChange(state, id, fields, event);
- },
- /**
- * This function is the hook called by the field manager mixin to confirm
* that a record has been saved.
*
* @override
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 1e24aaeb879..df7a953c632 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
@@ -4,31 +4,27 @@ odoo.define('web.EditableListRenderer', function (require) {
/**
* Editable List renderer
*
- * The list renderer is reasonably complex, so we split it in two files. This
+ * The list renderer is reasonably complex, so we split it in two files. This
* file simply 'includes' the basic ListRenderer to add all the necessary
- * behavior to enable editing records.
+ * behaviors to enable editing records.
*
* Unlike Odoo v10 and before, this list renderer is independant from the form
- * view. It uses the same widgets, but the code is totally stand alone.
+ * view. It uses the same widgets, but the code is totally stand alone.
*/
-
var core = require('web.core');
var Dialog = require('web.Dialog');
-var Domain = require('web.Domain');
var ListRenderer = require('web.ListRenderer');
var _t = core._t;
ListRenderer.include({
- custom_events: {
- field_changed: '_onFieldChanged',
+ custom_events: _.extend({}, ListRenderer.prototype.custom_events, {
move_down: '_onMoveDown',
move_up: '_onMoveUp',
move_left: '_onMoveLeft',
move_right: '_onMoveRight',
- move_next: '_onMoveNext',
move_next_line: '_onMoveNextLine',
- },
+ }),
events: _.extend({}, ListRenderer.prototype.events, {
'click tbody td': '_onCellClick',
'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
@@ -39,15 +35,12 @@ ListRenderer.include({
/**
* @override
* @param {Object} params
- * @param {string} params.mode either 'readonly' or 'edit'
* @param {boolean} params.addCreateLine
* @param {boolean} params.addTrashIcon
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
- this.mode = params.mode;
-
// if addCreateLine is true, the renderer will add a 'Add an item' link
// at the bottom of the list view
this.addCreateLine = params.addCreateLine;
@@ -88,19 +81,22 @@ ListRenderer.include({
var self = this;
var invalidDirtyWidgets = [];
var invalidCleanWidgets = [];
- _.each(this.widgets, function (widget) {
- // Note: the isValid method may return a deferred in general, but
- // not in this case. Here, this method is called by a fieldone2many
- // when it need to make sure the state is valid. So, the widgets
- // that we consider here are widgets that can be in a one2many, and
- // we don't support a list inside a list (for now at least).
- if (!widget.isValid()) {
- if (self.dirtyRows[widget.dataPointID]) {
- invalidDirtyWidgets.push(widget);
- } else {
- invalidCleanWidgets.push(widget);
+ _.each(this.allFieldWidgets, function (recordWidgets, recordID) {
+ _.each(recordWidgets, function (widget) {
+ // Note: the _canWidgetBeSaved method may return a deferred in
+ // general, but not in this case. Here, this method is called by
+ // a fieldone2many when it needs to make sure the state is
+ // valid. So, the widgets that we consider here are widgets that
+ // can be in a one2many, and we don't support a list inside a
+ // list (for now at least).
+ if (!self._canWidgetBeSaved(widget)) {
+ if (self.dirtyRows[recordID]) {
+ invalidDirtyWidgets.push(widget);
+ } else {
+ invalidCleanWidgets.push(widget);
+ }
}
- }
+ });
});
if (invalidDirtyWidgets.length) {
// TODO: ask for confirmation, open dialog, and depending on user
@@ -112,29 +108,29 @@ ListRenderer.include({
return $.when();
},
/**
- * This method is called by the controller, when a change has been confirmed
- * by the model. In that situation, we need to properly update the cell
- * widgets. (it is necessary, because the value could have been changed by
- * an onchange).
+ * @see BasicRenderer.confirmChange
*
- * Note that it could happen that the changedRecordID is not found in the
- * current state. This is possible if a change in a record caused an
- * onchange which erased the current record. In that case, we simply
- * rerender the list.
+ * We also react to changes by adding the 'o_field_dirty' css class,
+ * which changes its style to a visible orange outline. It allows the user
+ * to have a visual feedback on which fields changed, and on which fields
+ * have been saved.
*
- * @param {Object} state a record resource (the new full state)
- * @param {string} changedRecordID the local id for the changed record
- * @param {string[]} changedFields list of modified fields
- * @param {OdooEvent} event the event that triggered the change
+ * @override
+ * @param {Object} state - a record resource (the new full state)
+ * @param {string} id - the local id for the changed record
+ * @param {string[]} fields - list of modified fields
+ * @param {OdooEvent} e - the event that triggered the change
+ * @returns {Deferred}
*/
- confirmChange: function (state, changedRecordID, changedFields, event) {
- this.state = state;
- var rowIndex = _.findIndex(this.state.data, {id: changedRecordID});
- if (rowIndex > -1) {
- this._updateRow(rowIndex, changedFields, event);
- } else {
- this._render();
- }
+ confirmChange: function (state, id, fields, e) {
+ this.dirtyRows[id] = true;
+
+ return this._super.apply(this, arguments).then(function (resetWidgets) {
+ _.each(resetWidgets, function (widget) {
+ var $td = widget.$el.parent();
+ $td.addClass('o_field_dirty');
+ });
+ });
},
/**
* This method is called by the controller when the user has clicked 'save',
@@ -179,7 +175,7 @@ ListRenderer.include({
* @returns {jQueryElement} the $td for the index-th field
*/
_findTd: function ($row, index) {
- return $row.find('td').eq(index + (this.hasSelectors ? 1 : 0));
+ return $row.children(':nth(' + (index + (this.hasSelectors ? 1 : 0)) + ')');
},
/**
* Returns the current number of columns. The editable renderer may add a
@@ -196,42 +192,6 @@ ListRenderer.include({
return n;
},
/**
- * Determine if a given cell is readonly. A cell is considered readonly if
- * the field is readonly, or the widget is statically readonly (like the
- * sequence widget) of if there are a 'readonly' attr, with a matching
- * domain.
- *
- * @param {Object} record a basic model record
- * @param {Object} field a field description
- * @param {Object} node a node object (from the arch)
- * @returns {boolean}
- */
- _isReadonly: function (record, field, node) {
- if (field.readonly) {
- return true;
- }
- var Widget = this.state.fieldsInfo.list[node.attrs.name].Widget;
- if (Widget && Widget.prototype.readonly) {
- return true;
- }
- if ('readonly' in node.modifiers) {
- var fieldValues = record.getEvalContext();
- return new Domain(node.modifiers.readonly).compute(fieldValues);
- }
- return false;
- },
- /**
- * A cell is editable if its field isn't invisible and if it isn't readonly.
- *
- * @param {Object} record a basic model record
- * @param {Object} field a field description
- * @param {Object} node a node object (from the arch)
- * @returns {boolean}
- */
- _isEditable: function (record, field, node) {
- return !this._isInvisible(record, node) && !this._isReadonly(record, field, node);
- },
- /**
* The renderer needs to support reordering lines. This is only active in
* edit mode. The hasHandle attribute is used when there is a sequence
* widget.
@@ -260,65 +220,17 @@ ListRenderer.include({
* @param {Object} record
* @param {Object} node
* @param {integer} index
+ * @param {Object} [options]
* @returns {jQueryElement}
*/
- _renderBodyCell: function (record, node, index) {
+ _renderBodyCell: function (record, node, index, options) {
+ var $cell = this._super.apply(this, arguments);
if (this.mode === 'readonly') {
- return this._super.apply(this, arguments);
- }
- var $cell = this._super(record, node);
- if (node.tag === 'field') {
- var field = this.state.fields[node.attrs.name];
- if (field.required) {
- $cell.addClass('o_required_field');
- }
- if (this._isReadonly(record, field, node)) {
- $cell.addClass('o_readonly');
- }
+ return $cell;
}
return $cell.data('index', index);
},
/**
- * Helper method, to instantiate and render a field widget. It is only
- * called by _selectCell, when editing a row. It kind of looks like the
- * _renderBodyCell method of ListRenderer, but it has to properly tag the
- * widget and to do other subtly different tasks, so it is awkward to try to
- * merge them.
- *
- * @private
- * @param {jQueryElement} $row
- * @param {Object} node
- * @param {integer} rowIndex
- * @param {integer} colIndex
- * @returns {AbstractField} the instance of the desired widget
- */
- _renderFieldWidget: function ($row, node, rowIndex, colIndex) {
- if (node.tag === 'button') {
- return;
- }
- var name = node.attrs.name;
- var record = this.state.data[rowIndex];
- var field = this.state.fields[name];
- if (!this._isEditable(record, field, node)) {
- return;
- }
- var $td = this._findTd($row, colIndex);
- var Widget = this.state.fieldsInfo.list[name].Widget;
- var widget = new Widget(this, name, record, {
- mode: 'edit',
- viewType: 'list',
- });
- if (widget.replace_element) {
- $td.empty();
- }
- $td.addClass('o_edit_mode');
- widget.appendTo($td);
- widget.__rowIndex = rowIndex;
- widget.__colIndex = colIndex;
- this.widgets.push(widget);
- return widget;
- },
- /**
* Editable rows are extended with their index, and possibly a trash icon on
* their right, to allow deleting the corresponding record.
*
@@ -362,7 +274,7 @@ ListRenderer.include({
/**
* This is one of the trickiest method in the editable renderer. It has to
* do a lot of stuff: it has to determine which cell should be selected (if
- * the terget cell is readonly, we need to find another suitable cell), then
+ * the target cell is readonly, we need to find another suitable cell), then
* unselect the current row, and activate the line where the selected cell
* is, if necessary.
*
@@ -370,118 +282,97 @@ ListRenderer.include({
* @param {integer} colIndex
*/
_selectCell: function (rowIndex, colIndex) {
- var self = this;
-
- // do nothing if user tries to select current cell
+ // Do nothing if the user tries to select current cell
if (rowIndex === this.currentRow && colIndex === this.currentCol) {
return;
}
- // make sure the colIndex is on an editable field. otherwise, find
- // next editable field, or do nothing if no such field exists
- var data = this.state.data;
- var fields = this.state.fields;
- var col, field, isEditable, record;
- for (var i = 0; i < this.columns.length; i++) {
- col = this.columns[colIndex];
- record = data[rowIndex];
- field = fields[col.attrs.name];
- isEditable = this._isEditable(record, field, col);
- if (isEditable) {
- break;
- }
- colIndex = (colIndex + 1) % this.columns.length;
- }
- if (!isEditable) {
- return;
- }
-
- // if we are just changing active cell in the same row, activate the
- // corresponding widget and return
- if (rowIndex === this.currentRow) {
- var w = _.findWhere(this.widgets, {
- __rowIndex: rowIndex,
- __colIndex: colIndex
- });
- this.currentCol = colIndex;
- w.activate();
- return;
- }
+ var record = this.state.data[rowIndex];
- // if this is a different column, then we need to prepare for it by
- // unselecting the current row. This will trigger the save.
+ // If this is a different row, then we need to prepare for it by
+ // unselecting the current row and selecting the new one.
+ var def = $.when();
if (rowIndex !== this.currentRow) {
- this._unselectRow();
+ def = this._unselectRow(); // This will trigger the save
+ def.then(this._selectRow.bind(this, rowIndex));
}
- // instantiate column widgets
- var $row = this.$('tbody tr').eq(rowIndex);
- $row.addClass('o_selected_row');
- // force the width of each cell to its current value so that it won't
+ // In all cases (new row or not), activate the proper column widget
+ var self = this;
+ def.then(function () {
+ var fieldIndex = self._activateFieldWidget(record, colIndex - getNbButtonBefore(colIndex));
+ if (fieldIndex < 0) {
+ return self._unselectRow();
+ }
+
+ self.currentCol = fieldIndex + getNbButtonBefore(fieldIndex);
+
+ function getNbButtonBefore(index) {
+ var nbButtons = 0;
+ for (var i = 0 ; i < index ; i++) {
+ if (self.columns[i].tag === 'button') {
+ nbButtons++;
+ }
+ }
+ return nbButtons;
+ }
+ });
+ },
+ /**
+ * Activates the row at the given row index.
+ *
+ * @param {integer} rowIndex
+ */
+ _selectRow: function (rowIndex) {
+ this.currentRow = rowIndex;
+
+ var self = this;
+ var record = this.state.data[rowIndex];
+
+ // Force the width of each cell to its current value so that it won't
// change if its content changes, to prevent the view from flickering
- $row.find('td').each(function () {
+ var $row = this.$('tbody > tr:nth(' + rowIndex + ')');
+ $row.addClass('o_selected_row');
+ $row.children().each(function () {
var $td = $(this);
$td.width($td.width());
});
+ // Instantiate column widgets and destroy potential readonly ones
+ var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
_.each(this.columns, function (node, index) {
- var widget = self._renderFieldWidget($row, node, rowIndex, index);
- if (widget && index === colIndex) {
- widget.activate();
+ var $newTd = self._renderBodyCell(record, node, index, {
+ renderInvisible: true,
+ renderWidgets: true,
+ });
+ var $td = self._findTd($row, index);
+ if ($td.hasClass('o_list_button')) {
+ self._unregisterModifiersElement(node, record, $td.children()); // FIXME this is ugly...
}
+ $td.empty().append($newTd.contents());
});
+ _.each(oldWidgets, this._destroyFieldWidget.bind(this, record));
- this.currentRow = rowIndex;
- this.currentCol = colIndex;
this.trigger_up('change_mode', {mode: 'edit'});
},
/**
* Set the value of a cell. This method can be called when the value of a
- * cell was modified, for example afte an onchange.
+ * cell was modified, for example after an onchange.
*
* @param {integer} rowIndex
* @param {integer} colIndex
* @param {boolean} keepDirtyFlag
- * @param {boolean} forceDirtyFlag
*/
- _setCellValue: function (rowIndex, colIndex, keepDirtyFlag, forceDirtyFlag) {
+ _setCellValue: function (rowIndex, colIndex, keepDirtyFlag) {
var record = this.state.data[rowIndex];
var node = this.columns[colIndex];
- var $td = this._findTd(this.$('tbody tr').eq(rowIndex), colIndex);
- var $new_td = this._renderBodyCell(record, node, colIndex);
- if (keepDirtyFlag && $td.hasClass('o_field_dirty') || forceDirtyFlag) {
- $new_td.addClass('o_field_dirty');
+ var $oldTd = this._findTd(this.$('tbody > tr:nth(' + rowIndex + ')'), colIndex);
+ var $newTd = this._renderBodyCell(record, node, colIndex, {mode: 'readonly'});
+ if (keepDirtyFlag && $oldTd.hasClass('o_field_dirty')) {
+ $newTd.addClass('o_field_dirty');
}
- $td.replaceWith($new_td);
- },
- /**
- * Updates the row of a particular record of the editable list view.
- *
- * @param {index} index the index of the record whose row needs updating
- * @param {string[]} changedFields names of the fields which changed
- * @param {OdooEvent} event the event that triggered the change
- */
- _updateRow: function (index, changedFields, event) {
- var self = this;
- var record = this.state.data[index];
-
- // filter out fields that are not present in the view. They may come
- // from an onchange, but we need to ignore them.
- changedFields = _.filter(changedFields, function (fieldName) {
- return _.findWhere(self.columns, {name: fieldName});
- });
- _.each(changedFields, function (field_name) {
- var widget = _.findWhere(self.widgets, {name: field_name});
- if (widget && widget.__rowIndex === index) {
- widget.reset(record, event);
- widget.$el.parent().addClass('o_field_dirty');
- } else {
- var column_index = _.findIndex(self.columns, function (column) {
- return column.attrs.name === field_name;
- });
- self._setCellValue(index, column_index, true, true);
- }
- });
+ this._unregisterModifiersElement(node, record, $oldTd);
+ $oldTd.replaceWith($newTd);
},
/**
* This method is called whenever we click/move outside of a row that was
@@ -491,79 +382,76 @@ ListRenderer.include({
* If the current row is valid, then it can be unselected. If it is not
* valid, we need to check if it is dirty. If it is not dirty, it can be
* unselected (and will be removed). If it is dirty, we need to ask the user
- * for confirmation. If the user agrees, the row will be removed, otherwise
+ * for confirmation. If the user agrees, the row will be removed, otherwise
* nothing happens (it is still selected)
*
* @returns {Deferred} The deferred resolves if the row was unselected (and
- * possibly removed). If may be rejected, when the row is dirty and the
+ * possibly removed). If may be rejected, when the row is dirty and the
* user refuses to discard its changes.
*/
_unselectRow: function () {
- var self = this;
-
- // We need to protect against calling this method when no row is
- // selected.
+ // Protect against calling this method when no row is selected
if (this.currentRow === null) {
return $.when();
}
- var discarded = false;
-
- function discardLine() {
- self.trigger_up('discard_line', {id: currentLineID});
- discarded = true;
- def.resolve();
+ var self = this;
+ var record = this.state.data[this.currentRow];
+ var rowWidgets = _.clone(this.allFieldWidgets[record.id]);
+ var isRowValid = _.every(rowWidgets, this._canWidgetBeSaved.bind(this));
+ var $row = this.$('tbody > tr.o_selected_row');
+
+ // If the row is not valid and is dirty, we have to ask the user
+ // confirmation before going further
+ var def = $.when();
+ if (!isRowValid && this.dirtyRows[record.id]) {
+ def = $.Deferred();
+ var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?");
+ var dialog = Dialog.confirm(this, message, {
+ title: _t("Warning"),
+ confirm_callback: def.resolve.bind(def),
+ cancel_callback: def.reject.bind(def),
+ });
+ dialog.$modal.on('hidden.bs.modal', def.reject.bind(def));
}
- var rowWidgets = _.where(this.widgets, {__rowIndex: this.currentRow});
- var isRowValid = _.every(rowWidgets, function (w) { return w.isValid();});
- var $row = this.$('tbody tr.o_selected_row');
-
- var def;
-
- if (isRowValid) {
- // trigger the save (if the record isn't dirty, the datamodel won't save
- // anything)
- self.trigger_up('field_changed', {
- dataPointID: self.state.data[self.currentRow].id,
- changes: {},
- force_save: true,
- });
- self.trigger_up('change_mode', {mode: 'readonly'});
- $row.removeClass('o_selected_row');
- $row.find('td').removeClass('o_edit_mode');
- def = $.when();
- } else {
- def = $.Deferred();
- var currentLineID = this.state.data[this.currentRow].id;
- var isDirty = this.dirtyRows[currentLineID];
- if (isDirty) {
- // if the currently selected record is dirty, we want to ask for
- // confirmation before discarding it
- var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?");
- var options = {
- title: _t("Warning"),
- confirm_callback: discardLine,
- cancel_callback: def.reject.bind(def)
- };
- var dialog = Dialog.confirm(this, message, options);
- dialog.$modal.on('hidden.bs.modal', def.reject.bind(def));
+ return def.then(function finalizeUnselect() {
+ var rowRemoved = false;
+ var keepDirtyFlag = true;
+ if (isRowValid) {
+ // Trigger the save (if the record isn't dirty, the datamodel
+ // won't save anything)
+ self.trigger_up('field_changed', {
+ dataPointID: record.id,
+ changes: {},
+ force_save: true,
+ });
+ } else if (record.res_id) {
+ // Not a creation, tell the model to discard the changes
+ self.trigger_up('discard_changes', {id: record.id});
+ keepDirtyFlag = false;
} else {
- discardLine();
+ // Creation, tell the model to discard the row and remove the
+ // associated row DOM element
+ self.trigger_up('discard_line', {id: record.id});
+ $row.remove(); // FIXME row indexes are not updated... let's remove data('index') and check index dynamically ?
+ rowRemoved = true;
}
- }
- return def.then(function finalizeUnselect() {
- var widgets = _.where(self.widgets, {__rowIndex: self.currentRow});
- _.each(widgets, function (widget) {
- self._setCellValue(self.currentRow, widget.__colIndex, true);
- widget.destroy();
- });
- if (discarded) {
- $row.remove();
+ // Update row if it was not removed
+ if (!rowRemoved) {
+ $row.removeClass('o_selected_row');
+ _.each(self.columns, function (col, colIndex) {
+ self._setCellValue(self.currentRow, colIndex, keepDirtyFlag);
+ });
}
- self.widgets = _.without(self.widgets, widgets);
+
+ // Destroy the edition widgets
+ _.each(rowWidgets, self._destroyFieldWidget.bind(self, record));
+
+ delete self.dirtyRows[record.id];
self.currentRow = null;
+ self.trigger_up('change_mode', {mode: 'readonly'});
});
},
@@ -628,21 +516,8 @@ ListRenderer.include({
this._unselectRow();
},
/**
- * We react to field_changed events by adding the .o_field_dirty css class,
- * which changes its style to a visible orange outline. It allows the user
- * to have a visual feedback on which fields changed, and on which fields
- * have been saved.
- *
- * @param {OdooEvent} event
- */
- _onFieldChanged: function (event) {
- this.dirtyRows[event.data.dataPointID] = true;
- var $td = event.target.$el.parent();
- $td.addClass('o_field_dirty');
- },
- /**
* Clicking on a footer should unselect (and save) the currently selected
- * row. It has to be done this way, because this is a click inside this.el,
+ * row. It has to be done this way, because this is a click inside this.el,
* and _onWindowClicked ignore those clicks.
*/
_onFooterClick: function () {
@@ -669,13 +544,13 @@ ListRenderer.include({
* cell on the right, or the first on the next line. This method is called
* when the user press the TAB key.
*
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onMoveNext: function (event) {
+ _onMoveNext: function (ev) {
// we need to stop the event, to prevent interference with some other
// component up there, such as a form renderer.
- event.stopPropagation();
- if (this.currentCol < this.columns.length - 1) {
+ ev.stopPropagation();
+ if (this.currentCol + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentCol + 1);
} else if (this.currentRow < this.state.data.length - 1) {
this._selectCell(this.currentRow + 1, 0);
@@ -691,10 +566,27 @@ ListRenderer.include({
}
},
/**
+ * Move the cursor on the previous available cell, so either the previous
+ * available cell on the left, or the last on the previous line. This method
+ * is called when the user press the TAB key while holding the shift key.
+ *
+ * @param {OdooEvent} ev
+ */
+ _onMovePrevious: function (ev) {
+ // we need to stop the event, to prevent interference with some other
+ // component up there, such as a form renderer.
+ ev.stopPropagation();
+ if (this.currentCol > 0) {
+ this._selectCell(this.currentRow, this.currentCol - 1);
+ } else if (this.currentRow > 0) {
+ this._selectCell(this.currentRow - 1, this.columns.length - 1);
+ }
+ },
+ /**
* Move the cursor one cell right, if possible
*/
_onMoveRight: function () {
- if (this.currentCol < this.columns.length - 1) {
+ if (this.currentCol + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentCol + 1);
}
},
@@ -770,7 +662,6 @@ ListRenderer.include({
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 c51cae980da..b987929bce0 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -4,7 +4,6 @@ odoo.define('web.ListRenderer', function (require) {
var BasicRenderer = require('web.BasicRenderer');
var config = require('web.config');
var core = require('web.core');
-var Domain = require('web.Domain');
var field_utils = require('web.field_utils');
var Pager = require('web.Pager');
var session = require('web.session');
@@ -51,19 +50,11 @@ var ListRenderer = BasicRenderer.extend({
this._super.apply(this, arguments);
var self = this;
this.hasHandle = false;
- this._processModifiers();
this.columns = _.reject(this.arch.children, function (c) {
- if (c.attrs.invisible === '1') {
- return true;
- }
- if (c.modifiers.tree_invisible) {
- return true;
- }
if (c.attrs.widget === 'handle') {
self.hasHandle = true;
}
- c.name = c.attrs.name;
- return false;
+ return !!JSON.parse(c.attrs.modifiers || "{}").tree_invisible;
});
this.rowDecorations = _.chain(this.arch.attrs)
.pick(function (value, key) {
@@ -213,22 +204,6 @@ var ListRenderer = BasicRenderer.extend({
return this.hasSelectors ? n+1 : n;
},
/**
- * Determine if a given cell is invisible. A cell is considered invisible
- * if there is an 'invisible' attr, with a matching domain.
- *
- * @private
- * @param {Object} record a basic model record
- * @param {Object} node a node object (from the arch)
- * @returns {boolean}
- */
- _isInvisible: function (record, node) {
- if ('invisible' in node.modifiers) {
- var fieldValues = record.getEvalContext();
- return new Domain(node.modifiers.invisible).compute(fieldValues);
- }
- return false;
- },
- /**
* Render a list of <td>, with aggregates if available. It can be displayed
* in the footer, or for each open groups.
*
@@ -267,56 +242,88 @@ var ListRenderer = BasicRenderer.extend({
return $('<tbody>').append($rows);
},
/**
- * Render a cell for the table. For most cells, we only want to display the
- * formatted value, with some appropriate css class. However, when the
+ * Render a cell for the table. For most cells, we only want to display the
+ * formatted value, with some appropriate css class. However, when the
* node was explicitely defined with a 'widget' attribute, then we
* instantiate the corresponding widget.
*
* @private
* @param {Object} record
* @param {Object} node
+ * @param {integer} colIndex
+ * @param {Object} [options]
+ * @param {Object} [options.mode]
+ * @param {Object} [options.renderInvisible=false]
+ * force the rendering of invisible cell content
+ * @param {Object} [options.renderWidgets=false]
+ * force the rendering of the cell value thanks to a widget
* @returns {jQueryElement} a <td> element
*/
- _renderBodyCell: function (record, node) {
- var self = this;
- var $td = $('<td>');
- if (this._isInvisible(record, node)) {
- return $td;
+ _renderBodyCell: function (record, node, colIndex, options) {
+ var tdClassName;
+ if (node.tag === 'button') {
+ tdClassName = 'o_list_button';
+ } else if (node.attrs.widget){
+ tdClassName = ('o_' + node.attrs.widget + '_cell');
+ } else {
+ tdClassName = FIELD_CLASSES[this.state.fields[node.attrs.name].type];
}
- var name = node.attrs.name;
- if (node.attrs.widget) {
- var Widget = this.state.fieldsInfo.list[name].Widget;
- var widget = new Widget(this, name, record, {
- mode: 'readonly',
- viewType: 'list',
- });
- widget.appendTo($td);
- $td.addClass('o_' + node.attrs.widget + '_cell');
+ var $td = $('<td>', { class: tdClassName });
+
+ // We register modifiers on the <td> element so that it gets the correct
+ // modifiers classes (for styling)
+ var modifiers = this._registerModifiers(node, record, $td);
+ // If the invisible modifiers is true, the <td> element is left empty.
+ // Indeed, if the modifiers was to change the whole cell would be
+ // rerendered anyway.
+ if (modifiers.invisible && !(options && options.renderInvisible)) {
return $td;
}
+
if (node.tag === 'button') {
- var $button = $('<button type="button">').addClass('o_icon_button');
- $button.append($('<i>').addClass('fa').addClass(node.attrs.icon))
- .prop('title', node.attrs.string)
- .click(function (e) {
- e.stopPropagation();
- self.trigger_up('button_clicked', {
- attrs: node.attrs,
- record: record,
- });
- });
- $td.append($button);
- $td.addClass('o_list_button').click(function (e) {
- // prevent opening or editing the record on cell click
- e.stopPropagation();
- });
- return $td;
+ return $td.append(this._renderButton(record, node));
}
+ if (node.attrs.widget || (options && options.renderWidgets)) {
+ this.defs = []; // TODO maybe wait for those somewhere ?
+ var widget = this._renderFieldWidget(node, record, _.pick(options, 'mode'));
+ delete this.defs;
+ return $td.append(widget.$el);
+ }
+ var name = node.attrs.name;
var field = this.state.fields[name];
var value = record.data[name];
- $td.addClass(FIELD_CLASSES[field.type]);
- var formatted_value = field_utils.format[field.type](value, field, { data: record.data });
- return $td.html(formatted_value);
+ var formattedValue = field_utils.format[field.type](value, field, { data: record.data });
+ return $td.html(formattedValue);
+ },
+ /**
+ * Renders the button element associated to the given node and record.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} node
+ * @returns {jQuery} a <button> element
+ */
+ _renderButton: function (record, node) {
+ var $button = $('<button>', {
+ type: 'button',
+ class: 'o_icon_button',
+ title: node.attrs.string,
+ });
+ $button.append($('<i>', {class: 'fa ' + node.attrs.icon}));
+
+ this._registerModifiers(node, record, $button);
+
+ // TODO this should be moved to a handler
+ var self = this;
+ $button.on("click", function (e) {
+ e.stopPropagation();
+ self.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ });
+
+ return $button;
},
/**
* Render a complete empty row. This is used to fill in the blanks when we
@@ -544,7 +551,10 @@ var ListRenderer = BasicRenderer.extend({
*/
_renderRow: function (record) {
var decorations = this._computeDecorationClassNames(record);
- var $cells = _.map(this.columns, this._renderBodyCell.bind(this, record));
+ var self = this;
+ var $cells = _.map(this.columns, function (node, index) {
+ return self._renderBodyCell(record, node, index, {mode: 'readonly'});
+ });
var $tr = $('<tr class="o_data_row">')
.data('id', record.id)
.addClass(decorations.length && decorations.join(' '))
@@ -623,17 +633,6 @@ var ListRenderer = BasicRenderer.extend({
return this._super();
},
/**
- * Process the modifiers by setting their parsed version on the
- * corresponding nodes.
- *
- * @private
- */
- _processModifiers: function () {
- _.each(this.arch.children, function (c) {
- c.modifiers = JSON.parse(c.attrs.modifiers || '{}');
- });
- },
- /**
* 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.
@@ -713,5 +712,4 @@ var ListRenderer = BasicRenderer.extend({
});
return ListRenderer;
-
});