From 2728fe1ec1ac7e99819d0f30f29a1814b00c82a4 Mon Sep 17 00:00:00 2001 From: Christophe Matthieu Date: Thu, 24 Aug 2017 15:27:44 +0200 Subject: [PATCH] [IMP] web: onchange x2many with virtual id in create command When an onchange modifies an x2many field, the line on which the user works remains in editable mode and the cursor is returned to the same position. The second argument of create command is a reference used to reactivate the edited line. --- addons/web/static/src/js/core/dom.js | 42 ++++- .../static/src/js/fields/relational_fields.js | 26 +++- .../static/src/js/views/basic/basic_model.js | 13 +- .../src/js/views/basic/basic_renderer.js | 16 +- .../tests/fields/relational_fields_tests.js | 147 +++++++++++++++++- .../static/tests/views/basic_model_tests.js | 2 +- addons/web/static/tests/views/form_tests.js | 12 +- 7 files changed, 235 insertions(+), 23 deletions(-) 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);