From 9b90d8727da9df6eb7ce756c4f9ffb0973e6ea65 Mon Sep 17 00:00:00 2001 From: Vandan Shah Date: Thu, 25 Oct 2018 06:53:00 +0000 Subject: [PATCH] [IMP] web: enable drag&drop in ungrouped kanban views With this rev., the resequencing feature (with drag and drop) that was already available in list views is now available also in kanban views having a field with widget="handle". This works for main as well as x2many kanban views. Some code handling the resequencing has been moved from the list components (controller and renderer) to the basic ones, so that the logic is shared between list and kanban. Task 1902808 closes odoo/odoo#28581 Signed-off-by: VincentSchippefilt Co-authored-by: Mohammed Shekha Co-authored-by: Aaron Bohy --- .../static/src/js/fields/relational_fields.js | 53 ++++++++----- .../src/js/views/basic/basic_controller.js | 32 ++++++++ .../src/js/views/basic/basic_renderer.js | 58 ++++++++++++++ .../src/js/views/kanban/kanban_renderer.js | 25 ++++++ .../static/src/js/views/kanban/kanban_view.js | 2 +- .../src/js/views/list/list_controller.js | 28 ------- .../js/views/list/list_editable_renderer.js | 66 ++-------------- .../static/src/js/views/list/list_renderer.js | 3 - .../relational_fields/field_one2many_tests.js | 55 +++++++++++++- addons/web/static/tests/views/kanban_tests.js | 76 +++++++++++++++++++ doc/reference/javascript_reference.rst | 5 +- doc/reference/views.rst | 20 +++-- 12 files changed, 301 insertions(+), 122 deletions(-) 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?