[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 <VincentSchippefilt@users.noreply.github.com>


Co-authored-by: Mohammed Shekha <msh@openerp.com>
Co-authored-by: Aaron Bohy <aab@odoo.com>
This commit is contained in:
Vandan Shah
2019-04-23 13:45:15 +00:00
committed by Aaron Bohy
co-authored by Mohammed Shekha Aaron Bohy
parent 8c931ffc2e
commit 9b90d8727d
12 changed files with 301 additions and 122 deletions
@@ -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);
});
},
@@ -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
@@ -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
@@ -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);
@@ -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;
@@ -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.
@@ -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
@@ -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;
@@ -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: '<form>' +
'<field name="turtles">' +
'<kanban>' +
'<field name="turtle_int" widget="handle"/>' +
'<templates>' +
'<t t-name="kanban-box">' +
'<div><field name="turtle_foo"/></div>' +
'</t>' +
'</templates>' +
'</kanban>' +
'</field>' +
'</form>',
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();
});
});
});
});
@@ -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: '<kanban>' +
'<field name="int_field" widget="handle" />' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="foo"/>' +
'</div>' +
'</t></templates></kanban>',
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: '<kanban>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="foo"/>' +
'</div>' +
'</t></templates></kanban>',
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();
});
});
});
+3 -2
View File
@@ -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*
+12 -8
View File
@@ -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?