diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-06-29 12:46:00 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-06-29 12:46:00 +0200 |
| commit | 49edee1f4bd74bc342863d8ef50eb86de0b4c55f (patch) | |
| tree | 20f449f08ba1f4a548c9cb2d82f67f2545048419 /addons/web/static | |
| parent | 4a08388492c6667b7f4a11f1397b8557c355b208 (diff) | |
| parent | d54a3726eca6ad48d969d5b4a6e559f783809832 (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.js | 2 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager.js | 10 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/loading.js | 6 | ||||
| -rw-r--r-- | addons/web/static/src/js/core/ajax.js | 62 | ||||
| -rw-r--r-- | addons/web/static/src/js/core/session.js | 67 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/basic_fields.js | 16 | ||||
| -rw-r--r-- | addons/web/static/src/js/services/crash_manager.js | 3 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_model.js | 44 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/form/form_controller.js | 6 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/form/form_view.js | 1 | ||||
| -rw-r--r-- | addons/web/static/tests/chrome/action_manager_tests.js | 73 | ||||
| -rw-r--r-- | addons/web/static/tests/fields/relational_fields_tests.js | 270 | ||||
| -rw-r--r-- | addons/web/static/tests/views/form_tests.js | 45 |
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); |
