summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js10
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js22
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js28
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js14
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js33
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js37
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js6
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js126
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js2
-rw-r--r--addons/web/static/test/fields/relational_fields_tests.js157
10 files changed, 387 insertions, 48 deletions
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
index 47802d5d8c7..6eac12a2dba 100644
--- a/addons/web/static/src/js/fields/abstract_field.js
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -153,7 +153,7 @@ var AbstractField = Widget.extend({
// 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 || false;
+ 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
@@ -202,7 +202,13 @@ var AbstractField = Widget.extend({
* 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.
*
- * @returns {boolean}
+ * 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.
+ *
+ * @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;
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index c29f958cc06..4ae30264ff0 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -560,6 +560,28 @@ var FieldX2Many = AbstractField.extend({
isSet: function () {
return true;
},
+ /**
+ * This is a tricky override. For relational fields, we don't just need to
+ * know if the field is valid. We also need to discard some invalid lines,
+ * if the user is ok with it (or automatically if the invalid line is not
+ * dirty). This is the reason why we need to change the signature of the
+ * AbstractField isValid method to optionally return a deferred.
+ *
+ * @override
+ * @returns {boolean|Deferred} if there is a renderer (sub list or kanban
+ * view), we may need to ask the user => it will return a deferred.
+ * Otherwise, it returns a boolean
+ */
+ isValid: function () {
+ if (!this.renderer) {
+ return this._super.apply(this, arguments);
+ }
+ var def = $.Deferred();
+ this.renderer.canBeSaved()
+ .then(def.resolve.bind(def, true))
+ .fail(def.resolve.bind(def, false));
+ return def;
+ },
//--------------------------------------------------------------------------
// Private
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 0b5de486bb9..1fe0e52130b 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -545,6 +545,22 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * 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,
+ * then clicks somewhere else. The new line need to be discarded, but we
+ * don't want to trigger a real notifyChanges (no need for that, and also,
+ * we don't want to rerender the UI).
+ *
+ * @param {string} elementID some valid element id. It is necessary that the
+ * corresponding element has a parent.
+ */
+ removeLine: function (elementID) {
+ var record = this.localData[elementID];
+ var parent = this.localData[record.parentID];
+ parent._changes = _.without(parent._changes, elementID);
+ },
+ /**
* Save a local resource, if needed. This is a complicated operation,
* - it needs to check all changes,
* - generate commands for x2many fields,
@@ -1652,6 +1668,11 @@ var BasicModel = AbstractModel.extend({
removedIds = _.difference(list.res_ids, relIds);
addedIds = _.difference(relIds, list.res_ids);
keptIds = _.intersection(list.res_ids, relIds);
+
+ // the didChange variable keep track of the fact that at
+ // least one id was updated
+ var didChange = false;
+
for (i = 0; i < keptIds.length; i++) {
relRecord = _.findWhere(relData, {res_id: keptIds[i]});
var command;
@@ -1659,11 +1680,18 @@ var BasicModel = AbstractModel.extend({
var r = this.get(relRecord.id, {raw: true});
var changes = _.pick(r.data, Object.keys(relRecord._changes));
command = x2ManyCommands.update(relRecord.res_id, changes);
+ didChange = true;
} else {
command = x2ManyCommands.link_to(keptIds[i]);
}
commands[fieldName].push(command);
}
+ if (!didChange && addedIds.length === 0 && removedIds.length === 0) {
+ // in this situation, we have no changed ids, no added
+ // ids and no removed ids, so we can safely ignore the
+ // last changes
+ commands[fieldName] = null;
+ }
for (i = 0; i < addedIds.length; i++) {
relRecord = _.findWhere(relData, {res_id: addedIds[i]});
relRecord = this.get(relRecord.id, {raw: true});
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 10d0c06ad76..9fcd34ffee2 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_line: '_onDiscardLine',
field_changed: '_onFieldChanged',
load: '_onLoad',
},
@@ -59,6 +60,19 @@ var FieldManagerMixin = {
//--------------------------------------------------------------------------
/**
+ * 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,
+ * then clicks somewhere else. The new line need to be discarded, but we
+ * don't want to trigger a real notifyChanges (no need for that, and also,
+ * we don't want to rerender the UI).
+ *
+ * @param {OdooEvent} event
+ */
+ _onDiscardLine: function (event) {
+ this.model.removeLine(event.data.id);
+ },
+ /**
* This is the main job of the FMM: deciding what to do when a controlled
* field changes. Most of the time, it notifies the model that a change
* just occurred, then confirm the change.
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 22f4688bc70..d41ff592687 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -75,14 +75,11 @@ var FormController = BasicController.extend({
return def;
},
/**
- * @returns {boolean}
+ * @returns {Deferred}
*/
- checkInvalidFields: function () {
- var invalidFields = this.renderer.checkInvalidFields();
- if (invalidFields.length) {
- this._notifyInvalidFields(invalidFields);
- }
- return !!invalidFields.length;
+ canBeSaved: function () {
+ return this.renderer.canBeSaved()
+ .fail(this._notifyInvalidFields.bind(this));
},
/**
* This method switches the form view in edit mode, with a new record.
@@ -225,17 +222,19 @@ var FormController = BasicController.extend({
}
return $.Deferred().resolve();
} else {
- if (this.checkInvalidFields()) {
- return $.Deferred().reject();
- } else {
- return this.model.save(this.handle, {reload: shouldReload, savePoint: options.savePoint})
- .then(function () {
- if (!stayInEdit) {
- self._toReadOnlyMode();
- }
- self.isDirty = false;
+ return this.canBeSaved()
+ .then(function () {
+ return self.model.save(self.handle, {
+ reload: shouldReload,
+ savePoint: options.savePoint
});
- }
+ })
+ .then(function () {
+ if (!stayInEdit) {
+ self._toReadOnlyMode();
+ }
+ self.isDirty = false;
+ });
}
},
/**
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 2cdc2f91a1d..f417b53885d 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -42,22 +42,39 @@ return BasicRenderer.extend({
* toggling the o_form_invalid css class. It has to be done both on the
* widget, and on the label, if there is a label.
*
- * @returns {string[]} the list of invalid field names
+ * @returns {Deferred}
+ * if it fails, it gives the list of invalid field names
*/
- checkInvalidFields: function () {
+ canBeSaved: function () {
var self = this;
- var invalid_fields = [];
- _.each(this.widgets, function (widget) {
- var is_valid = widget.isValid();
- if (!is_valid) {
- invalid_fields.push(widget.name);
+ var invalidFields = [];
+ var defs = [];
+
+ function markWidget(widget, isValid) {
+ if (!isValid) {
+ invalidFields.push(widget.name);
}
- widget.$el.toggleClass('o_form_invalid', !is_valid);
+ widget.$el.toggleClass('o_form_invalid', !isValid);
var idForLabel = self.idsForLabels[widget.name];
var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
- $label.toggleClass('o_form_invalid', !is_valid);
+ $label.toggleClass('o_form_invalid', !isValid);
+ }
+
+ _.each(this.widgets, function (widget) {
+ var isValid = widget.isValid();
+ if (isValid instanceof $.Deferred) {
+ defs.push(isValid.then(function (isValid) {
+ markWidget(widget, isValid);
+ }));
+ } else {
+ markWidget(widget, isValid);
+ }
+ });
+ return $.when.apply($, defs).then(function () {
+ if (invalidFields.length) {
+ return $.Deferred().reject(invalidFields);
+ }
});
- return invalid_fields;
},
/**
* returns the active tab pages for each notebook
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 4fe030eb168..9e4e6865c0f 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -112,6 +112,12 @@ var KanbanRenderer = BasicRenderer.extend({
this.widgets[0].addQuickCreate();
},
/**
+ * @returns {Deferred}
+ */
+ canBeSaved: function () {
+ return $.when();
+ },
+ /**
* Removes a widget (record if ungrouped, column if grouped) from the view.
*
* @param {Widget} widget the instance of the widget to remove
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 337d62b1dc1..67dcecb03ed 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
@@ -13,6 +13,7 @@ odoo.define('web.EditableListRenderer', function (require) {
*/
var core = require('web.core');
+var Dialog = require('web.Dialog');
var Domain = require('web.Domain');
var ListRenderer = require('web.ListRenderer');
@@ -59,6 +60,10 @@ ListRenderer.include({
this.currentRow = null;
this.currentCol = null;
+
+ // the dirtyRows object is there to keep track of lines that have been
+ // modified by the user
+ this.dirtyRows = {};
},
/**
* @override
@@ -77,6 +82,36 @@ ListRenderer.include({
//--------------------------------------------------------------------------
/**
+ * @returns {Deferred}
+ */
+ canBeSaved: function () {
+ 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);
+ }
+ }
+ });
+ if (invalidDirtyWidgets.length) {
+ // TODO: ask for confirmation, open dialog, and depending on user
+ // input, resolve and reject deferred
+ }
+ if (invalidCleanWidgets.length) {
+ this._unselectRow();
+ }
+ 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
@@ -452,30 +487,84 @@ ListRenderer.include({
* This method is called whenever we click/move outside of a row that was
* in edit mode. This is the moment we save all accumulated changes on that
* row, if needed.
+ *
+ * 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
+ * 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
+ * user refuses to discard its changes.
*/
_unselectRow: function () {
var self = this;
+
+ // We need to protect against calling this method when no row is
+ // selected.
if (this.currentRow === null) {
- return;
+ return $.when();
}
- // trigger the save (if the record isn't dirty, the datamodel won't save
- // anything)
- this.trigger_up('field_changed', {
- dataPointID: this.state.data[this.currentRow].id,
- changes: {},
- force_save: true,
- });
- this.trigger_up('change_mode', {mode: 'readonly'});
- var widgets = _.where(this.widgets, {__rowIndex: this.currentRow});
- _.each(widgets, function (widget) {
- self._setCellValue(self.currentRow, widget.__colIndex, true);
- widget.destroy();
+
+ var discarded = false;
+
+ function discardLine() {
+ self.trigger_up('discard_line', {id: currentLineID});
+ discarded = true;
+ def.resolve();
+ }
+
+ 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));
+ } else {
+ discardLine();
+ }
+ }
+
+ 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();
+ }
+ self.widgets = _.without(self.widgets, widgets);
+ self.currentRow = null;
});
- self.widgets = _.without(self.widgets, widgets);
- var $row = this.$('tbody tr').eq(this.currentRow);
- $row.removeClass('o_selected_row');
- $row.find('td').removeClass('o_edit_mode');
- this.currentRow = null;
},
//--------------------------------------------------------------------------
@@ -547,6 +636,7 @@ ListRenderer.include({
* @param {OdooEvent} event
*/
_onFieldChanged: function (event) {
+ this.dirtyRows[event.data.dataPointID] = true;
var $td = event.target.$el.parent();
$td.addClass('o_field_dirty');
},
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index fe7ddddaf10..6f0fe8328c3 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -207,7 +207,7 @@ var FormViewDialog = ViewDialog.extend({
var self = this;
var def;
if (this.options.on_save) {
- if (this.form_view.checkInvalidFields()) {
+ if (this.form_view.canBeSaved()) {
return $.Deferred().reject();
}
def = this.options.on_save(this.form_view.model.get(this.form_view.handle));
diff --git a/addons/web/static/test/fields/relational_fields_tests.js b/addons/web/static/test/fields/relational_fields_tests.js
index 0cc697e96e5..68a50ee30e9 100644
--- a/addons/web/static/test/fields/relational_fields_tests.js
+++ b/addons/web/static/test/fields/relational_fields_tests.js
@@ -1312,6 +1312,163 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many list (editable): discarding required empty data', function (assert) {
+ assert.expect(7);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ delete this.data.turtle.fields.turtle_foo.default;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item, then click elsewhere
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_form_field_x2many_list_row_add a').click();
+ form.$('label.o_form_label').first().click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should still have 0 data rows");
+
+ // click on Add an item again, then click on save
+ form.$('.o_form_field_x2many_list_row_add a').click();
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should still have 0 data rows");
+
+ assert.verifySteps(['read', 'default_get', 'default_get']);
+ form.destroy();
+ });
+
+ QUnit.test('unselecting a line with missing required data', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ delete this.data.turtle.fields.turtle_foo.default;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // edit mode, then click on Add an item, then click elsewhere
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_form_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data rows");
+
+ // adding a value in the non required field, so it is dirty, but with
+ // a missing required field
+ form.$('input[name="turtle_int"]').val('12345').trigger('input');
+
+ // click elsewhere,
+ form.$('label.o_form_label').click();
+ assert.strictEqual($('.modal').length, 1,
+ 'a confirmation model should be opened');
+
+ // click on cancel, the line should still be selected
+ $('.modal .modal-footer button.btn-default').click();
+ assert.strictEqual(form.$('tr.o_data_row.o_selected_row').length, 1,
+ "should still have 1 selected data row");
+
+ // click elsewhere, and click on ok (on the confirmation dialog)
+ form.$('label.o_form_label').click();
+ $('.modal .modal-footer button.btn-primary').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows (invalid line has been discarded");
+
+ form.destroy();
+ });
+
+ QUnit.test('editing a o2m, with required field and onchange', function (assert) {
+ assert.expect(12);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ delete this.data.turtle.fields.turtle_foo.default;
+ this.data.turtle.onchanges = {
+ turtle_foo: function (obj) {
+ obj.turtle_int = obj.turtle_foo.length;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_form_field_x2many_list_row_add a').click();
+
+ // input some text in required turtle_foo field
+ form.$('input[name="turtle_foo"]').val('aubergine').trigger('input');
+ assert.strictEqual(form.$('input[name="turtle_int"]').val(), "9",
+ "onchange should have been triggered");
+
+ // save and check everything is fine
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_data_row td:contains(aubergine)').length, 1,
+ "should have one row with turtle_foo value");
+ assert.strictEqual(form.$('.o_data_row td:contains(9)').length, 1,
+ "should have one row with turtle_int value");
+
+ assert.verifySteps(['read', 'default_get', 'onchange', 'onchange', 'write', 'read', 'read']);
+ form.destroy();
+ });
+
QUnit.test('onchange in a one2many', function (assert) {
assert.expect(1);