summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src')
-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
-rw-r--r--addons/web/static/src/less/new_views.less63
16 files changed, 1012 insertions, 705 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;
-
});
diff --git a/addons/web/static/src/less/new_views.less b/addons/web/static/src/less/new_views.less
index cb43686fbeb..f8c0ca2e7b6 100644
--- a/addons/web/static/src/less/new_views.less
+++ b/addons/web/static/src/less/new_views.less
@@ -5,63 +5,44 @@
.o_list_view {
tr.o_selected_row {
outline: 1px solid lightgray;
- }
- td.o_edit_mode {
- position: relative;
- outline: 1px solid gray;
- &.o_required_field {
- outline-width: 2px;
- z-index: 1;
- }
- background-color: white;
+ > td:not(.o_readonly):not(.o_list_button) {
+ position: relative;
+ background-color: white;
- .o_form_input {
- border: none;
- padding: 0 5px;
- }
+ > .o_field_widget, > .o_form_field_many2one {
+ .o-position-absolute(0, 0, 0, 0);
+ }
- > .o_field_widget, > .o_form_field_many2one {
- .o-position-absolute(0, 0, 0, 0);
- }
+ .o_form_field_many2one .o_form_input_dropdown {
+ .o-flex(1, 1, auto);
- .o_form_field_many2one .o_form_input_dropdown {
- .o-flex(1, 1, auto);
+ .o_form_input {
+ height: 100%;
+ }
- .o_form_input {
- height: 100%;
+ .o_dropdown_button {
+ display: none;
+ }
}
- .o_dropdown_button {
- display: none;
+ .o_form_field_many2manytags {
+ .o_form_field_many2one {
+ height: 100%;
+ display: inline-block;
+ }
}
- }
- .o_form_field_many2manytags {
- .o_form_field_many2one {
- height: 100%;
- display: inline-block;
+ .o_field_boolean {
+ .o-flex-display();
+ .o-align-items(center);
}
}
-
- .o_field_boolean {
- .o-flex-display();
- .o-align-items(center);
- }
}
td.o_field_dirty {
outline: 1px solid orange;
}
- //
- // &.o_readonly {
- // outline: 2px solid lightgray;
- // }
- // div.o_field_dirty {
- // outline: 1px solid orange;
- // }
- // }
-
}
////////////////////////////////////////////////////////////////////////////////