summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2018-06-29 12:46:00 +0200
committerChristophe Simonis <chs@odoo.com>2018-06-29 12:46:00 +0200
commit49edee1f4bd74bc342863d8ef50eb86de0b4c55f (patch)
tree20f449f08ba1f4a548c9cb2d82f67f2545048419 /addons/web/static
parent4a08388492c6667b7f4a11f1397b8557c355b208 (diff)
parentd54a3726eca6ad48d969d5b4a6e559f783809832 (diff)
[MERGE] forward port branch saas-11.4 up to d54a3726eca
Diffstat (limited to 'addons/web/static')
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js2
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js10
-rw-r--r--addons/web/static/src/js/chrome/loading.js6
-rw-r--r--addons/web/static/src/js/core/ajax.js62
-rw-r--r--addons/web/static/src/js/core/session.js67
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js16
-rw-r--r--addons/web/static/src/js/services/crash_manager.js3
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js44
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js6
-rw-r--r--addons/web/static/src/js/views/form/form_view.js1
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js73
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js270
-rw-r--r--addons/web/static/tests/views/form_tests.js45
13 files changed, 534 insertions, 71 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 7d3f19c1639..bce77a352ea 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -170,7 +170,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
core.bus.on('connection_restored', this, this._onConnectionRestored);
// crash manager integration
- session.on('error', crash_manager, crash_manager.rpc_error);
+ core.bus.on('rpc_error', crash_manager, crash_manager.rpc_error);
window.onerror = function (message, file, line, col, error) {
// Scripts injected in DOM (eg: google API's js files) won't return a clean error on window.onerror.
// The browser will just give you a 'Script error.' as message and nothing else for security issue.
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index 0fe45f011e8..f98447c8983 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -391,10 +391,13 @@ var ActionManager = Widget.extend({
}
return this._startController(controller).then(function (controller) {
+ var prevDialogOnClose;
if (self.currentDialogController) {
+ prevDialogOnClose = self.currentDialogController.onClose;
self._closeDialog(true);
}
+ controller.onClose = prevDialogOnClose || options.on_close;
var dialog = new Dialog(self, _.defaults({}, options, {
buttons: [],
dialogClass: controller.className,
@@ -405,7 +408,7 @@ var ActionManager = Widget.extend({
self._removeAction(action.jsID);
self.currentDialogController = null;
if (silent !== true) {
- options.on_close();
+ controller.onClose();
}
});
controller.dialog = dialog;
@@ -488,8 +491,9 @@ var ActionManager = Widget.extend({
* @returns {Deferred} resolved immediately
*/
_executeCloseAction: function (action, options) {
+ var result;
if (!this.currentDialogController) {
- options.on_close();
+ result = options.on_close();
}
this._closeDialog();
@@ -499,7 +503,7 @@ var ActionManager = Widget.extend({
this.trigger_up('show_effect', action.effect);
}
- return $.when();
+ return $.when(result);
},
/**
* Executes actions of type 'ir.actions.server'.
diff --git a/addons/web/static/src/js/chrome/loading.js b/addons/web/static/src/js/chrome/loading.js
index 0c1b5e5025f..6c7f102231b 100644
--- a/addons/web/static/src/js/chrome/loading.js
+++ b/addons/web/static/src/js/chrome/loading.js
@@ -26,9 +26,9 @@ var Loading = Widget.extend({
this._super(parent);
this.count = 0;
this.blocked_ui = false;
- session.on("request", this, this.request_call);
- session.on("response", this, this.response_call);
- session.on("response_failed", this, this.response_call);
+ core.bus.on('rpc_request', this, this.request_call);
+ core.bus.on("rpc_response", this, this.response_call);
+ core.bus.on("rpc_response_failed", this, this.response_call);
},
destroy: function() {
this.on_rpc_event(-this.count);
diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js
index d6c67eacaec..fa79c65ef45 100644
--- a/addons/web/static/src/js/core/ajax.js
+++ b/addons/web/static/src/js/core/ajax.js
@@ -5,7 +5,15 @@ var core = require('web.core');
var utils = require('web.utils');
var time = require('web.time');
-function genericJsonRpc (fct_name, params, fct) {
+function genericJsonRpc (fct_name, params, settings, fct) {
+ var shadow = settings.shadow || false;
+ delete settings.shadow;
+ if (!shadow) {
+ core.bus.trigger('rpc_request');
+ }
+
+ var deferred = $.Deferred();
+
var data = {
jsonrpc: "2.0",
method: fct_name,
@@ -29,12 +37,56 @@ function genericJsonRpc (fct_name, params, fct) {
return def.reject.apply(def, ["communication"].concat(_.toArray(arguments)));
});
// FIXME: jsonp?
- result.abort = function () { if (xhr.abort) xhr.abort(); };
- return result;
+ deferred.abort = function () {
+ deferred.reject('communication', $.Event(), 'abort', 'abort');
+ if (xhr.abort) {
+ xhr.abort();
+ }
+ };
+
+ result.then(function (result) {
+ if (!shadow) {
+ core.bus.trigger('rpc_response');
+ }
+ deferred.resolve(result);
+ }, function (type, error, textStatus, errorThrown) {
+ if (type === "server") {
+ if (!shadow) {
+ core.bus.trigger('rpc_response');
+ }
+ if (error.code === 100) {
+ core.bus.trigger('invalidate_session');
+ }
+ deferred.reject(error, $.Event());
+ } else {
+ if (!shadow) {
+ core.bus.trigger('rpc_response_failed');
+ }
+ var nerror = {
+ code: -32098,
+ message: "XmlHttpRequestError " + errorThrown,
+ data: {
+ type: "xhr"+textStatus,
+ debug: error.responseText,
+ objects: [error, errorThrown]
+ },
+ };
+ deferred.reject(nerror, $.Event());
+ }
+ });
+ deferred.fail(function () { // Allow deferred user to disable rpc_error call in fail
+ deferred.fail(function (error, event) {
+ if (!event.isDefaultPrevented()) {
+ core.bus.trigger('rpc_error', error, event);
+ }
+ });
+ });
+ return deferred;
}
function jsonRpc(url, fct_name, params, settings) {
- return genericJsonRpc(fct_name, params, function(data) {
+ settings = settings || {};
+ return genericJsonRpc(fct_name, params, settings, function(data) {
return $.ajax(url, _.extend({}, settings, {
url: url,
dataType: 'json',
@@ -47,7 +99,7 @@ function jsonRpc(url, fct_name, params, settings) {
function jsonpRpc(url, fct_name, params, settings) {
settings = settings || {};
- return genericJsonRpc(fct_name, params, function(data) {
+ return genericJsonRpc(fct_name, params, settings, function(data) {
var payload_str = JSON.stringify(data, time.date_to_utc);
var payload_url = $.param({r:payload_str});
var force2step = settings.force2step || false;
diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js
index 11423eec909..09b65357404 100644
--- a/addons/web/static/src/js/core/session.js
+++ b/addons/web/static/src/js/core/session.js
@@ -49,6 +49,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
this.qweb_mutex = new concurrency.Mutex();
this.currencies = {};
this._groups_def = {};
+ core.bus.on('invalidate_session', this, this._onInvalidateSession);
},
setup: function (origin, options) {
// must be able to customize server
@@ -327,31 +328,31 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
rpc: function (url, params, options) {
var self = this;
options = _.clone(options || {});
- var shadow = options.shadow || false;
options.headers = _.extend({}, options.headers);
if (odoo.debug) {
options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug;
}
- delete options.shadow;
-
- var deferred = $.Deferred();
+ var deferred = self.check_session_id();
var aborted = false;
+ var xhrDef;
deferred.abort = function () {
- aborted = true;
+ if (xhrDef) {
+ xhrDef.abort();
+ } else {
+ aborted = true;
+ }
};
- self.check_session_id().then(function () {
+
+ return deferred.then(function () {
if (aborted) {
- return deferred.reject('communication', $.Event(), 'abort', 'abort');
+ return $.Deferred().reject('communication', $.Event(), 'abort', 'abort');
}
// TODO: remove
if (! _.isString(url)) {
_.extend(options, url);
url = url.url;
}
- // TODO correct handling of timeouts
- if (! shadow)
- self.trigger('request');
var fct;
if (self.origin_server) {
fct = ajax.jsonRpc;
@@ -370,40 +371,9 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
url = self.url(url, null);
options.session_id = self.session_id || '';
}
- var p = fct(url, "call", params, options);
- deferred.abort = p.abort.bind(p); // Allow to abort the rpc call
- p = p.then(function (result) {
- if (! shadow)
- self.trigger('response');
- return deferred.resolve(result);
- }, function (type, error, textStatus, errorThrown) {
- if (type === "server") {
- if (! shadow)
- self.trigger('response');
- if (error.code === 100) {
- self.uid = false;
- }
- return deferred.reject(error, $.Event());
- } else {
- if (! shadow)
- self.trigger('response_failed');
- var nerror = {
- code: -32098,
- message: "XmlHttpRequestError " + errorThrown,
- data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] }
- };
- return deferred.reject(nerror, $.Event());
- }
- });
- return deferred.fail(function () { // Allow deferred user to disable rpc_error call in fail
- deferred.fail(function (error, event) {
- if (!event.isDefaultPrevented()) {
- self.trigger('error', error, event);
- }
- });
- });
+ xhrDef = fct(url, "call", params, options);
+ return xhrDef;
});
- return deferred;
},
url: function (path, params) {
params = _.extend(params || {});
@@ -428,6 +398,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
getTZOffset: function (date) {
return -new Date(date).getTimezoneOffset();
},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInvalidateSession: function () {
+ this.uid = false;
+ },
});
return Session;
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index aa66439143d..cb676871fc5 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -2597,6 +2597,21 @@ var AceEditor = DebouncedField.extend({
//--------------------------------------------------------------------------
/**
+ * Format value
+ *
+ * Note: We have to overwrite this method to always return a string.
+ * AceEditor works with string and not boolean value.
+ *
+ * @override
+ * @private
+ * @param {boolean|string} value
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ return this._super.apply(this, arguments) || '';
+ },
+
+ /**
* @override
* @private
*/
@@ -2618,6 +2633,7 @@ var AceEditor = DebouncedField.extend({
this.aceSession.setValue(newValue);
}
},
+
/**
* Starts the ace library on the given DOM element. This initializes the
* ace editor option according to the edit/readonly mode and binds ace
diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js
index 8d15efda686..f3bfa87341c 100644
--- a/addons/web/static/src/js/services/crash_manager.js
+++ b/addons/web/static/src/js/services/crash_manager.js
@@ -41,7 +41,8 @@ var CrashManager = core.Class.extend({
core.bus.trigger('connection_lost');
this.connection_lost = true;
var timeinterval = setInterval(function() {
- ajax.jsonRpc('/web/webclient/version_info').then(function() {
+ var options = {shadow: true};
+ ajax.jsonRpc('/web/webclient/version_info', 'call', {}, options).then(function () {
clearInterval(timeinterval);
core.bus.trigger('connection_restored');
self.connection_lost = false;
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 d8ebddc101c..6ad520f9eb0 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -683,13 +683,16 @@ var BasicModel = AbstractModel.extend({
/**
* Returns true if a record can be abandoned.
*
- * A record can be abandonned if it is a new record, except if
- * this datapoint has been specifically tagged as "do not abandon".
+ * Case for not abandoning the record:
*
- * Example:
+ * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
+ * `onchange` from a `default_get`)
+ * 2. registered in a list on addition
+ * 2.1. registered as non-new addition
+ * 2.2. registered as new additon on update
+ * 3. record is not new
*
- * - Discard record from "Add an item" => "New" record => abandon
- * - Discard record from `default_get`/`onchange` => do not abandon
+ * Otherwise, the record can be abandoned.
*
* This is useful when discarding changes on this record, as it means that
* we must keep the record even if some fields are invalids (e.g. required
@@ -699,7 +702,29 @@ var BasicModel = AbstractModel.extend({
* @returns {boolean}
*/
canBeAbandoned: function (id) {
- return !this.localData[id]._noAbandon && this.isNew(id);
+ // 1. no drop if flagged
+ if (this.localData[id]._noAbandon) {
+ return false;
+ }
+ // 2. no drop in a list on "ADD in some cases
+ var record = this.localData[id];
+ var parent = this.localData[record.parentID];
+ if (parent) {
+ var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
+ if (entry) {
+ // 2.1. no drop on non-new addition in list
+ if (!entry.isNew) {
+ return false;
+ }
+ // 2.2. no drop on new addition on "UPDATE"
+ var lastEntry = _.last(parent._savePoint);
+ if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
+ return false;
+ }
+ }
+ }
+ // 3. drop new records
+ return this.isNew(id);
},
/**
* Returns true if a record is dirty. A record is considered dirty if it has
@@ -2673,7 +2698,7 @@ var BasicModel = AbstractModel.extend({
count: ids.length,
context: record.context,
fieldsInfo: fieldsInfo,
- fields: view ? view.viewFields : fieldInfo.relatedFields,
+ fields: view ? view.fields : fieldInfo.relatedFields,
limit: fieldInfo.limit,
modelName: field.relation,
res_ids: ids,
@@ -2685,6 +2710,11 @@ var BasicModel = AbstractModel.extend({
relationField: field.relation_field,
viewType: view ? view.type : fieldInfo.viewType,
});
+ // set existing changes to the list
+ if (record._changes && record._changes[fieldName]) {
+ list._changes = self.localData[record._changes[fieldName]]._changes;
+ record._changes[fieldName] = list.id;
+ }
record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) {
var def = self._readUngroupedList(list).then(function () {
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index be49c12e768..d0ca5c3d09f 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -634,7 +634,11 @@ var FormController = BasicController.extend({
*/
_onSave: function (ev) {
ev.stopPropagation(); // Prevent x2m lines to be auto-saved
- this.saveRecord();
+ var self = this;
+ this._disableButtons();
+ this.saveRecord().always(function () {
+ self._enableButtons();
+ });
},
/**
* This method is called when someone tries to sort a column, most likely
diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js
index f0ea360fed0..c688a997581 100644
--- a/addons/web/static/src/js/views/form/form_view.js
+++ b/addons/web/static/src/js/views/form/form_view.js
@@ -100,6 +100,7 @@ var FormView = BasicView.extend({
for (var viewName in views) {
// clone to make runbot green?
attrs.views[viewName] = self._processFieldsView(views[viewName], viewName);
+ attrs.views[viewName].fields = attrs.views[viewName].viewFields;
self._processSubViewAttrs(attrs.views[viewName], attrs);
}
self._setSubViewLimit(attrs);
diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js
index 07febe83234..3b8f320393b 100644
--- a/addons/web/static/tests/chrome/action_manager_tests.js
+++ b/addons/web/static/tests/chrome/action_manager_tests.js
@@ -98,6 +98,9 @@ QUnit.module('ActionManager', {
name: 'A Client Action',
tag: 'ClientAction',
type: 'ir.actions.client',
+ }, {
+ id: 10,
+ type: 'ir.actions.act_window_close',
}];
this.archs = {
@@ -2416,6 +2419,51 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test('requests for execute_action of type object: disable buttons', function (assert) {
+ assert.expect(2);
+
+ var self = this;
+ var def;
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_button') {
+ return $.when(false);
+ } else if (args.method === 'read') {
+ // Block the 'read' call
+ var result = this._super.apply(this, arguments);
+ return $.when(def).then(_.constant(result));
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ actionManager.doAction(3);
+
+ // open a record in form view
+ actionManager.$('.o_list_view .o_data_row:first').click();
+
+ // click on 'Call method' button (should call an Object method)
+ def = $.Deferred();
+ actionManager.$('.o_form_view button:contains(Call method)').click();
+
+ // Buttons should be disabled
+ assert.strictEqual(
+ actionManager.$('.o_form_view button:contains(Call method)').attr('disabled'),
+ 'disabled', 'buttons should be disabled')
+
+ // Release the 'read' call
+ def.resolve();
+
+ // Buttons should be enabled after the reload
+ assert.strictEqual(
+ actionManager.$('.o_form_view button:contains(Call method)').attr('disabled'),
+ undefined, 'buttons should be disabled')
+
+ actionManager.destroy();
+ });
+
QUnit.test('can open different records from a multi record view', function (assert) {
assert.expect(11);
@@ -3015,6 +3063,31 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test('chained action on_close', function (assert) {
+ assert.expect(3);
+
+ function on_close() {
+ assert.step('Close Action');
+ };
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ actionManager.doAction(5, {on_close: on_close});
+
+ // a target=new action shouldn't activate the on_close
+ actionManager.doAction(5);
+ assert.verifySteps([]);
+
+ // An act_window_close should trigger the on_close
+ actionManager.doAction(10);
+ assert.verifySteps(['Close Action']);
+
+ actionManager.destroy();
+ });
+
QUnit.test('footer buttons are moved to the dialog footer', function (assert) {
assert.expect(3);
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index ed44c23f07d..7dd69db4a7b 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -143,10 +143,12 @@ QUnit.module('relational_fields', {
user: {
fields: {
name: {string: "Name", type: "char"},
+ partner_ids: {string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id'},
},
records: [{
id: 17,
name: "Aline",
+ partner_ids: [1, 2],
}, {
id: 19,
name: "Christine",
@@ -1178,7 +1180,7 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('list in form: discard newly added element with empty required field (default_get)', function (assert) {
+ QUnit.test('item not dropped on discard with empty required field (default_get)', function (assert) {
// This test simulates discarding a record that has been created with
// one of its required field that is empty. When we discard the changes
// on this empty field, it should not assume that this record should be
@@ -1322,7 +1324,7 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('list in form: discard newly added element with empty required field (onchange in default_get)', function (assert) {
+ QUnit.test('list in form: item not dropped on discard with empty required field (onchange in default_get)', function (assert) {
// variant of the test "list in form: discard newly added element with
// empty required field (default_get)", in which the `default_get`
// performs an `onchange` at the same time. This `onchange` may create
@@ -1392,11 +1394,13 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('list in form: discard newly added element with empty required field (onchange after default_get)', function (assert) {
+ QUnit.test('list in form: item not dropped on discard with empty required field (onchange on list after default_get)', function (assert) {
// discarding a record from an `onchange` in a `default_get` should not
- // abandon the record. However, any `onchange` after `default_get`
- // should be abandoned on discard
- assert.expect(6);
+ // abandon the record. This should not be the case for following
+ // `onchange`, except if an onchange make some changes on the list:
+ // in particular, if an onchange make changes on the list such that
+ // a record is added, this record should not be dropped on discard
+ assert.expect(8);
var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
@@ -1448,10 +1452,127 @@ QUnit.module('relational_fields', {
// click off so that the required field still stay empty
$('body').click();
+ // record should not be dropped
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the editable list");
+ assert.strictEqual($('td.o_data_cell').first().text(), "entry",
+ "should still have the correct displayed name");
+ assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
+ "should still have empty string in the required field");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ });
+
+ QUnit.test('item dropped on discard with empty required field with "Add an item" (invalid on "ADD")', function (assert) {
+ // when a record in a list is added with "Add an item", it should
+ // always be dropped on discard if some required field are empty
+ // at the record creation.
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // Click on "Add an item"
+ form.$('.o_field_x2many_list_row_add a').click();
+ var charField = form.$('.o_field_widget.o_field_char[name="display_name"]');
+ var requiredField = form.$('.o_field_widget.o_required_modifier[name="trululu"]');
+ charField.val("some text");
+ assert.strictEqual(charField.length, 1,
+ "should have a char field 'display_name' on this record");
+ assert.notOk(charField.hasClass('o_required_modifier'),
+ "the char field should not be required on this record");
+ assert.strictEqual(charField.val(), "some text",
+ "should have entered text in the char field on this record");
+ assert.strictEqual(requiredField.length, 1,
+ "should have a required field 'trululu' on this record");
+ assert.strictEqual(requiredField.val().trim(), "",
+ "should have empty string in the required field on this record");
+
+ // click on empty required field in editable list record
+ requiredField.click();
+ // click off so that the required field still stay empty
+ $('body').click();
+
// record should be dropped
assert.strictEqual(form.$('.o_data_row').length, 0,
"should have dropped record in the editable list");
+ form.destroy();
+ });
+
+ QUnit.test('item not dropped on discard with empty required field with "Add an item" (invalid on "UPDATE")', function (assert) {
+ // when a record in a list is added with "Add an item", it should
+ // be temporarily added to the list when it is valid (e.g. required
+ // fields are non-empty). If the record is updated so that the required
+ // field is empty, and it is discarded, then the record should not be
+ // dropped.
+ assert.expect(8);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "should initially not have any record in the list");
+
+ // Click on "Add an item"
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have a temporary record in the list");
+
+ var $inputEditMode = form.$('.o_field_widget.o_required_modifier[name="trululu"] input');
+ assert.strictEqual($inputEditMode.length, 1,
+ "should have a required field 'trululu' on this record");
+ assert.strictEqual($inputEditMode.val(), "",
+ "should have empty string in the required field on this record");
+
+ // add something to required field and leave edit mode of the record
+ $inputEditMode.click();
+ $('li.ui-menu-item').first().click();
+ $('body').click(); // leave edit mode on the line
+
+ var $inputReadonlyMode = form.$('.o_data_cell.o_required_modifier');
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped valid record when leaving edit mode");
+ assert.strictEqual($inputReadonlyMode.text(), "first record",
+ "should have put some content in the required field on this record");
+
+ // remove the required field and leave edit mode of the record
+ $('.o_data_row').click(); // enter edit mode on the line
+ $inputEditMode.click();
+ $inputEditMode.val("");
+ $('body').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the list on discard (invalid on UPDATE)");
+ assert.strictEqual($inputReadonlyMode.text(), "first record",
+ "should keep previous valid required field content on this record");
+
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
form.destroy();
});
@@ -9415,7 +9536,7 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('onchange in a one2Many, but not in main record, with non inline view', function (assert) {
+ QUnit.test('onchange in a one2many with non inline view on an existing record', function (assert) {
assert.expect(6);
this.data.partner.fields.sequence = {string: 'Sequence', type: 'integer'};
@@ -9452,6 +9573,53 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchange in a one2many with non inline view on a new record', function (assert) {
+ assert.expect(8);
+
+ this.data.turtle.onchanges = {
+ display_name: function (obj) {
+ if (obj.display_name) {
+ obj.turtle_int = 44;
+ }
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="turtles"/></form>',
+ archs: {
+ 'turtle,false,list': '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // add a row and trigger the onchange
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_data_row .o_field_widget[name=display_name]').val('a name').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row .o_field_widget[name=turtle_int]').val(), "44",
+ "should have triggered the onchange");
+
+ assert.verifySteps([
+ 'load_views', // load sub list
+ 'default_get', // main record
+ 'onchange', // main record
+ 'default_get', // sub record
+ 'onchange', // sub record
+ 'onchange', // edition of display_name of sub record
+ ]);
+
+ form.destroy();
+ });
+
QUnit.test('add a line, edit it and "Save & New"', function (assert) {
assert.expect(5);
@@ -9567,6 +9735,94 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchange affecting inline unopened list view', function (assert) {
+ // when we got onchange result for fields of record that were not
+ // already available because they were in a inline view not already
+ // opened, in a given configuration the change were applied ignoring
+ // existing data, thus a line of a one2many field inside a one2many
+ // field could be duplicated unexplectedly
+ assert.expect(5);
+
+ var numUserOnchange = 0;
+
+ this.data.user.onchanges = {
+ partner_ids: function (obj) {
+ if (numUserOnchange === 0) {
+ // simulate proper server onchange after save of modal with new record
+ obj.partner_ids = [
+ [5],
+ [1, 1, {
+ display_name: 'first record',
+ turtles: [
+ [5],
+ [1, 2, {'display_name': 'donatello'}],
+ ],
+ }],
+ [1, 2, {
+ display_name: 'second record',
+ turtles: [
+ [5],
+ obj.partner_ids[1][2].turtles[0],
+ ],
+ }],
+ ];
+ }
+ numUserOnchange++;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'user',
+ data: this.data,
+ arch: '<form><sheet><group>' +
+ '<field name="partner_ids">' +
+ '<form>'+
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group></sheet></form>',
+ res_id: 17,
+ });
+
+ // add a turtle on second partner
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_row:eq(1)').click();
+ $('.modal .o_field_x2many_list_row_add a').click();
+ $('.modal input[name="display_name"]').val('michelangelo').change();
+ $('.modal .btn-primary').click();
+ // open first partner so changes from previous action are applied
+ form.$('.o_data_row:eq(0)').click();
+ $('.modal .btn-primary').click();
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(numUserOnchange, 2,
+ 'there should 2 and only 2 onchange from closing the partner modal');
+
+ form.$('.o_data_row:eq(0)').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ 'only 1 turtle for first partner');
+ assert.strictEqual($('.modal .o_data_row').text(), 'donatello',
+ 'first partner turtle is donatello');
+ $('.modal .o_form_button_cancel').click();
+
+ form.$('.o_data_row:eq(1)').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ 'only 1 turtle for second partner');
+ assert.strictEqual($('.modal .o_data_row').text(), 'michelangelo',
+ 'second partner turtle is michelangelo');
+ $('.modal .o_form_button_cancel').click();
+
+ form.destroy();
+ });
+
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 44ac724995b..4ffdd72b089 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -1335,6 +1335,51 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('disable buttons until reload data from server', function (assert) {
+ assert.expect(2);
+ var def;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ args.args[1].foo = "apple";
+ } else if (args.method === 'read') {
+ // Block the 'read' call
+ var result = this._super.apply(this, arguments);
+ return $.when(def).then(_.constant(result));
+ }
+ return this._super(route, args);
+ },
+ res_id: 2,
+ });
+
+ var def = $.Deferred();
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('input').val("tralala").trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ // Save button should be disabled
+ assert.strictEqual(
+ form.$buttons.find('.o_form_button_save').attr('disabled'),
+ 'disabled', 'buttons should be disabled')
+
+ // Release the 'read' call
+ def.resolve();
+
+ // Edit button should be enabled after the reload
+ assert.strictEqual(
+ form.$buttons.find('.o_form_button_edit').attr('disabled'),
+ undefined, 'buttons should be enabled')
+
+ form.destroy();
+ });
+
QUnit.test('properly apply onchange in simple case', function (assert) {
assert.expect(2);