diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 642fa55b33e..85342269617 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -812,7 +812,7 @@ var FieldX2Many = AbstractField.extend({ open_record: '_onOpenRecord', kanban_record_delete: '_onRemoveRecord', list_record_remove: '_onRemoveRecord', - resequence: '_onResequence', + resequence_records: '_onResequenceRecords', save_line: '_onSaveLine', toggle_column_order: '_onToggleColumnOrder', activate_next_widget: '_onActiveNextWidget', @@ -1043,6 +1043,7 @@ var FieldX2Many = AbstractField.extend({ }; _.extend(rendererParams, { record_options: record_options, + readOnlyMode: this.isReadonly, }); } @@ -1351,38 +1352,52 @@ var FieldX2Many = AbstractField.extend({ * Forces a resequencing of the records. * * @private - * @param {OdooEvent} event + * @param {OdooEvent} ev + * @param {string[]} ev.data.recordIds + * @param {integer} ev.data.offset + * @param {string} ev.data.handleField */ - _onResequence: function (event) { - event.stopPropagation(); + _onResequenceRecords: function (ev) { + ev.stopPropagation(); var self = this; - this.trigger_up('edited_list', { id: this.value.id }); - var rowIDs = event.data.rowIDs.slice(); - var rowID = rowIDs.pop(); - var defs = _.map(rowIDs, function (rowID, index) { + if (this.view.arch.tag === 'tree') { + this.trigger_up('edited_list', { id: this.value.id }); + } + var handleField = ev.data.handleField; + var offset = ev.data.offset; + var recordIds = ev.data.recordIds.slice(); + // trigger an update of all records but the last one with option + // 'notifyChanges' set to false, and once all those changes have been + // validated by the model, trigger the change on the last record + // (without the option, s.t. the potential onchange on parent record + // is triggered) + var recordId = recordIds.pop(); + var proms = recordIds.map(function (recordId, index) { var data = {}; - data[event.data.handleField] = event.data.offset + index; + data[handleField] = offset + index; return self._setValue({ operation: 'UPDATE', - id: rowID, + id: recordId, data: data, }, { notifyChange: false, }); }); - Promise.all(defs).then(function () { + Promise.all(proms).then(function () { function always() { - self.trigger_up('toggle_column_order', { - id: self.value.id, - name: event.data.handleField, - }); + if (self.view.arch.tag === 'tree') { + self.trigger_up('toggle_column_order', { + id: self.value.id, + name: handleField, + }); + } } - - // trigger only once the onchange for parent record + var data = {}; + data[handleField] = offset + recordIds.length; self._setValue({ operation: 'UPDATE', - id: rowID, - data: _.object([event.data.handleField], [event.data.offset + rowIDs.length]), + id: recordId, + data: data, }).then(always).guardedCatch(always); }); }, diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 064f7b84a52..610e7e6dc6b 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -19,6 +19,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { discard_changes: '_onDiscardChanges', reload: '_onReload', + resequence_records: '_onResequenceRecords', set_dirty: '_onSetDirty', sidebar_data_asked: '_onSidebarDataAsked', translate: '_onTranslate', @@ -641,6 +642,37 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { }); } }, + /** + * Resequence records in the given order. + * + * @private + * @param {OdooEvent} ev + * @param {string[]} ev.data.recordIds + * @param {integer} ev.data.offset + * @param {string} ev.data.handleField + */ + _onResequenceRecords: function (ev) { + var self = this; + + this.trigger_up('mutexify', { + action: function () { + var state = self.model.get(self.handle); + var resIDs = _.map(ev.data.recordIds, function (recordID) { + return _.findWhere(state.data, {id: recordID}).res_id; + }); + var options = { + offset: ev.data.offset, + field: ev.data.handleField, + }; + return self.model.resequence(self.modelName, resIDs, self.handle, options) + .then(function () { + self._updateEnv(); + state = self.model.get(self.handle); + return self.renderer.updateState(state, {noRender: true}); + }); + }, + }); + }, /** * @private * @param {OdooEvent} ev 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 4c03d9b77a1..4ee2ef8fbb3 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -31,6 +31,9 @@ var BasicRenderer = AbstractRenderer.extend({ this.viewType = params.viewType; this.mode = params.mode || 'readonly'; this.widgets = []; + // This attribute lets us know if there is a handle widget on a field, + // and on which field it is set. + this.handleField = null; }, /** * This method has two responsabilities: find every invalid fields in the @@ -388,6 +391,61 @@ var BasicRenderer = AbstractRenderer.extend({ _hasContent: function () { return this.state.count !== 0; }, + /** + * Force the resequencing of the records after moving one of them to a given + * index. + * + * @private + * @param {string} recordId datapoint id of the moved record + * @param {integer} toIndex new index of the moved record + */ + _moveRecord: function (recordId, toIndex) { + var self = this; + var records = this.state.data; + var record = _.findWhere(records, {id: recordId}); + var fromIndex = records.indexOf(record); + var lowerIndex = Math.min(fromIndex, toIndex); + var upperIndex = Math.max(fromIndex, toIndex) + 1; + var order = _.findWhere(this.state.orderedBy, {name: this.handleField}); + var asc = !order || order.asc; + var reorderAll = false; + var sequence = (asc ? -1 : 1) * Infinity; + + // determine if we need to reorder all records + _.each(records, function (record, index) { + if ((index < lowerIndex || index >= upperIndex) && + ((asc && sequence >= record.data[self.handleField]) || + (!asc && sequence <= record.data[self.handleField]))) { + reorderAll = true; + } + sequence = record.data[self.handleField]; + }); + + if (reorderAll) { + records = _.without(records, record); + records.splice(toIndex, 0, record); + } else { + records = records.slice(lowerIndex, upperIndex); + records = _.without(records, record); + if (fromIndex > toIndex) { + records.unshift(record); + } else { + records.push(record); + } + } + + var sequences = _.pluck(_.pluck(records, 'data'), this.handleField); + var recordIds = _.pluck(records, 'id'); + if (!asc) { + recordIds.reverse(); + } + + this.trigger_up('resequence_records', { + handleField: this.handleField, + offset: _.min(sequences), + recordIds: recordIds, + }); + }, /** * This function is called each time a field widget is created, when it is * ready (after its willStart and Start methods are complete). This is the diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index 4c5326f755b..9e86c857085 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -121,6 +121,10 @@ var KanbanRenderer = BasicRenderer.extend({ this.columnOptions.progressBarStates = {}; } this.quickCreateEnabled = params.quickCreateEnabled; + if (!params.readOnlyMode) { + var handleField = _.findWhere(this.state.fieldsInfo.kanban, {widget: 'handle'}); + this.handleField = handleField && handleField.name; + } this._setState(state); }, /** @@ -384,6 +388,27 @@ var KanbanRenderer = BasicRenderer.extend({ self.defs.push(def); }); + // enable record resequencing if there is a field with widget='handle' + // and if there is no orderBy (in this case we assume that the widget + // has been put on the first default order field of the model), or if + // the first orderBy field is the one with widget='handle' + var orderedBy = this.state.orderedBy; + var hasHandle = this.handleField && + (orderedBy.length === 0 || orderedBy[0].name === this.handleField); + if (hasHandle) { + this.$el.sortable({ + items: '.o_kanban_record:not(.o_kanban_ghost)', + cursor: 'move', + revert: 0, + delay: 0, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function (event, ui) { + self._moveRecord(ui.item.data('record').db_id, ui.item.index()); + }, + }); + } + // append ghost divs to ensure that all kanban records are left aligned var prom = Promise.all(self.defs).then(function () { return self._renderGhostDivs(fragment, 6); diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js index 49ba9b74034..3764cf7ca22 100644 --- a/addons/web/static/src/js/views/kanban/kanban_view.js +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -77,13 +77,13 @@ var KanbanView = BasicView.extend({ read_only_mode: params.readOnlyMode, }; this.rendererParams.quickCreateEnabled = this._isQuickCreateEnabled(); + this.rendererParams.readOnlyMode = params.readOnlyMode; var examples = archAttrs.examples; if (examples) { this.rendererParams.examples = kanbanExamplesRegistry.get(examples); } this.controllerParams.on_create = archAttrs.on_create; - this.controllerParams.readOnlyMode = false; this.controllerParams.hasButtons = true; this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled; diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 5db0323c87d..10d3c0d54c5 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -29,7 +29,6 @@ var ListController = BasicController.extend({ group_edit_button_clicked: '_onEditGroupClicked', edit_line: '_onEditLine', save_line: '_onSaveLine', - resequence: '_onResequence', selection_changed: '_onSelectionChanged', toggle_column_order: '_onToggleColumnOrder', toggle_group: '_onToggleGroup', @@ -629,33 +628,6 @@ var ListController = BasicController.extend({ } this._super.apply(this, arguments); }, - /** - * Force a resequence of the records curently on this page. - * - * @private - * @param {OdooEvent} ev - */ - _onResequence: function (ev) { - var self = this; - - this.trigger_up('mutexify', { - action: function () { - var state = self.model.get(self.handle); - var resIDs = _.map(ev.data.rowIDs, function(rowID) { - return _.findWhere(state.data, {id: rowID}).res_id; - }); - var options = { - offset: ev.data.offset, - field: ev.data.handleField, - }; - return self.model.resequence(self.modelName, resIDs, self.handle, options).then(function () { - self._updateEnv(); - state = self.model.get(self.handle); - return self.renderer.updateState(state, {noRender: true}); - }); - }, - }); - }, /** * Called when the renderer displays an editable row and the user tries to * leave it -> Saves the record associated to that line. diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 818739c6f48..c68f5ab573a 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -775,6 +775,7 @@ ListRenderer.include({ * @override */ _renderBody: function () { + var self = this; var $body = this._super.apply(this, arguments); if (this.hasHandle) { $body.sortable({ @@ -782,7 +783,11 @@ ListRenderer.include({ items: '> tr.o_data_row', helper: 'clone', handle: '.o_row_handle', - stop: this._resequence.bind(this), + stop: function (event, ui) { + self.unselectRow().then(function () { + self._moveRecord(ui.item.data('id'), ui.item.index()); + }); + }, }); } return $body; @@ -890,65 +895,6 @@ ListRenderer.include({ } }); }, - /** - * Force the resequencing of the items in the list. - * - * @private - * @param {jQuery.Event} event - * @param {Object} ui jqueryui sortable widget - */ - _resequence: function (event, ui) { - var self = this; - var movedRecordID = ui.item.data('id'); - var rows = this.state.data; - var row = self._getRecord(movedRecordID); - var index0 = rows.indexOf(row); - var index1 = ui.item.index(); - var lower = Math.min(index0, index1); - var upper = Math.max(index0, index1) + 1; - - var order = _.findWhere(self.state.orderedBy, {name: self.handleField}); - var asc = !order || order.asc; - var reorderAll = false; - var sequence = (asc ? -1 : 1) * Infinity; - - // determine if we need to reorder all lines - _.each(rows, function (row, index) { - if ((index < lower || index >= upper) && - ((asc && sequence >= row.data[self.handleField]) || - (!asc && sequence <= row.data[self.handleField]))) { - reorderAll = true; - } - sequence = row.data[self.handleField]; - }); - - if (reorderAll) { - rows = _.without(rows, row); - rows.splice(index1, 0, row); - } else { - rows = rows.slice(lower, upper); - rows = _.without(rows, row); - if (index0 > index1) { - rows.unshift(row); - } else { - rows.push(row); - } - } - - var sequences = _.pluck(_.pluck(rows, 'data'), self.handleField); - var rowIDs = _.pluck(rows, 'id'); - - if (!asc) { - rowIDs.reverse(); - } - this.unselectRow().then(function () { - self.trigger_up('resequence', { - rowIDs: rowIDs, - offset: _.min(sequences), - handleField: self.handleField, - }); - }); - }, /** * This is one of the trickiest method in the editable renderer. It has to * do a lot of stuff: it has to determine which cell should be selected (if diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 471598ca4e4..4d8be05d252 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -51,9 +51,6 @@ var ListRenderer = BasicRenderer.extend({ */ init: function (parent, state, params) { this._super.apply(this, arguments); - // This attribute lets us know if there is a handle widget on a field, - // and on which field it is set. - this.handleField = null; this.rowDecorations = _.chain(this.arch.attrs) .pick(function (value, key) { return DECORATIONS.indexOf(key) >= 0; diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js index 85975d99231..40d362d220c 100644 --- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js @@ -2,7 +2,6 @@ odoo.define('web.field_one_to_many_tests', function (require) { "use strict"; var AbstractField = require('web.AbstractField'); -var concurrency = require('web.concurrency'); var FormView = require('web.FormView'); var KanbanRecord = require('web.KanbanRecord'); var ListRenderer = require('web.ListRenderer'); @@ -121,6 +120,7 @@ QUnit.module('fields', {}, function () { display_name: "leonardo", turtle_bar: true, turtle_foo: "yop", + turle_int: 1, partner_ids: [], }, { id: 2, @@ -8290,6 +8290,59 @@ QUnit.module('fields', {}, function () { form.destroy(); }); + + QUnit.test('one2many kanban with widget handle', async function (assert) { + assert.expect(5); + + this.data.partner.records[0].turtles = [1, 2, 3]; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
', + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], { + turtles: [ + [1, 2, {turtle_int: 0}], + [1, 3, {turtle_int: 1}], + [1, 1, {turtle_int: 2}], + ], + }); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').text(), 'yopblipkawa'); + assert.doesNotHaveClass(form.$('.o_field_one2many .o_kanban_view'), 'ui-sortable'); + + await testUtils.form.clickEdit(form); + + assert.hasClass(form.$('.o_field_one2many .o_kanban_view'), 'ui-sortable'); + + var $record = form.$('.o_field_one2many[name=turtles] .o_kanban_view .o_kanban_record:first'); + var $to = form.$('.o_field_one2many[name=turtles] .o_kanban_view .o_kanban_record:nth-child(3)'); + await testUtils.dom.dragAndDrop($record, $to, {position: "bottom"}); + + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').text(), 'blipkawayop'); + + await testUtils.form.clickSave(form); + + form.destroy(); + }); }); }); }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index f0dc078f26c..8fbbab8c27b 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -5571,6 +5571,82 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('ungrouped kanban with handle field', async function (assert) { + assert.expect(4); + + var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '' + + '
' + + '' + + '
' + + '
', + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + assert.deepEqual(args.ids, envIDs, + "should write the sequence in correct order"); + return Promise.resolve(true); + } + return this._super(route, args); + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'), 'ui-sortable'); + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'yopblipgnapblip'); + + var $record = kanban.$('.o_kanban_view .o_kanban_record:first'); + var $to = kanban.$('.o_kanban_view .o_kanban_record:nth-child(4)'); + envIDs = [2, 3, 4, 1]; // first record of moved after last one + await testUtils.dom.dragAndDrop($record, $to, {position: "bottom"}); + + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'blipgnapblipyop'); + + kanban.destroy(); + }); + + QUnit.test('ungrouped kanban without handle field', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '
' + + '' + + '
' + + '
', + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + assert.ok(false, "should not trigger a resequencing"); + } + return this._super(route, args); + }, + }); + + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'ui-sortable'); + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'yopblipgnapblip'); + + var $draggedRecord = kanban.$('.o_kanban_view .o_kanban_record:first'); + var $to = kanban.$('.o_kanban_view .o_kanban_record:nth-child(4)'); + await testUtils.dom.dragAndDrop($draggedRecord, $to, {position: "bottom"}); + + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'yopblipgnapblip'); + + kanban.destroy(); + }); + }); }); diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst index 05e546dfec6..0e6d2578bad 100644 --- a/doc/reference/javascript_reference.rst +++ b/doc/reference/javascript_reference.rst @@ -1673,9 +1673,10 @@ order. - handle (HandleWidget) - This field's job is to be displayed as a *handle* in a list view, and allows - reordering the various records by drag and dropping lines. + This field's job is to be displayed as a *handle*, and allows reordering the + various records by drag and dropping them. + .. warning:: It has to be specified on the field by which records are sorted. .. warning:: Having more than one field with a handle widget on the same list is not supported. - Supported field types: *integer* diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 56dca21bf1d..767bb8bbe5b 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -309,16 +309,14 @@ Possible children elements of the list view are: lists the groups which should be able to see the field ``widget`` alternate representations for a field's display. Possible list view - values are: + values are (among others): ``progressbar`` displays ``float`` fields as a progress bar. - ``many2onebutton`` - replaces the m2o field's value by a checkmark if the field is - filled, and a cross if it is not ``handle`` - for ``sequence`` fields, instead of displaying the field's value - just displays a drag&drop icon + for ``sequence`` (or ``integer``) fields by which records are + sorted, instead of displaying the field's value just displays a + drag&drop icon to reorder records. ``sum``, ``avg`` displays the corresponding aggregate at the bottom of the column. The aggregation is only computed on *currently displayed* records. The @@ -1101,8 +1099,14 @@ Possible children of the view element are: While most of the Kanban templates are standard :ref:`reference/qweb`, the Kanban view processes ``field``, ``button`` and ``a`` elements specially: - * by default fields are replaced by their formatted value, unless they - match specific kanban view widgets + * by default fields are replaced by their formatted value, unless the + ``widget`` attribute is specified, in which case their rendering and + behavior depends on the corresponding widget. Possible values are (among + others): + + ``handle`` + for ``sequence`` (or ``integer``) fields by which records are + sorted, allows to drag&drop records to reorder them. .. todo:: list widgets?