summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorVandan Shah <vas@odoo.com>2018-10-25 06:53:00 +0000
committerAaron Bohy <aab@odoo.com>2019-04-23 13:45:15 +0000
commit9b90d8727da9df6eb7ce756c4f9ffb0973e6ea65 (patch)
tree0617989834e0d6915314b39d1c07d5b50690b1b2 /addons/web/static
parent8c931ffc2e6f31caac4c15050403a84ca92b8c82 (diff)
[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>
Diffstat (limited to 'addons/web/static')
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js53
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js32
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js58
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js25
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js2
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js28
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js66
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js3
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_one2many_tests.js55
-rw-r--r--addons/web/static/tests/views/kanban_tests.js76
10 files changed, 286 insertions, 112 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',
@@ -642,6 +643,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
@@ -389,6 +392,61 @@ var BasicRenderer = AbstractRenderer.extend({
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
* place where work having to do with $el should be done.
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',
@@ -630,33 +629,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;
@@ -891,65 +896,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
* the target cell is readonly, we need to find another suitable cell), then
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: '<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();
+ });
});
});
});
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: '<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();
+ });
+
});
});