summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorqsm-odoo <qsm@odoo.com>2017-04-18 10:43:10 +0200
committerqsm-odoo <qsm@odoo.com>2017-04-19 11:42:01 +0200
commit7ff8aa73e17b281b3e2bc4c7275d020eab0204e7 (patch)
treed45b9ea0299c66bc1bb448634163d8aa686050b5 /addons/web/static
parentff86e5b9862deab9a9772774e941140966a9ab7c (diff)
[REF] web, *: implement modifiers behaviors
* account, mail, sale Since the views refactoring, modifiers were not properly handled anymore (readonly/required not recomputed, use of readonly/required of "python field" instead of "view field", ...). This commit tries to implement a system which allows to handle the modifiers (re)computation the same way for all the basic views. For this to work, many specialized renderers behaviors have been moved to the basic renderer so that more mechanisms are shared (especially by the form renderer and the list editable renderer). The list editable renderer is hugely impacted by this commit. Here is a list of key changes: - The basic renderer now has a `_renderFieldWidget` function which is used without modification by the form and the list renderers. - Modifiers have to be registered thanks to the basic renderer `_registerModifiersData` function (this is done automatically for fields by the `_renderFieldWidget` function for example). - All instantiated widgets are accessible, organized by record, and ordered in a special basic renderer variable. - The code which resets the widgets of the form view and the code which updates the row of a list editable view is now shared in the basic renderer and now also automatically updates the DOM according to the reevaluated modifiers. - The last point has an huge impact on list editable renderer: all the widgets have to be instantiated when editing a row (even readonly and invisible ones) as they can be switched to editable/visible during edition (after a modifiers update). - `replace_element` and `readonly` options of `AbstractField` are useless: widgets are now always replacing the list editable cells in edition and the `readonly` widget mechanism is replaced by the notion of 'focusable' widgets. (see `AbstractField.getFocusableElement`) - The 'tab' navigation mechanism is impacted by the previous point, some code sharing have been done in basic renderer for this too. - `AbstractField` does not care anymore of the 'required' status, this is the view responsability to check that a required field has a set value on save. - Some list editable bug fixing (e.g. it was possible to edit multiple rows at the same time, it was not possible to navigate towards the previous field, crash on o2m add an item click, ...) - The mockserver now properly simulates server modifiers computation. - ... Note: list editable style may be worse than before this commit but a CSS update is coming in a few days.
Diffstat (limited to 'addons/web/static')
-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
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js90
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js22
-rw-r--r--addons/web/static/tests/helpers/mock_server.js57
-rw-r--r--addons/web/static/tests/views/form_tests.js119
-rw-r--r--addons/web/static/tests/views/list_tests.js237
21 files changed, 1426 insertions, 816 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;
- // }
- // }
-
}
////////////////////////////////////////////////////////////////////////////////
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index f32f0b6ac61..7d7aabb5617 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -182,17 +182,17 @@ QUnit.module('basic_fields', {
// Edit a line
var $cell = list.$('tr.o_data_row:has(.o_checkbox input:checked) td:not(.o_list_record_selector)').first();
assert.ok($cell.find('.o_checkbox input:checked').prop('disabled'),
- "input should be disabled in readonly mode")
+ "input should be disabled in readonly mode");
$cell.click();
assert.ok(!$cell.find('.o_checkbox input:checked').prop('disabled'),
- "input should not have the disabled property in edit mode")
+ "input should not have the disabled property in edit mode");
$cell.find('.o_checkbox input:checked').click();
// save
list.$buttons.find('.o_list_button_save').click();
$cell = list.$('tr.o_data_row:has(.o_checkbox input:not(:checked)) td:not(.o_list_record_selector)').first();
assert.ok($cell.find('.o_checkbox input:not(:checked)').prop('disabled'),
- "input should be disabled again")
+ "input should be disabled again");
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input').length, 5,
"should still have 5 checkboxes");
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input:checked').length, 3,
@@ -200,9 +200,8 @@ QUnit.module('basic_fields', {
// Re-Edit the line and fake-check the checkbox
$cell.click();
- var $checkbox = $cell.find('.o_checkbox input:not(:checked)');
- $checkbox.click(); // Change the checkbox
- $checkbox.click(); // Undo the change
+ $cell.find('.o_checkbox input').click(); // Change the checkbox
+ $cell.find('.o_checkbox input').click(); // Undo the change
// Save
list.$buttons.find('.o_list_button_save').click();
@@ -304,7 +303,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('float field in form view', function(assert) {
+ QUnit.test('float field in form view', function (assert) {
assert.expect(5);
var form = createView({
@@ -322,20 +321,20 @@ QUnit.module('basic_fields', {
assert.ok(!form.$('.o_form_field').hasClass('o_form_field_empty'),
'Float field should be considered set for value 0.');
assert.strictEqual(form.$('.o_form_field.o_form_field_number').first().text(), '0.000',
- 'The value should be displayed properly.')
+ 'The value should be displayed properly.');
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('input.o_form_input').val(), '0.000',
- 'The value should be rendered with correct precision.')
+ 'The value should be rendered with correct precision.');
form.$('input.o_form_input').val('108.2458938598598').trigger('input');
assert.strictEqual(form.$('input.o_form_input').val(), '108.2458938598598',
- 'The value should not be formated yet.')
+ 'The value should not be formated yet.');
form.$('input.o_form_input').val('18.8958938598598').trigger('input');
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('.o_form_field.o_form_field_number').first().text(), '18.896',
- 'The new value should be rounded properly.')
+ 'The new value should be rounded properly.');
form.destroy();
});
@@ -352,7 +351,7 @@ QUnit.module('basic_fields', {
'</tree>',
});
- var zeroValues = list.$('td').filter(function() {return $(this).text() === '0.000'});
+ var zeroValues = list.$('td').filter(function () {return $(this).text() === '0.000';});
assert.strictEqual(zeroValues.length, 2,
'Unset float values should be rendered as zeros.');
@@ -361,16 +360,16 @@ QUnit.module('basic_fields', {
$cell.click();
assert.strictEqual(list.$('input.o_form_input').length, 1,
- 'The view should have 1 input for editable float.')
+ 'The view should have 1 input for editable float.');
list.$('input.o_form_input').val('108.2458938598598').trigger('input');
assert.strictEqual(list.$('input.o_form_input').val(), '108.2458938598598',
- 'The value should not be formated yet.')
+ 'The value should not be formated yet.');
list.$('input.o_form_input').val('18.8958938598598').trigger('input');
list.$buttons.find('.o_list_button_save').click();
assert.strictEqual(list.$('.o_form_field.o_form_field_number').first().text(), '18.896',
- 'The new value should be rounded properly.')
+ 'The new value should be rounded properly.');
list.destroy();
});
@@ -424,7 +423,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('email field in editable list view', function(assert) {
+ QUnit.test('email field in editable list view', function (assert) {
assert.expect(10);
var list = createView({
@@ -448,15 +447,15 @@ QUnit.module('basic_fields', {
// Edit a line and check the result
var $cell = list.$('tbody td:not(.o_list_record_selector)').first();
$cell.click();
- assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode');
+ assert.ok($cell.parent().hasClass('o_selected_row'), 'should be set as edit mode');
assert.strictEqual($cell.find('input').val(), 'yop',
'should have the corect value in internal input');
- $cell.find('input').val('new').trigger('input');;
+ $cell.find('input').val('new').trigger('input');
// save
list.$buttons.find('.o_list_button_save').click();
$cell = list.$('tbody td:not(.o_list_record_selector)').first();
- assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore');
+ assert.ok(!$cell.parent().hasClass('o_selected_row'), 'should not be in edit mode anymore');
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'new',
"value should be properly updated");
$mailtoLink = list.$('a.o_form_uri.o_field_widget.o_text_overflow');
@@ -485,7 +484,7 @@ QUnit.module('basic_fields', {
res_id: 1,
});
- var charField = form.renderer.widgets[0];
+ var charField = _.find(form.renderer.allFieldWidgets)[0];
assert.strictEqual(charField.isValid(), true);
form.destroy();
});
@@ -527,7 +526,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('char field in editable list view', function(assert) {
+ QUnit.test('char field in editable list view', function (assert) {
assert.expect(6);
var list = createView({
@@ -545,15 +544,15 @@ QUnit.module('basic_fields', {
// Edit a line and check the result
var $cell = list.$('tbody td:not(.o_list_record_selector)').first();
$cell.click();
- assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode');
+ assert.ok($cell.parent().hasClass('o_selected_row'), 'should be set as edit mode');
assert.strictEqual($cell.find('input').val(), 'yop',
'should have the corect value in internal input');
- $cell.find('input').val('brolo').trigger('input');;
+ $cell.find('input').val('brolo').trigger('input');
// save
list.$buttons.find('.o_list_button_save').click();
$cell = list.$('tbody td:not(.o_list_record_selector)').first();
- assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore');
+ assert.ok(!$cell.parent().hasClass('o_selected_row'), 'should not be in edit mode anymore');
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'brolo',
"value should be properly updated");
list.destroy();
@@ -608,7 +607,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('char field in editable list view', function(assert) {
+ QUnit.test('char field in editable list view', function (assert) {
assert.expect(10);
var list = createView({
@@ -630,15 +629,15 @@ QUnit.module('basic_fields', {
// Edit a line and check the result
var $cell = list.$('tbody td:not(.o_list_record_selector)').first();
$cell.click();
- assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode');
+ assert.ok($cell.parent().hasClass('o_selected_row'), 'should be set as edit mode');
assert.strictEqual($cell.find('input').val(), 'yop',
'should have the corect value in internal input');
- $cell.find('input').val('brolo').trigger('input');;
+ $cell.find('input').val('brolo').trigger('input');
// save
list.$buttons.find('.o_list_button_save').click();
$cell = list.$('tbody td:not(.o_list_record_selector)').first();
- assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore');
+ assert.ok(!$cell.parent().hasClass('o_selected_row'), 'should not be in edit mode anymore');
assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').length, 5,
"should still have 5 anchors with correct classes");
assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').first().attr('href'), 'brolo',
@@ -921,7 +920,7 @@ QUnit.module('basic_fields', {
QUnit.module('HandleWidget');
QUnit.test('handle widget', function (assert) {
- assert.expect(7);
+ assert.expect(6);
this.data.partner.records[0].p = [2, 4];
var form = createView({
@@ -949,9 +948,6 @@ QUnit.module('basic_fields', {
form.$buttons.find('.o_form_button_edit').click();
- assert.ok(form.$('td:first').hasClass('o_readonly'),
- "column should be displayed as readonly");
-
assert.ok(form.$('td:first').hasClass('o_handle_cell'),
"column widget should be displayed in css class");
@@ -1677,7 +1673,7 @@ QUnit.module('basic_fields', {
// Edit a line and check the result
var $cell = list.$('tbody td:not(.o_list_record_selector)').first();
$cell.click();
- assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode');
+ assert.ok($cell.parent().hasClass('o_selected_row'), 'should be set as edit mode');
assert.strictEqual($cell.find('input').val(), 'yop',
'should have the corect value in internal input');
$cell.find('input').val('new').trigger('input');
@@ -1685,7 +1681,7 @@ QUnit.module('basic_fields', {
// save
list.$buttons.find('.o_list_button_save').click();
$cell = list.$('tbody td:not(.o_list_record_selector)').first();
- assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore');
+ assert.ok(!$cell.parent().hasClass('o_selected_row'), 'should not be in edit mode anymore');
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'n\u00ADew',
"value should be properly updated");
$phoneLink = list.$('a.o_form_uri.o_field_widget.o_text_overflow');
@@ -1787,15 +1783,15 @@ QUnit.module('basic_fields', {
// Edit a line and check the result
var $cell = list.$('tbody td:not(.o_list_record_selector)').first();
$cell.click();
- assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode');
+ assert.ok($cell.parent().hasClass('o_selected_row'), 'should be set as edit mode');
assert.strictEqual($cell.find('input').val(), 'yop',
'should have the corect value in internal input');
- $cell.find('input').val('new').trigger('input');;
+ $cell.find('input').val('new').trigger('input');
// save
list.$buttons.find('.o_list_button_save').click();
$cell = list.$('tbody td:not(.o_list_record_selector)').first();
- assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore');
+ assert.ok(!$cell.parent().hasClass('o_selected_row'), 'should not be in edit mode anymore');
assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'new',
"value should be properly updated");
assert.strictEqual(list.$('span.o_field_widget.o_text_overflow:not(.o_form_uri)').length, 5,
@@ -2100,8 +2096,8 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('state_selection widget in editable list view', function(assert) {
- assert.expect(34);
+ QUnit.test('state_selection widget in editable list view', function (assert) {
+ assert.expect(32);
var list = createView({
View: ListView,
@@ -2125,10 +2121,8 @@ QUnit.module('basic_fields', {
// Click on the status button to make the dropdown appear
var $cell = list.$('tbody td.o_state_selection_cell').first();
list.$('.o_state_selection_cell .o_selection > a span.o_status').first().click();
- assert.ok(!$cell.hasClass('o_edit_mode'),
- 'should not be in edit mode since we clicked on the widget and not the cell itself');
- assert.ok(!$cell.siblings().hasClass('o_edit_mode'),
- 'siblings should not be in edit mode since we clicked on the widget and not the cell itself');
+ assert.ok(!$cell.parent().hasClass('o_selected_row'),
+ 'should not be in edit mode since we clicked on the state selection widget');
assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 1,
"there should be a dropdown");
assert.strictEqual(list.$('ul.dropdown-menu.state:visible li').length, 2,
@@ -2148,10 +2142,8 @@ QUnit.module('basic_fields', {
// switch to edit mode and check the result
$cell = list.$('tbody td.o_state_selection_cell').first();
$cell.click();
- assert.ok(!$cell.hasClass('o_edit_mode'),
- 'should still not be in edit mode since widget is readonly');
- assert.ok($cell.siblings().hasClass('o_edit_mode'),
- 'siblings should be in edit mode since we clicked on the cell and not the widget itself');
+ assert.ok($cell.parent().hasClass('o_selected_row'),
+ 'should now be in edit mode');
assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status').length, 5,
"should still have five status selection widgets");
assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_red').length, 0,
@@ -2174,9 +2166,9 @@ QUnit.module('basic_fields', {
$lastCell.click();
assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0,
"there should not be a dropdown anymore");
- assert.ok(!$firstCell.siblings().hasClass('o_edit_mode'),
+ assert.ok(!$firstCell.parent().hasClass('o_selected_row'),
'first row should not be in edit mode anymore');
- assert.ok($lastCell.siblings().hasClass('o_edit_mode'),
+ assert.ok($lastCell.parent().hasClass('o_selected_row'),
'last row should be in edit mode');
// Click on the last status button to make the dropdown appear
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 6b23940a9db..93a19de7b81 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -835,15 +835,15 @@ QUnit.module('relational_fields', {
"display name of first record in o2m list should be 'relational record 1'");
form.$buttons.find('.o_form_button_edit').click();
form.$('.o_field_one2many tbody td').first().click();
- assert.ok(form.$('.o_field_one2many tbody td').first().hasClass('o_edit_mode'),
+ assert.ok(form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
"first row of o2m should be in edition");
form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
- assert.ok(form.$('.o_field_one2many tbody td').first().hasClass('o_edit_mode'),
+ assert.ok(form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
"first row of o2m should still be in edition");
// // leave o2m edition
form.$el.click();
- assert.ok(!form.$('.o_field_one2many tbody td').first().hasClass('o_edit_mode'),
+ assert.ok(!form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
"first row of o2m should be readonly again");
// discard changes
@@ -1224,18 +1224,18 @@ QUnit.module('relational_fields', {
// edit mode, then click on Add an item and enter a value
form.$buttons.find('.o_form_button_edit').click();
form.$('.o_form_field_x2many_list_row_add a').click();
- form.$('td.o_edit_mode input').val('kartoffel').trigger('input');
+ form.$('.o_selected_row > td input').val('kartoffel').trigger('input');
// click again on Add an item
form.$('.o_form_field_x2many_list_row_add a').click();
assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
"should have one td with the new value");
- assert.strictEqual(form.$('td.o_edit_mode input').length, 1,
+ assert.strictEqual(form.$('.o_selected_row > td input').length, 1,
"should have one other new td");
assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows");
// enter another value and save
- form.$('td.o_edit_mode input').val('gemuse').trigger('input');
+ form.$('.o_selected_row > td input').val('gemuse').trigger('input');
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows");
assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
@@ -2682,7 +2682,7 @@ QUnit.module('relational_fields', {
assert.strictEqual(form.$('.o_data_row').length, 2,
"should have 2 data rows");
- assert.ok(form.$('tr.o_data_row:first:contains(My little Foo Value)').length, 1,
+ assert.strictEqual(form.$('tr.o_data_row:first input').val(), 'My little Foo Value',
"first row should be the new value");
assert.ok(form.$('tr.o_data_row:first').hasClass('o_selected_row'),
"first row should be selected");
@@ -2728,7 +2728,7 @@ QUnit.module('relational_fields', {
assert.strictEqual(form.$('.o_data_row').length, 2,
"should have 2 data rows");
- assert.ok(form.$('tr.o_data_row:eq(1):contains(My little Foo Value)').length, 1,
+ assert.strictEqual(form.$('tr.o_data_row:eq(1) input').val(), 'My little Foo Value',
"second row should be the new value");
assert.ok(form.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'),
"second row should be selected");
@@ -3190,13 +3190,15 @@ QUnit.module('relational_fields', {
res_id: 1,
});
- assert.ok(form.$('.o_statusbar_status button[data-value="4"]').hasClass('btn-primary disabled'),
+ var $selectedStatus = form.$('.o_statusbar_status button[data-value="4"]');
+ assert.ok($selectedStatus.hasClass('btn-primary') && $selectedStatus.hasClass('disabled'),
"selected status should be btn-primary and disabled");
var $clickable = form.$('.o_statusbar_status button.btn-default:not(.dropdown-toggle):not(:disabled)');
assert.strictEqual($clickable.length, 2,
"other status should be btn-default and not disabled");
$clickable.last().click(); // (last is visually the first here (css))
- assert.ok(form.$('.o_statusbar_status button[data-value="1"]').hasClass("btn-primary disabled"),
+ var $status = form.$('.o_statusbar_status button[data-value="1"]');
+ assert.ok($status.hasClass("btn-primary") && $status.hasClass("disabled"),
"value should have been updated");
form.destroy();
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index de5e02934f0..d7778d0923d 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -154,7 +154,7 @@ var MockServer = Class.extend({
},
/**
* helper: read a string describing an arch, and returns a simulated
- * 'field_view_get' call to the server.
+ * 'fields_view_get' call to the server.
*
* @private
* @param {string|Object} arch a string OR a parsed xml document
@@ -165,6 +165,7 @@ var MockServer = Class.extend({
*/
_fieldsViewGet: function (arch, model, fields) {
var self = this;
+ var modifiersNames = ['invisible', 'readonly', 'required'];
var onchanges = this.data[model].onchanges || {};
var fieldNodes = {};
@@ -173,11 +174,45 @@ var MockServer = Class.extend({
arch = utils.xml_to_json(doc, true);
}
+ var inTreeView = (arch.tag === 'tree');
+
this._traverse(arch, function (node) {
if (typeof node === "string") {
return false;
}
var modifiers = {};
+
+ var isField = (node.tag === 'field');
+
+ if (isField) {
+ fieldNodes[node.attrs.name] = node;
+
+ // 'transfer_field_to_modifiers' simulation
+ var field = fields[node.attrs.name];
+
+ var defaultValues = {};
+ var stateExceptions = {};
+ _.each(modifiersNames, function (attr) {
+ stateExceptions[attr] = [];
+ defaultValues[attr] = !!field[attr];
+ });
+ _.each(field['states'] || {}, function (modifs, state) {
+ _.each(modifs, function (modif) {
+ if (defaultValues[modif[0]] !== modif[1]) {
+ stateExceptions[modif[0]].append(state);
+ }
+ });
+ });
+ _.each(defaultValues, function (defaultValue, attr) {
+ if (stateExceptions[attr].length) {
+ modifiers[attr] = [("state", defaultValue ? "not in" : "in", stateExceptions[attr])];
+ } else {
+ modifiers[attr] = defaultValue;
+ }
+ });
+ }
+
+ // 'transfer_node_to_modifiers' simulation
if (node.attrs.attrs) {
var attrs = pyeval.py_eval(node.attrs.attrs);
_.extend(modifiers, attrs);
@@ -189,20 +224,26 @@ var MockServer = Class.extend({
}
modifiers.invisible.push(["state", "not in", node.attrs.states.split(",")]);
}
- _.each(["invisible", "readonly", "required"], function (a) {
+ _.each(modifiersNames, function (a) {
if (node.attrs[a]) {
var v = pyeval.py_eval(node.attrs[a]);
- if (v || modifiers[a] === undefined) {
+ if (inTreeView && a === 'invisible') {
+ modifiers['tree_invisible'] = v;
+ } else if (v || !(a in modifiers) || !_.isArray(modifiers[a])) {
modifiers[a] = v;
}
}
});
+
+ // 'transfer_modifiers_to_node' simulation
+ _.each(modifiersNames, function (a) {
+ if (a in modifiers && (!!modifiers[a] === false || (_.isArray(modifiers[a]) && !modifiers[a].length))) {
+ delete modifiers[a];
+ }
+ });
node.attrs.modifiers = JSON.stringify(modifiers);
- if (node.tag === 'field') {
- fieldNodes[node.attrs.name] = node;
- return false;
- }
- return true;
+
+ return !isField;
});
var relModel, relFields;
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 2a81286b257..b777fde31c6 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -205,7 +205,7 @@ QUnit.module('Views', {
form.destroy();
});
- QUnit.test('invisible fields are not rendered', function (assert) {
+ QUnit.test('invisible fields are properly hidden', function (assert) {
assert.expect(3);
var form = createView({
@@ -248,16 +248,13 @@ QUnit.module('Views', {
'</group>' +
'</group>' +
'<notebook>' +
- '<page string="visible">' +
- '</page>' +
- '<page string="invisible" invisible="1">' +
- '</page>' +
+ '<page string="visible"/>' +
+ '<page string="invisible" invisible="1"/>' +
'</notebook>' +
'</sheet>' +
'</form>',
res_id: 1,
});
-
assert.strictEqual(form.$('.o_notebook li.o_form_invisible a:contains(invisible)').length, 1,
"should not display tab invisible");
assert.strictEqual(form.$('table.o_inner_group.o_form_invisible td:contains(invgroup)').length, 1,
@@ -265,7 +262,7 @@ QUnit.module('Views', {
form.destroy();
});
- QUnit.test('invisible attrs are re-evaluated on field changed', function (assert) {
+ QUnit.test('invisible attrs on fields are re-evaluated on field change', function (assert) {
assert.expect(3);
// we set the value bar to simulate a falsy boolean value.
@@ -277,7 +274,7 @@ QUnit.module('Views', {
data: this.data,
arch: '<form string="Partners">' +
'<sheet><group>' +
- '<field name="product_id" invisible="1"/>' +
+ '<field name="product_id"/>' +
'<field name="timmy" invisible="1"/>' +
'<field name="foo" class="foo_field" attrs=\'{"invisible": [["product_id", "=", false]]}\'/>' +
'<field name="bar" class="bar_field" attrs=\'{"invisible":[("bar","=",False),("timmy","=",[])]}\'/>' +
@@ -295,7 +292,7 @@ QUnit.module('Views', {
var $dropdown = form.$('.o_form_field_many2one input').autocomplete('widget');
form.$('.o_form_field_many2one input').click();
$dropdown.find('li:first()').click();
- assert.ok(!form.$('.foo_field').hasClass('o_form_invisible'), 'should not display foo field');
+ assert.ok(!form.$('.foo_field').hasClass('o_form_invisible'), 'should display foo field');
form.destroy();
});
@@ -343,7 +340,7 @@ QUnit.module('Views', {
form.destroy();
});
- QUnit.test('invisible attrs on group', function (assert) {
+ QUnit.test('invisible attrs on group are re-evaluated on field change', function (assert) {
assert.expect(2);
var form = createView({
@@ -434,7 +431,6 @@ QUnit.module('Views', {
form.destroy();
});
-
QUnit.test('label uses the string attribute', function (assert) {
assert.expect(1);
@@ -458,15 +454,50 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('readonly attrs on fields are re-evaluated on field change', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('span[name="foo"]').length, 1,
+ "the foo field widget should be readonly");
+ form.$('.o_field_boolean input').click();
+ assert.strictEqual(form.$('input[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered to now be editable");
+ form.$('.o_field_boolean input').click();
+ assert.strictEqual(form.$('span[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered to now be readonly again");
+
+ form.destroy();
+ });
QUnit.test('empty fields have o_form_empty class in readonly mode', function (assert) {
- assert.expect(4);
+ assert.expect(8);
this.data.partner.fields.foo.default = false; // no default value for this test
this.data.partner.records[1].foo = false; // 1 is record with id=2
this.data.partner.records[1].trululu = false; // 1 is record with id=2
- this.data.partner.fields.trululu.readonly = true;
this.data.partner.fields.int_field.readonly = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ if (obj.foo === "hello") {
+ obj.int_field = false;
+ }
+ };
+
var form = createView({
View: FormView,
model: 'partner',
@@ -475,7 +506,7 @@ QUnit.module('Views', {
'<sheet>' +
'<group>' +
'<field name="foo"/>' +
- '<field name="trululu"/>' +
+ '<field name="trululu" attrs="{\'readonly\': [[\'foo\', \'=\', False]]}"/>' +
'<field name="int_field"/>' +
'</group>' +
'</sheet>' +
@@ -484,16 +515,31 @@ QUnit.module('Views', {
});
assert.strictEqual(form.$('.o_form_field.o_form_field_empty').length, 2,
- "should have 2 empty fields with correct class");
+ "should have 2 empty fields with correct class");
assert.strictEqual(form.$('.o_form_label_empty').length, 2,
- "should have 2 muted labels (for the empty fieds) in readonly");
+ "should have 2 muted labels (for the empty fieds) in readonly");
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('.o_form_field_empty').length, 1,
- "in edit mode, only readonly fields should have .o_form_field_empty class");
+ "in edit mode, only empty readonly fields should have the o_form_field_empty class");
assert.strictEqual(form.$('.o_form_label_empty').length, 1,
- "in edit mode, only readonly fields should have .o_form_label_empty class");
+ "in edit mode, only labels associated to empty readonly fields should have the o_form_label_empty class");
+
+ form.$('input[name="foo"]').val("test").trigger("input");
+
+ assert.strictEqual(form.$('.o_form_field_empty').length, 0,
+ "after readonly modifier change, the o_form_field_empty class should have been removed");
+ assert.strictEqual(form.$('.o_form_label_empty').length, 0,
+ "after readonly modifier change, the o_form_label_empty class should have been removed");
+
+ form.$('input[name="foo"]').val("hello").trigger("input");
+
+ assert.strictEqual(form.$('.o_form_field_empty').length, 1,
+ "after value changed to false for a readonly field, the o_form_field_empty class should have been added");
+ assert.strictEqual(form.$('.o_form_label_empty').length, 1,
+ "after value changed to false for a readonly field, the o_form_label_empty class should have been added");
+
form.destroy();
});
@@ -559,6 +605,37 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('required attrs on fields are re-evaluated on field change', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('input[name="foo"].o_form_required').length, 1,
+ "the foo field widget should be required");
+ form.$('.o_field_boolean input').click();
+ assert.strictEqual(form.$('input[name="foo"]:not(.o_form_required)').length, 1,
+ "the foo field widget should now have been marked as non-required");
+ form.$('.o_field_boolean input').click();
+ assert.strictEqual(form.$('input[name="foo"].o_form_required').length, 1,
+ "the foo field widget should now have been marked as required again");
+
+ form.destroy();
+ });
+
QUnit.test('required fields should have o_form_required in readonly mode', function (assert) {
assert.expect(2);
@@ -947,7 +1024,6 @@ QUnit.module('Views', {
form.destroy();
});
-
QUnit.test('make default record with non empty one2many', function (assert) {
assert.expect(4);
@@ -1066,7 +1142,7 @@ QUnit.module('Views', {
form.destroy();
});
- QUnit.test('buttons in footer are moved to $buttons if required', function (assert) {
+ QUnit.test('buttons in footer are moved to $buttons if necessary', function (assert) {
// not sure about this test...
assert.expect(2);
@@ -1139,7 +1215,6 @@ QUnit.module('Views', {
form.destroy();
});
-
QUnit.test('buttons with attr "special" do not trigger a save', function (assert) {
assert.expect(4);
@@ -1532,7 +1607,6 @@ QUnit.module('Views', {
form.destroy();
});
-
QUnit.test('restore local state when switching to another record', function (assert) {
assert.expect(4);
@@ -1703,7 +1777,6 @@ QUnit.module('Views', {
form.destroy();
});
-
QUnit.test('all group children have correct layout classname', function (assert) {
assert.expect(2);
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index cd04cecbc39..20a92efbcb2 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -105,7 +105,6 @@ QUnit.module('Views', {
list.destroy();
});
-
QUnit.test('simple editable rendering', function (assert) {
assert.expect(9);
@@ -249,7 +248,7 @@ QUnit.module('Views', {
});
QUnit.test('editable list view: readonly fields cannot be edited', function (assert) {
- assert.expect(3);
+ assert.expect(4);
this.data.foo.fields.foo.readonly = true;
@@ -267,9 +266,14 @@ QUnit.module('Views', {
var $second_td = list.$('td:not(.o_list_record_selector)').eq(1);
var $third_td = list.$('td:not(.o_list_record_selector)').eq(2);
$td.click();
- assert.ok(!$td.hasClass('o_edit_mode'), "foo cells should not be editable");
- assert.ok($second_td.hasClass('o_edit_mode'), "bar cells should be editable");
- assert.ok(!$third_td.hasClass('o_edit_mode'), "int_field cells should not be editable");
+ assert.ok($td.parent().hasClass('o_selected_row'),
+ "row should be in edit mode");
+ assert.ok($td.hasClass('o_readonly'),
+ "foo cell should be readonly in edit mode");
+ assert.ok(!$second_td.hasClass('o_readonly'),
+ "bar cell should be editable");
+ assert.ok($third_td.hasClass('o_readonly'),
+ "int_field cell should be readonly in edit mode");
list.destroy();
});
@@ -284,12 +288,12 @@ QUnit.module('Views', {
});
var $td = list.$('td:not(.o_list_record_selector)').first();
- assert.strictEqual($td.hasClass('o_edit_mode'), false, "td should not be in edit mode");
+ assert.ok(!$td.parent().hasClass('o_selected_row'), "td should not be in edit mode");
$td.click();
- assert.strictEqual($td.hasClass('o_edit_mode'), true, "td should be in edit mode");
- assert.strictEqual($td.hasClass('o_field_dirty'), false, "td should not be dirty");
+ assert.ok($td.parent().hasClass('o_selected_row'), "td should be in edit mode");
+ assert.ok(!$td.hasClass('o_field_dirty'), "td should not be dirty");
$td.find('input').val('abc').trigger('input');
- assert.strictEqual($td.hasClass('o_field_dirty'), true, "td should be dirty");
+ assert.ok($td.hasClass('o_field_dirty'), "td should be dirty");
list.destroy();
});
@@ -629,7 +633,6 @@ QUnit.module('Views', {
list.destroy();
});
-
QUnit.test('list without import button', function (assert) {
assert.expect(1);
@@ -825,7 +828,7 @@ QUnit.module('Views', {
"the div for the table should take the full height");
- assert.ok(list.$('tbody tr:eq(0) td:eq(1)').hasClass('o_edit_mode'),
+ assert.ok(list.$('tbody tr:eq(0)').hasClass('o_selected_row'),
"the date field td should be in edit mode");
assert.strictEqual(list.$('tbody tr:eq(0) td:eq(1)').text().trim(), "",
"the date field td should not have any content");
@@ -982,10 +985,10 @@ QUnit.module('Views', {
// edit first row
list.$('tbody tr:nth(0) td:nth(2)').click(); // click on first row to edit it
- assert.equal(list.$('tbody tr:nth(0) td:nth(4)').html(), "",
- "td that contains an invisible field should be empty");
- assert.equal(list.$('tbody tr:nth(0) td:nth(1)').html(), "",
- "td that contains an invisible button should be empty");
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(4) input.o_form_invisible').length, 1,
+ "td that contains an invisible field should not be empty in edition");
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > button.o_form_invisible').length, 1,
+ "td that contains an invisible button should not be empty in edition");
list.$buttons.find('.o_list_button_discard').click(); // leave edition
// click on the invisible field's cell to edit first row
@@ -1065,11 +1068,12 @@ QUnit.module('Views', {
list.$('.o_data_row:first td:nth(1)').click();
assert.ok(list.$('.o_data_row:first').hasClass('o_selected_row'),
"first row should be selected");
- assert.ok(list.$('.o_data_row:first td:nth(1)').hasClass('o_readonly'),
- "foo field cells should have class 'o_readonly'");
- assert.strictEqual(list.$('.o_data_row:first td:nth(1)').text(), 'yop',
- "no widget should be rendered for readonly fields");
- assert.ok(list.$('.o_data_row:first td:nth(2)').hasClass('o_edit_mode'),
+ var $cell = list.$('.o_data_row:first td:nth(1)');
+ assert.ok($cell.hasClass('o_readonly') && $cell.parent().hasClass('o_selected_row'),
+ "foo field cells should have class 'o_readonly' and the row should be in edition");
+ assert.strictEqual(list.$('.o_data_row:first td:nth(1) span').text(), 'yop',
+ "a widget should have been rendered for readonly fields");
+ assert.ok(list.$('.o_data_row:first td:nth(2)').parent().hasClass('o_selected_row'),
"field 'int_field' should be in edition");
assert.strictEqual(list.$('.o_data_row:first td:nth(2) input').length, 1,
"a widget for field 'int_field should have been rendered'");
@@ -1260,7 +1264,198 @@ QUnit.module('Views', {
"should still have 4 records");
list.destroy();
});
-});
+ QUnit.test('invisible attrs on fields are re-evaluated on field change', function (assert) {
+ assert.expect(7);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'invisible\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tbody td.o_form_invisible').length, 3,
+ "there should be 3 invisible foo cells in readonly mode");
+
+ // Make first line editable
+ list.$('tbody tr:nth(0) td:nth(1)').click();
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_form_invisible').length, 1,
+ "the foo field widget should have been rendered as invisible");
+
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]:not(.o_form_invisible)').length, 1,
+ "the foo field widget should have been marked as non-invisible");
+ assert.strictEqual(list.$('tbody td.o_form_invisible').length, 2,
+ "the foo field widget parent cell should not be invisible anymore");
+
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_form_invisible').length, 1,
+ "the foo field widget should have been marked as invisible again");
+ assert.strictEqual(list.$('tbody td.o_form_invisible').length, 3,
+ "the foo field widget parent cell should now be invisible again");
+
+ // Reswitch the cell to editable and save the row
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ list.$('thead').click();
+
+ assert.strictEqual(list.$('tbody td.o_form_invisible').length, 2,
+ "there should be 2 invisible foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('readonly attrs on fields are re-evaluated on field change', function (assert) {
+ assert.expect(7);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'readonly\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tbody td.o_readonly').length, 3,
+ "there should be 3 readonly foo cells in readonly mode");
+
+ // Make first line editable
+ list.$('tbody tr:nth(0) td:nth(1)').click();
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > span[name="foo"]').length, 1,
+ "the foo field widget should have been rendered as readonly");
+
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as editable");
+ assert.strictEqual(list.$('tbody td.o_readonly').length, 2,
+ "the foo field widget parent cell should not be readonly anymore");
+
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > span[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as readonly");
+ assert.strictEqual(list.$('tbody td.o_readonly').length, 3,
+ "the foo field widget parent cell should now be readonly again");
+
+ // Reswitch the cell to editable and save the row
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ list.$('thead').click();
+
+ assert.strictEqual(list.$('tbody td.o_readonly').length, 2,
+ "there should be 2 readonly foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('required attrs on fields are re-evaluated on field change', function (assert) {
+ assert.expect(7);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tbody td.o_form_required').length, 3,
+ "there should be 3 required foo cells in readonly mode");
+
+ // Make first line editable
+ list.$('tbody tr:nth(0) td:nth(1)').click();
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_form_required').length, 1,
+ "the foo field widget should have been rendered as required");
+
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]:not(.o_form_required)').length, 1,
+ "the foo field widget should have been marked as non-required");
+ assert.strictEqual(list.$('tbody td.o_form_required').length, 2,
+ "the foo field widget parent cell should not be required anymore");
+
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_form_required').length, 1,
+ "the foo field widget should have been marked as required again");
+ assert.strictEqual(list.$('tbody td.o_form_required').length, 3,
+ "the foo field widget parent cell should now be required again");
+
+ // Reswitch the cell to editable and save the row
+ list.$('tbody tr:nth(0) td:nth(2) input').click();
+ list.$('thead').click();
+
+ assert.strictEqual(list.$('tbody td.o_form_required').length, 2,
+ "there should be 2 required foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('discarding (or not) changes after row edition', function (assert) {
+ assert.expect(8);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ // Start first line edition
+ var $firstFooTd = list.$('tbody tr:nth(0) td:nth(1)');
+ $firstFooTd.click();
+
+ // Remove required foo field value
+ $firstFooTd.find('input').val("").trigger("input");
+
+ // Try starting other line edition
+ var $secondFooTd = list.$('tbody tr:nth(1) td:nth(1)');
+ $secondFooTd.click();
+
+ assert.strictEqual($firstFooTd.parent('.o_selected_row').length, 1,
+ "first line should still be in edition as cannot be auto discarded");
+ assert.strictEqual(list.$('tbody tr.o_selected_row').length, 1,
+ "no other line should be in edition");
+
+ // Prevent discard of first line
+ $('.modal-footer:visible > .btn-default').click();
+
+ assert.strictEqual($firstFooTd.parent('.o_selected_row').length, 1,
+ "first line should still be in edition as not discarded");
+ assert.strictEqual(list.$('tbody tr.o_selected_row').length, 1,
+ "no other line should be in edition");
+
+ // Retry starting other line edition
+ $secondFooTd.click();
+
+ assert.strictEqual($firstFooTd.parent('.o_selected_row').length, 1,
+ "first line should still be in edition as cannot be auto discarded");
+ assert.strictEqual(list.$('tbody tr.o_selected_row').length, 1,
+ "no other line should be in edition");
+
+ // Discard first line
+ $('.modal-footer:visible > .btn-primary').click();
+
+ assert.strictEqual($secondFooTd.parent('.o_selected_row').length, 1,
+ "second line should now be in edition");
+ assert.strictEqual(list.$('tbody tr.o_selected_row').length, 1,
+ "no other line should be in edition");
+
+ list.destroy();
+ });
+});
});