[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:
Dharmang Soni
2017-09-25 14:10:40 +02:00
committed by Aaron Bohy
parent 39e83e84c7
commit a12cfa8260
11 changed files with 2453 additions and 9 deletions
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%;
}
}
}
}
}
}
+10
View File
@@ -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">
+50 -2
View File
@@ -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();
});
});
});
});
+2
View File
@@ -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>