[IMP] web: grouped Kanban: UX improvements in mobile
This commit aims to improve the UX of grouped Kanban views on mobile devices. Only one column is now displayed, full width. The columns' records are lazy-loaded. The user can swipe horizontally to navigate through columns (thanks to the touchSwipe library).
This commit is contained in:
committed by
Aaron Bohy
parent
39e83e84c7
commit
a12cfa8260
File diff suppressed because it is too large
Load Diff
@@ -238,7 +238,7 @@ var KanbanColumn = Widget.extend({
|
||||
this.$header.find('.o_column_title').text(title);
|
||||
this.$header.find('.o-kanban-count').text(this.records.length);
|
||||
|
||||
this.$el.toggleClass('o_column_folded', this.folded);
|
||||
this.$el.toggleClass('o_column_folded', this.folded && !config.isMobile);
|
||||
var tooltip = this.size + _t(' records');
|
||||
tooltip = '<p>' + tooltip + '</p>' + this.tooltipInfo;
|
||||
this.$header.find('.o_kanban_header_title').tooltip({html: true}).attr('data-original-title', tooltip);
|
||||
|
||||
@@ -10,6 +10,7 @@ odoo.define('web.KanbanController', function (require) {
|
||||
var BasicController = require('web.BasicController');
|
||||
var Context = require('web.Context');
|
||||
var core = require('web.core');
|
||||
var config = require('web.config');
|
||||
var view_dialogs = require('web.view_dialogs');
|
||||
|
||||
var _t = core._t;
|
||||
@@ -29,6 +30,7 @@ var KanbanController = BasicController.extend({
|
||||
kanban_column_archive_records: '_onArchiveRecords',
|
||||
kanban_load_more: '_onLoadMore',
|
||||
column_toggle_fold: '_onToggleColumn',
|
||||
kanban_load_records: '_onLoadColumnRecords'
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
@@ -231,6 +233,19 @@ var KanbanController = BasicController.extend({
|
||||
self._updateEnv();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {OdooRevent} event
|
||||
*/
|
||||
_onLoadColumnRecords: function (event) {
|
||||
var self = this;
|
||||
var column = event.target;
|
||||
// Calling loadMore with offset override to zero
|
||||
this.model.loadMore(column.db_id, 0).then(function (db_id) {
|
||||
var data = self.model.get(db_id);
|
||||
self.renderer.updateColumn(db_id, data);
|
||||
});
|
||||
|
||||
},
|
||||
/**
|
||||
* @param {OdooEvent} event
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ odoo.define('web.KanbanModel', function (require) {
|
||||
*/
|
||||
|
||||
var BasicModel = require('web.BasicModel');
|
||||
var config = require('web.config');
|
||||
|
||||
var KanbanModel = BasicModel.extend({
|
||||
|
||||
@@ -128,11 +129,12 @@ var KanbanModel = BasicModel.extend({
|
||||
* Load more records in a group.
|
||||
*
|
||||
* @param {string} groupID localID of the group
|
||||
* @param {integer} offset optional offset override
|
||||
* @returns {Deferred<string>} resolves to the localID of the group
|
||||
*/
|
||||
loadMore: function (groupID) {
|
||||
loadMore: function (groupID, offset) {
|
||||
var group = this.localData[groupID];
|
||||
var offset = group.loadMoreOffset + group.limit;
|
||||
offset = offset === 0 ? 0 : group.loadMoreOffset + group.limit;
|
||||
return this.reload(group.id, {
|
||||
loadMoreOffset: offset,
|
||||
});
|
||||
@@ -250,6 +252,12 @@ var KanbanModel = BasicModel.extend({
|
||||
if (list.groupedBy.length > 1) {
|
||||
list.groupedBy = [list.groupedBy[0]];
|
||||
}
|
||||
// to prevent pre-loading data by default in mobile for kanban column
|
||||
// by setting openGroupByDefault to 'false' which will set column's isOpen property
|
||||
// to false and will ingore _load().
|
||||
if (config.isMobile) {
|
||||
list.openGroupByDefault = false;
|
||||
}
|
||||
return this._super.apply(this, arguments).then(function (result) {
|
||||
return self._readTooltipFields(list).then(_.constant(result));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ odoo.define('web.KanbanRenderer', function (require) {
|
||||
|
||||
var BasicRenderer = require('web.BasicRenderer');
|
||||
var core = require('web.core');
|
||||
var config = require("web.config");
|
||||
var KanbanColumn = require('web.KanbanColumn');
|
||||
var KanbanRecord = require('web.KanbanRecord');
|
||||
var quick_create = require('web.kanban_quick_create');
|
||||
@@ -109,6 +110,9 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
}
|
||||
|
||||
this._setState(state);
|
||||
// Used for mobile, when returning back to view
|
||||
this.lastActiveMobileTab = -1;
|
||||
this.isMobile = config.isMobile;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -116,10 +120,12 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Displays the quick create record in the first column.
|
||||
* Displays the quick create record in the first column if not mobile otherwise,
|
||||
* Uses active column index.
|
||||
*/
|
||||
addQuickCreate: function () {
|
||||
this.widgets[0].addQuickCreate();
|
||||
var index = this.lastActiveMobileTab > -1 ? this.lastActiveMobileTab : 0;
|
||||
this.widgets[index].addQuickCreate();
|
||||
},
|
||||
/**
|
||||
* Toggle fold/unfold the Column quick create widget
|
||||
@@ -145,11 +151,18 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
updateColumn: function (localID, columnState) {
|
||||
var self = this;
|
||||
var newColumn = new KanbanColumn(this, columnState, this.columnOptions, this.recordOptions);
|
||||
var index = _.findIndex(this.widgets, {db_id: localID});
|
||||
var column = this.widgets[index];
|
||||
this.widgets[index] = newColumn;
|
||||
return newColumn.insertAfter(column.$el).then(column.destroy.bind(column));
|
||||
return newColumn.insertAfter(column.$el).then(function () {
|
||||
column.destroy();
|
||||
if (self.isMobile) {
|
||||
newColumn.$el.addClass("current");
|
||||
self._enableSwipeOnRecordGroup(newColumn);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Updates a given record with its new state.
|
||||
@@ -201,6 +214,88 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
this.createColumnEnabled ||
|
||||
(this.state.groupedBy.length && this.state.data.length);
|
||||
},
|
||||
/* Enable swipe event on kanban column in mobile
|
||||
*
|
||||
* @private
|
||||
* @param {KanbanColumn} column
|
||||
*/
|
||||
_enableSwipeOnRecordGroup: function (column) {
|
||||
var self = this;
|
||||
column.$el.swipe({
|
||||
swipeLeft: function() {
|
||||
self._moveToGroup(++self.lastActiveMobileTab);
|
||||
},
|
||||
swipeRight: function() {
|
||||
self._moveToGroup(--self.lastActiveMobileTab);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Enables swipe on kanban columns
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_enableSwipeOnRecordGroups: function() {
|
||||
var self = this;
|
||||
this.$el.find(".o_kanban_group").swipe({
|
||||
swipeLeft: function() {
|
||||
self._moveToGroup(++self.lastActiveMobileTab);
|
||||
},
|
||||
swipeRight: function() {
|
||||
self._moveToGroup(--self.lastActiveMobileTab);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Moving to kanan column group when swiping kanban column in mobile
|
||||
*
|
||||
* @private
|
||||
* @param {integer} moveToIndex
|
||||
*/
|
||||
_moveToGroup: function (moveToIndex) {
|
||||
var self = this;
|
||||
if (this.widgets.length - 1 < moveToIndex) {
|
||||
this.lastActiveMobileTab = this.widgets.length - 1;
|
||||
return;
|
||||
} else if (moveToIndex < 0) {
|
||||
this.lastActiveMobileTab = 0;
|
||||
return;
|
||||
}
|
||||
this.lastActiveMobileTab = moveToIndex;
|
||||
var moveTo = moveToIndex;
|
||||
var next = moveToIndex + 1;
|
||||
var previous = moveToIndex - 1;
|
||||
this.$(".o_kanban_group").removeClass("previous next current before after");
|
||||
this.$(".o_kanban_mobile_tab").removeClass("previous next current before after");
|
||||
_.each(this.widgets, function(column, index) {
|
||||
var recordPane = self.$(".o_kanban_group[data-id=" + column.id + "]");
|
||||
var tab = self.$(".o_kanban_mobile_tab[data-id=" + column.id + "]");
|
||||
if (index == previous) {
|
||||
tab.addClass("previous");
|
||||
tab.css("margin-left", "-" + (tab.outerWidth() / 2) + "px");
|
||||
recordPane.addClass("previous");
|
||||
} else if (index == next) {
|
||||
tab.addClass("next");
|
||||
tab.css("margin-left", "-" + (tab.outerWidth() / 2) + "px");
|
||||
recordPane.addClass("next");
|
||||
} else if (index < moveTo) {
|
||||
tab.addClass("before");
|
||||
tab.css("margin-left", "-" + tab.outerWidth() + "px");
|
||||
recordPane.addClass("before");
|
||||
} else if (index == moveTo) {
|
||||
var marginLeft = tab.outerWidth() / 2;
|
||||
tab.css("margin-left", "-" + marginLeft + "px");
|
||||
tab.addClass("current");
|
||||
recordPane.addClass("current");
|
||||
if (column.isEmpty())
|
||||
column.trigger_up('kanban_load_records');
|
||||
} else if (index > moveTo) {
|
||||
tab.addClass("after");
|
||||
tab.css("margin-left", "0");
|
||||
recordPane.addClass("after");
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Renders empty invisible divs in a document fragment.
|
||||
*
|
||||
@@ -224,6 +319,12 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
var self = this;
|
||||
|
||||
// Render columns
|
||||
if (this.isMobile) {
|
||||
this.$el.append($(qweb.render("KanbanView.MobileTabs", {'data': this.state.data})));
|
||||
this.$(".o_kanban_mobile_tab").on('click', function (event) {
|
||||
self._moveToGroup($(event.currentTarget).index());
|
||||
});
|
||||
}
|
||||
_.each(this.state.data, function (group) {
|
||||
var column = new KanbanColumn(self, group, self.columnOptions, self.recordOptions);
|
||||
if (!group.value) {
|
||||
@@ -306,6 +407,14 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
this._renderUngrouped(fragment);
|
||||
}
|
||||
this.$el.append(fragment);
|
||||
if (this.widgets && config.isMobile && isGrouped) {
|
||||
core.bus.on("DOM_updated", this, function() {
|
||||
this._enableSwipeOnRecordGroups();
|
||||
this._moveToGroup(this.lastActiveMobileTab);
|
||||
});
|
||||
// Required to allow mobile kanban stage tabs to enable swipe and move to active tab
|
||||
core.bus.trigger('DOM_updated');
|
||||
}
|
||||
}
|
||||
|
||||
return this._super.apply(this, arguments).then(_.invoke.bind(_, oldWidgets, 'destroy'));
|
||||
|
||||
@@ -3,6 +3,7 @@ odoo.define('web.KanbanView', function (require) {
|
||||
|
||||
var BasicView = require('web.BasicView');
|
||||
var core = require('web.core');
|
||||
var config = require('web.config');
|
||||
var KanbanModel = require('web.KanbanModel');
|
||||
var KanbanRenderer = require('web.KanbanRenderer');
|
||||
var KanbanController = require('web.KanbanController');
|
||||
@@ -20,6 +21,7 @@ var KanbanView = BasicView.extend({
|
||||
Model: KanbanModel,
|
||||
Controller: KanbanController,
|
||||
Renderer: KanbanRenderer,
|
||||
js_libs: []
|
||||
},
|
||||
viewType: 'kanban',
|
||||
|
||||
@@ -96,7 +98,21 @@ var KanbanView = BasicView.extend({
|
||||
return JSON.parse(viewInfo.arch.attrs.quick_create);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Adding touch swipe library for mobile
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_loadLibs: function () {
|
||||
if (config.isMobile) {
|
||||
this.config.js_libs.push("/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js");
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
return KanbanView;
|
||||
|
||||
});
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
border-color: @gray-lighter-darker;
|
||||
min-width: 150px;
|
||||
margin: 0 0 -1px;
|
||||
@media (max-width: @screen-xs-max) {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
// ------- Kanban Record, v11 Layout -------
|
||||
// Records colours
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
@o-kanban-mobile-tabs-height: 40px;
|
||||
|
||||
.o_kanban_view {
|
||||
|
||||
.o_kanban_mobile_tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-xs-max){
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&.o_kanban_grouped {
|
||||
padding: 0px;
|
||||
|
||||
.o_column_quick_create {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_kanban_mobile_tabs {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: @o-kanban-mobile-tabs-height;
|
||||
background: #5E5E5E;
|
||||
|
||||
.o_kanban_mobile_tab {
|
||||
display: block;
|
||||
position: absolute;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
width: auto;
|
||||
left: 100%;
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
height: @o-kanban-mobile-tabs-height;
|
||||
transition: 0.2s all;
|
||||
transform: translate3d(0,0,0);
|
||||
|
||||
.o_column_title {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
background: #ffffff;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
transition: 0.2s all;
|
||||
transform: translate3d(0,3px,0);
|
||||
}
|
||||
|
||||
&.previous, &.before {
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
&.current {
|
||||
left: 50%;
|
||||
font-weight: 600;
|
||||
|
||||
&:after {
|
||||
opacity: 1;
|
||||
transform: translate3d(0,0,0);
|
||||
}
|
||||
}
|
||||
|
||||
&.next, &.after {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.o_kanban_small_column .o_kanban_group {
|
||||
&:not(.o_column_folded) {
|
||||
overflow-x: scroll;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: @o-kanban-mobile-tabs-height;
|
||||
padding-bottom: @o-kanban-mobile-tabs-height;
|
||||
padding-top: 8px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: 0.2s all;
|
||||
transform: translate3d(0,0,0);
|
||||
|
||||
.o_kanban_header {
|
||||
display: none;
|
||||
}
|
||||
.o_kanban_record, .o_kanban_quick_create {
|
||||
width: auto;
|
||||
border-radius: 2px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.o_kanban_quick_create {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&.previous {
|
||||
left: -100%;
|
||||
}
|
||||
|
||||
&.current, &.before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.next, &.after {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,16 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="KanbanView.MobileTabs">
|
||||
<div class="o_kanban_mobile_tabs">
|
||||
<t t-foreach="data" t-as="group">
|
||||
<div class="o_kanban_mobile_tab" t-att-data-id="group.res_id">
|
||||
<span class="o_column_title"><t t-esc="group.value"/></span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="KanbanView.Group">
|
||||
<div class="o_kanban_group" t-att-data-id="widget.id">
|
||||
<div class="o_kanban_header">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
odoo.define('web.kanban_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
|
||||
var KanbanView = require('web.KanbanView');
|
||||
var testUtils = require('web.test_utils');
|
||||
var widgetRegistry = require('web.widget_registry');
|
||||
var Widget = require('web.Widget');
|
||||
var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
|
||||
var widgetRegistry = require('web.widget_registry');
|
||||
|
||||
var createAsyncView = testUtils.createAsyncView;
|
||||
var createView = testUtils.createView;
|
||||
|
||||
QUnit.module('Views', {
|
||||
@@ -2051,6 +2053,52 @@ QUnit.module('Views', {
|
||||
"quick create should have been added in the first column");
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('mobile grouped rendering', function (assert) {
|
||||
assert.expect(8);
|
||||
var done = assert.async();
|
||||
|
||||
createAsyncView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<kanban class="o_kanban_test o_kanban_small_column" on_create="quick_create">' +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
'<div><field name="foo"/></div>' +
|
||||
'</t></templates>' +
|
||||
'</kanban>',
|
||||
groupBy: ['product_id'],
|
||||
config: {
|
||||
isMobile: true
|
||||
}
|
||||
}).then(function (kanban) {
|
||||
|
||||
// Dummy dom update trigger for activate mobile tabs and move to first column
|
||||
core.bus.trigger("DOM_updated");
|
||||
|
||||
assert.equal(kanban.$el.find('.o_kanban_group').length, 2, "2 colomns are created" );
|
||||
|
||||
kanban.$buttons.find('.o-kanban-button-new').click(); // Click on 'Create'
|
||||
assert.ok(kanban.$('.o_kanban_group:nth(0) > div:nth(1)').hasClass('o_kanban_quick_create'),
|
||||
"clicking on create should open the quick_create in the first column");
|
||||
|
||||
assert.equal(kanban.$el.find('.o_kanban_mobile_tab.current > span').html(), "hello", "First tab 'hello' is active tab with class 'current'" );
|
||||
assert.equal(kanban.$el.find('.o_kanban_group.current > div.o_kanban_record').length, "2", "there is 2 record in active 'hello' tab" );
|
||||
assert.equal(kanban.$el.find('.o_kanban_group.next > div.o_kanban_record').length, "0", "there is 0 record in next tab. Records will load when click on next tab");
|
||||
|
||||
kanban.$el.find('.o_kanban_mobile_tab.next').trigger('click'); // Moving to next tab
|
||||
assert.equal(kanban.$el.find('.o_kanban_mobile_tab.current > span').html(), "xmo", "Second tab 'xmo' is active with class 'current'" );
|
||||
assert.equal(kanban.$el.find('.o_kanban_group.current > div.o_kanban_record').length, "2", "there is 2 record in active 'xmo' tab. Records are loaded after click on tab");
|
||||
|
||||
kanban.$buttons.find('.o-kanban-button-new').click(); // Click on 'Create'
|
||||
assert.ok(kanban.$('.o_kanban_group:nth(1) > div:nth(1)').hasClass('o_kanban_quick_create'),
|
||||
"clicking on create should open the quick_create in the second column");
|
||||
|
||||
kanban.destroy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/kanban_dashboard.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/kanban_column_progressbar.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/kanban_view.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/kanban_view_mobile.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/web_calendar.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/search_view.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/search_view_extra.less"/>
|
||||
@@ -132,6 +133,7 @@
|
||||
<script type="text/javascript" src="/base/static/src/js/res_config_settings.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/web/static/lib/jquery.scrollTo/jquery.scrollTo.js"></script>
|
||||
<script type="text/javascript" src="/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js"></script>
|
||||
<script type="text/javascript" src="/web/static/lib/backbone/backbone.js"></script>
|
||||
<script type="text/javascript" src="/web/static/lib/fuzzy-master/fuzzy.js"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user