diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 6650022c486..7bae1bc29a5 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -152,11 +152,33 @@ return { } return $to_detach.detach(); }, + /** + * Returns the input or textarea cursor offset + * + * @param {Object} DOM item input or texteara + * @returns {Object} + */ + getCursor: function (node) { + node = $(node)[0]; + var offset = 0; + var length = 0; + if ('selectionStart' in node) { + offset = node.selectionStart; + length = node.selectionEnd - offset; + } else if ('selection' in document) { + node.focus(); + var sel = document.selection.createRange(); + length = document.selection.createRange().text.length; + sel.moveStart('character', -node.value.length); + offset = sel.text.length - length; + } + return { offset: offset, length: length }; + }, /** * Returns the distance between a DOM element and the top-left corner of the * window * - * @param {element} [e] the DOM element + * @param {Object} node DOM element (input or texteara) * @return {Object} the left and top distances in pixels */ getPosition: function (e) { @@ -254,5 +276,23 @@ return { } return $container; }, + /** + * Moves the cursor position in a DOM element (input or texteara) + * + * @param {Object} node DOM element (input or textarea) + * @param {Int} pos cursor position + */ + setCursor: function (node, pos) { + node = $(node).first().focus()[0]; + if ('createTextRange' in node) { + var textRange = node.createTextRange(); + textRange.collapse(true); + textRange.moveEnd(pos); + textRange.moveStart(pos); + textRange.select(); + } else if ('setSelectionRange' in node) { + node.setSelectionRange(pos, pos); + } + }, }; }); diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 301717d985d..ba9b947d6a3 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -20,6 +20,7 @@ var ControlPanel = require('web.ControlPanel'); var dialogs = require('web.view_dialogs'); var core = require('web.core'); var data = require('web.data'); +var dom = require('web.dom'); var Dialog = require('web.Dialog'); var KanbanRenderer = require('web.KanbanRenderer'); var ListRenderer = require('web.ListRenderer'); @@ -684,6 +685,7 @@ var FieldX2Many = AbstractField.extend({ if (command.operation === 'UPDATE' && command.data) { var state = record.data[this.name]; var fieldNames = state.getFieldNames(); + this._reset(record, ev); this.renderer.confirmChange(state, command.id, fieldNames, ev.initialEvent); return $.when(); } @@ -895,11 +897,33 @@ var FieldX2Many = AbstractField.extend({ this.lastInitialEvent = undefined; if (Object.keys(changes).length) { this.lastInitialEvent = ev; - this._setValue({ + // store the cursor position to restore it once potential onchanges have been applied + var self = this; + var editableID = this.renderer.getEditableRecordID(); + var datapoint = _.find(this.value.data, {id: editableID}); + + var ref = datapoint && datapoint.ref; + var cursor = ref && dom.getCursor(ev.target.getFocusableElement()); + var fieldName = ev.target.name; + + var def = this._setValue({ operation: 'UPDATE', id: ev.data.dataPointID, data: changes, }); + + if (ref) { + def.then(function () { + var nextEditableRecordID = self.renderer.getEditableRecordID(); + if (nextEditableRecordID && editableID !== nextEditableRecordID) { + return; + } + var datapoint = _.find(self.value.data, {ref: ref}); + if (datapoint) { + self.renderer.focusField(datapoint.id, fieldName, cursor && cursor.offset); + } + }); + } } }, /** 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 0d95c292070..537fa11fdb9 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -92,10 +92,10 @@ var session = require('web.session'); var _t = core._t; var x2ManyCommands = { - // (0, _, {values}) + // (0, virtualID, {values}) CREATE: 0, - create: function (values) { - return [x2ManyCommands.CREATE, false, values]; + create: function (virtualID, values) { + return [x2ManyCommands.CREATE, virtualID || false, values]; }, // (1, id, {values}) UPDATE: 1, @@ -391,6 +391,7 @@ var BasicModel = AbstractModel.extend({ limit: element.limit, model: element.model, offset: element.offset, + ref: element.ref, res_ids: element.res_ids.slice(0), specialData: _.extend({}, element.specialData), type: 'record', @@ -1213,6 +1214,7 @@ var BasicModel = AbstractModel.extend({ modelName: list.model, parentID: list.id, viewType: list.viewType, + ref: command[1], }; if (command[0] === 1) { params.res_id = command[1]; @@ -2415,7 +2417,7 @@ var BasicModel = AbstractModel.extend({ commands[fieldName].push(x2ManyCommands.replace_with(realIDs)); _.each(relRecordCreated, function (relRecord) { var changes = self._generateChanges(relRecord, options); - commands[fieldName].push(x2ManyCommands.create(changes)); + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); }); // generate update commands for records that have been // updated (it may happen with editable lists) @@ -2451,7 +2453,7 @@ var BasicModel = AbstractModel.extend({ // this is a new id relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); changes = this._generateChanges(relRecord, options); - commands[fieldName].push(x2ManyCommands.create(changes)); + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); } } if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { @@ -2824,6 +2826,7 @@ var BasicModel = AbstractModel.extend({ orderedBy: params.orderedBy || [], parentID: params.parentID, rawContext: params.rawContext, + ref: params.ref || res_id, relationField: params.relationField, res_id: res_id, res_ids: res_ids, 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 e040a5fa7f0..0e26e2fa19f 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -10,7 +10,7 @@ odoo.define('web.BasicRenderer', function (require) { var AbstractRenderer = require('web.AbstractRenderer'); var config = require('web.config'); var core = require('web.core'); -var Domain = require('web.Domain'); +var dom = require('web.dom'); var qweb = core.qweb; @@ -115,6 +115,20 @@ var BasicRenderer = AbstractRenderer.extend({ return resetWidgets; }); }, + /** + * Activates the widget and move the cursor to the given offset + * + * @param {string} id + * @param {string} fieldName + * @param {integer} offset + */ + focusField: function (id, fieldName, offset) { + this.editRecord(id); + if (typeof offset === "number") { + var field = _.findWhere(this.allFieldWidgets[id], {name: fieldName}); + dom.setCursor(field.getFocusableElement(), offset); + } + }, //-------------------------------------------------------------------------- // Private diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 89f4601efc4..82b668a11fb 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1005,7 +1005,7 @@ QUnit.module('relational_fields', { obj.timmy, [ [6, false, []], - [0, false, {display_name: 'brandon is the new timmy', name: 'brandon'}] + [0, obj.timmy[1][1], {display_name: 'brandon is the new timmy', name: 'brandon'}] ], "should have properly created the x2many command list"); obj.int_field = obj.timmy.length; @@ -2077,7 +2077,7 @@ QUnit.module('relational_fields', { "should send 46 commands (one for each record)"); assert.strictEqual(nbLinkCommands, 45, "should send a LINK_TO command for each existing record"); - assert.deepEqual(args.args[1].p[45], [0, false, { + assert.deepEqual(args.args[1].p[45], [0, args.args[1].p[45][1], { display_name: 'new record', }], "should sent a CREATE command for the new record"); break; @@ -2095,10 +2095,10 @@ QUnit.module('relational_fields', { assert.strictEqual(nbLinkCommands, 43, "should send a LINK_TO command for each existing record"); assert.deepEqual(args.args[1].p[43], - [0, false, {display_name: 'new record page 1'}], + [0, args.args[1].p[43][1], {display_name: 'new record page 1'}], "should sent correct CREATE command"); assert.deepEqual(args.args[1].p[44], - [0, false, {display_name: 'new record page 2'}], + [0, args.args[1].p[44][1], {display_name: 'new record page 2'}], "should sent correct CREATE command"); assert.deepEqual(args.args[1].p[45], [2, 11, false], @@ -3294,7 +3294,7 @@ QUnit.module('relational_fields', { mockRPC: function (method, args) { if (args.method === 'write') { assert.deepEqual(args.args[1].p, [ - [0, false, {display_name: 'z'}], + [0, args.args[1].p[0][1], {display_name: 'z'}], [2, 2, false], ], "correct commands should be sent"); } @@ -3436,7 +3436,7 @@ QUnit.module('relational_fields', { }, mockRPC: function (route, args) { if (args.method === 'onchange') { - assert.deepEqual(args.args[1].p, [[4, 2, false], [0, false, {product_id: 41}]], + assert.deepEqual(args.args[1].p, [[4, 2, false], [0, args.args[1].p[1][1], {product_id: 41}]], "should trigger onchange with correct parameters"); } return this._super.apply(this, arguments); @@ -3498,7 +3498,7 @@ QUnit.module('relational_fields', { mockRPC: function (route, args) { rpcCount++; if (args.method === 'write') { - assert.deepEqual(args.args[1].p, [[0, false, { + assert.deepEqual(args.args[1].p, [[0, args.args[1].p[0][1], { display_name: false, int_field: 123, product_id: 41 }]]); } @@ -3736,7 +3736,7 @@ QUnit.module('relational_fields', { if (args.method === 'default_get') { var expected = counter === 0 ? [[4, 2, false]] : - [[4, 2, false], [0, false, {display_name: false, turtle_foo: 'hammer'}]]; + [[4, 2, false], [0, args.kwargs.context.turtles[1][1], {display_name: false, turtle_foo: 'hammer'}]]; assert.deepEqual(args.kwargs.context.turtles, expected, "should have properly evaluated turtles key in context"); counter++; @@ -4387,6 +4387,137 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange many2many in one2many list editable', function (assert) { + assert.expect(14); + + this.data.product.records.push({ + id: 1, + display_name: "xenomorphe", + }); + + this.data.turtle.onchanges = { + product_id: function (rec) { + if (rec.product_id) { + rec.partner_ids = [ + [5], + [4, rec.product_id === 41 ? 1 : 2] + ]; + } + }, + }; + var partnerOnchange = function (rec) { + if (!rec.int_field || !rec.turtles.length) { + return; + } + rec.turtles = [ + [5], + [0, 0, { + display_name: 'new line', + product_id: [37, 'xphone'], + partner_ids: [ + [5], + [4, 1] + ] + }], + [0, rec.turtles[0][1], { + display_name: rec.turtles[0][2].display_name, + product_id: [1, 'xenomorphe'], + partner_ids: [ + [5], + [4, 2] + ] + }], + ]; + }; + + this.data.partner.onchanges = { + int_field: partnerOnchange, + turtles: partnerOnchange, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + + form.$('.o_field_x2many_list_row_add a').click(); + form.$('input[name="display_name"]').val('first').trigger('input'); + form.$('div[name="product_id"] input').click(); + $('li.ui-menu-item a').click(); + + assert.strictEqual(form.$('.o_field_many2manytags.o_input').length, 1, + 'should display the line in editable mode'); + assert.strictEqual(form.$('.o_field_many2one input').val(), "xpad", + 'should display the product'); + assert.strictEqual(form.$('.o_field_many2manytags.o_input .o_badge_text').text(), "first record", + 'should display the tag from the onchange'); + + form.$('input.o_field_integer[name="int_field"]').click(); + + assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xpad", + 'should display the product xpad'); + assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "first record", + 'should display the tag in readonly'); + + form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input'); + + assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xphonexenomorphe", + 'should display the product xphone and xenomorphe'); + assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "new linexphone first record firstxenomorphe second record ", + 'should display the name, one2many and many2many value'); + + form.$('input.o_field_integer[name="int_field"]').val('0').trigger('input'); + + form.$('.o_list_record_delete:first span').click(); + form.$('.o_list_record_delete:first span').click(); + + form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input'); + + form.$('.o_field_x2many_list_row_add a').click(); + form.$('input[name="display_name"]').val('first').trigger('input'); + form.$('div[name="product_id"] input').click(); + $('li.ui-menu-item a').click(); + + assert.strictEqual(form.$('.o_field_many2manytags.o_input').length, 1, + 'should display the line in editable mode'); + assert.strictEqual(form.$('.o_field_many2one input').val(), "xenomorphe", + 'should display the product xenomorphe'); + assert.strictEqual(form.$('.o_field_many2manytags.o_input .o_badge_text').text(), "second record", + 'should display the tag from the onchange'); + + form.$('input.o_field_integer[name="int_field"]').click(); + + assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xphonexenomorphe", + 'should display the product xphone and xenomorphe'); + assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "first recordsecond record", + 'should display the tag in readonly (first record and second record)'); + + form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "new linexphone first record firstxenomorphe second record ", + 'should display the name, one2many and many2many value'); + + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "new linexphone first record firstxenomorphe second record ", + 'should display the name, one2many and many2many value after save'); + + form.destroy(); + }); + QUnit.test('load view for x2many in one2many', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 318b715a504..2c5f6fe8993 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -1186,7 +1186,7 @@ QUnit.module('Views', { data: this.data, mockRPC: function (route, args) { if (args.method === 'onchange' && args.args[1].total === 150) { - assert.deepEqual(args.args[1].product_ids, [[0, false, {name: "xpod"}]], + assert.deepEqual(args.args[1].product_ids, [[0, args.args[1].product_ids[0][1], {name: "xpod"}]], "Should have sent the create command in the onchange"); } return this._super(route, args); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index c71ec8f2d93..dcb422b00ca 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -1174,7 +1174,7 @@ QUnit.module('Views', { display_name: "first record", foo: "tralala", id: 1, - p: [[0, false, {"display_name": "valid line", "qux": 12.4}]] + p: [[0, args.args[1].p[0][1], {"display_name": "valid line", "qux": 12.4}]] }, "should send the values for the present fields"); } return this._super(route, args); @@ -3760,7 +3760,7 @@ QUnit.module('Views', { mockRPC: function (route, args) { if (args.method === 'create') { var command = args.args[0].p; - assert.deepEqual(command, [[0, false, { + assert.deepEqual(command, [[0, command[0][1], { display_name: false, timmy: [[6, false, [12]]], }]], "the default partner_type_id should be equal to 12"); @@ -3809,8 +3809,8 @@ QUnit.module('Views', { mockRPC: function (route, args) { if (args.method === 'create') { assert.deepEqual(args.args[0].p, - [[0, false, { - p: [[0, false, {display_name: "xtv"}]], + [[0, args.args[0].p[0][1], { + p: [[0, args.args[0].p[0][2].p[0][1], {display_name: "xtv"}]], }]], "create should be called with the correct arguments"); } @@ -5488,13 +5488,13 @@ QUnit.module('Views', { "readonly fields value should be sent for onchanges"); } else { // onchange on field p assert.deepEqual(args.args[1].p, [ - [0, false, {display_name: 'readonly', foo: 'foo value'}] + [0, args.args[1].p[0][1], {display_name: 'readonly', foo: 'foo value'}] ], "readonly fields value should be sent for onchanges"); } } if (args.method === 'create') { assert.deepEqual(args.args[0], { - p: [[0, false, {display_name: 'readonly'}]] + p: [[0, args.args[0].p[0][1], {display_name: 'readonly'}]] }, "should not have sent the value of the readonly field"); } return this._super.apply(this, arguments);