summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorqsm-odoo <qsm@odoo.com>2017-09-14 22:43:43 +0200
committerqsm-odoo <qsm@odoo.com>2017-09-14 23:48:56 +0200
commit33f9e90bd5a9ad3e9239f1b414ea012bb94fa1c0 (patch)
tree18f7ec62c340b1e6e63e5520565b0bd4b7d8a10c /addons
parent031dc1d952837c0d9740e1caac1ab1d923863545 (diff)
[IMP] web, *: add kanban column progress bars
* crm, project Add a new feature which allows to put a progressbar in the kanban columns. The progressbar shows with the same color the amount of records whose value of a given field are the same in the column. It also indicate the sum of another given field or simply the total number of records. It also allows to subgroup the column content. To define a progressbar, add this as a direct child of the kanban arch: <progressbar field="<name of the field to use for subgroups>" colors="{<one possible value for the above field>: <success, warning or danger>, ...}" sum="<name of the field to sum or nothing to use total number of records>"/> Also: - Properly update record model data's parentID when moving a record - ...
Diffstat (limited to 'addons')
-rw-r--r--addons/crm/views/crm_lead_views.xml1
-rw-r--r--addons/project/views/project_views.xml1
-rw-r--r--addons/web/models/__init__.py1
-rw-r--r--addons/web/models/models.py40
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js6
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js47
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column_progressbar.js247
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js10
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js110
-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.js36
-rw-r--r--addons/web/static/src/less/kanban_column_progressbar.less115
-rw-r--r--addons/web/static/src/xml/kanban.xml15
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js2
-rw-r--r--addons/web/static/tests/helpers/mock_server.js37
-rw-r--r--addons/web/static/tests/views/kanban_tests.js38
-rw-r--r--addons/web/views/webclient_templates.xml2
17 files changed, 713 insertions, 20 deletions
diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index a8b72e90556..3e8b0494310 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -298,6 +298,7 @@
<field name="company_currency"/>
<field name="activity_state" />
<field name="activity_ids" />
+ <progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum="planned_revenue"/>
<templates>
<field name="date_deadline"/>
<t t-name="kanban-box">
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index b714cd28c9d..eb001c6490f 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -505,6 +505,7 @@
<field name="legend_done"/>
<field name="activity_ids"/>
<field name="activity_state"/>
+ <progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger"}'/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click">
diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py
index 494031ac888..29ae76aa206 100644
--- a/addons/web/models/__init__.py
+++ b/addons/web/models/__init__.py
@@ -3,3 +3,4 @@
from . import ir_qweb
from . import ir_http
+from . import models
diff --git a/addons/web/models/models.py b/addons/web/models/models.py
new file mode 100644
index 00000000000..09d56349048
--- /dev/null
+++ b/addons/web/models/models.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+from odoo import _, api, fields, models
+
+class Base(models.AbstractModel):
+ _inherit = 'base'
+
+ @api.model
+ def read_progress_bar(self, domain, group_by, progress_bar):
+ """
+ Gets the data needed for all the kanban column progressbars.
+ These are fetched alongside read_group operation.
+
+ :param domain - the domain used in the kanban view to filter records
+ :param group_by - the name of the field used to group records into
+ kanban columns
+ :param progress_bar - the <progressbar/> declaration attributes
+ (field, colors, sum)
+ :return a dictionnary mapping group_by values to dictionnaries mapping
+ progress bar field values to the related number of records
+ """
+ fields = [progress_bar['field'], group_by]
+ records_values = self.search_read(domain or [], fields)
+
+ data = {}
+ for record_values in records_values:
+ group_by_value = record_values[group_by]
+ if type(group_by_value) == tuple:
+ group_by_value = group_by_value[1] # FIXME should use technical value (0)
+
+ if group_by_value not in data:
+ data[group_by_value] = {}
+ for key in progress_bar['colors']:
+ data[group_by_value][key] = 0
+
+ field_value = record_values[progress_bar['field']]
+ if field_value in data[group_by_value]:
+ data[group_by_value][field_value] += 1
+
+ return data
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 779073b51ed..bcccefbb984 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -1576,7 +1576,7 @@ var BasicModel = AbstractModel.extend({
if (change.id) {
relRecord = self.localData[change.id];
}
- switch(change.operation) {
+ switch (change.operation) {
case 'ADD':
list.count++;
var resID = relRecord ? relRecord.res_id : change.resID;
@@ -1666,7 +1666,7 @@ var BasicModel = AbstractModel.extend({
var result = {};
var self = this;
var evalContext;
- function evalModifier (mod) {
+ function evalModifier(mod) {
if (mod === undefined || mod === false || mod === true) {
return !!mod;
}
@@ -2775,7 +2775,7 @@ var BasicModel = AbstractModel.extend({
var context = _.extend({}, record.data, record._changes);
// calls _generateX2ManyCommands for a given field, and returns the array of commands
- function _generateX2ManyCommands (fieldName) {
+ function _generateX2ManyCommands(fieldName) {
var commands = self._generateX2ManyCommands(record, {fieldNames: [fieldName]});
return commands[fieldName];
}
diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js
index ab071d0e930..dac28c536dd 100644
--- a/addons/web/static/src/js/views/kanban/kanban_column.js
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -8,6 +8,7 @@ var kanban_quick_create = require('web.kanban_quick_create');
var KanbanRecord = require('web.KanbanRecord');
var view_dialogs = require('web.view_dialogs');
var Widget = require('web.Widget');
+var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
var _t = core._t;
var QWeb = core.qweb;
@@ -19,6 +20,8 @@ var KanbanColumn = Widget.extend({
cancel_quick_create: '_onCancelQuickCreate',
kanban_record_delete: '_onDeleteRecord',
quick_create_add_record: '_onQuickCreateAddRecord',
+ tweak_column: '_onTweakColumn',
+ tweak_column_records: '_onTweakColumnRecords',
},
events: {
'click .o_column_edit': '_onEditColumn',
@@ -58,6 +61,13 @@ var KanbanColumn = Widget.extend({
this.offset = 0;
this.remaining = this.size - this.data_records.length;
+ if (options.hasProgressBar) {
+ this.barOptions = {
+ columnID: this.db_id,
+ progressBarStates: options.progressBarStates,
+ };
+ }
+
this.record_options = _.clone(recordOptions);
if (options.grouped_by_m2o) {
@@ -81,6 +91,7 @@ var KanbanColumn = Widget.extend({
*/
start: function () {
var self = this;
+ var defs = [this._super.apply(this, arguments)];
this.$header = this.$('.o_kanban_header');
for (var i = 0; i < this.data_records.length; i++) {
@@ -128,9 +139,12 @@ var KanbanColumn = Widget.extend({
self._onToggleFold(event);
}
});
- this._update();
+ if (this.barOptions) {
+ this.progressBar = new KanbanColumnProgressBar(this, this.barOptions, this.data);
+ defs.push(this.progressBar.appendTo(this.$header));
+ }
- return this._super.apply(this, arguments);
+ return $.when.apply($, defs).then(this._update.bind(this));
},
//--------------------------------------------------------------------------
@@ -180,6 +194,18 @@ var KanbanColumn = Widget.extend({
isEmpty: function () {
return !this.records.length;
},
+ /**
+ * Updates the column progressBar and sets the new data. New data are
+ * supposed to already match column rendering (except for the progressBar).
+ *
+ * @param {Object} data
+ */
+ updateProgressBar: function (data) {
+ this.data = data;
+ if (!this.folded && this.progressBar) {
+ this.progressBar.update(this.data);
+ }
+ },
//--------------------------------------------------------------------------
// Private
@@ -221,6 +247,7 @@ var KanbanColumn = Widget.extend({
} else {
this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this}));
}
+ this.updateProgressBar(this.data);
},
//--------------------------------------------------------------------------
@@ -321,6 +348,22 @@ var KanbanColumn = Widget.extend({
},
/**
* @private
+ * @param {OdooEvent} ev
+ */
+ _onTweakColumn: function (ev) {
+ ev.data.callback(this.$el);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onTweakColumnRecords: function (ev) {
+ _.each(this.records, function (record) {
+ ev.data.callback(record.$el, record.state.data);
+ });
+ },
+ /**
+ * @private
* @param {MouseEvent} event
*/
_onUnarchiveRecords: function (event) {
diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
new file mode 100644
index 00000000000..c4a62989b97
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
@@ -0,0 +1,247 @@
+odoo.define('web.KanbanColumnProgressBar', function (require) {
+'use strict';
+
+var Widget = require('web.Widget');
+var session = require('web.session');
+var utils = require('web.utils');
+
+var KanbanColumnProgressBar = Widget.extend({
+ template: 'KanbanView.ColumnProgressBar',
+ events: {
+ 'click .o_kanban_counter_progress': '_onProgressBarParentClick',
+ 'click .progress-bar': '_onProgressBarClick',
+ },
+ /**
+ * Allows to disable animations for tests.
+ * @type {boolean}
+ */
+ ANIMATE: true,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, columnState) {
+ this._super.apply(this, arguments);
+
+ this.columnID = options.columnID;
+
+ // <progressbar/> attributes
+ this.fieldName = columnState.progressBarValues.field;
+ this.colors = columnState.progressBarValues.colors;
+ this.sumField = columnState.progressBarValues.sum;
+
+ // Previous progressBar state
+ var state = options.progressBarStates[this.columnID];
+ this.groupCount = state ? state.groupCount : 0;
+ this.subgroupCounts = state ? state.subgroupCounts : {};
+ this.totalCounterValue = state ? state.totalCounterValue : 0;
+ this.activeFilter = state ? state.activeFilter : false;
+
+ // Prepare currency (TODO this should be automatic... use a field ?)
+ this.counterPrefix = '';
+ this.counterSuffix = '';
+ var sumFieldInfo = this.sumField && columnState.fieldsInfo.kanban[this.sumField];
+ var currencyField = sumFieldInfo && sumFieldInfo.options && sumFieldInfo.options.currency_field;
+ if (currencyField && columnState.data.length) {
+ var currency = session.currencies[columnState.data[0].data[currencyField].res_id];
+ if (currency.position === 'before') {
+ this.counterPrefix = currency.symbol + ' ';
+ } else {
+ this.counterSuffix = ' ' + currency.symbol;
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ this.$bars = {};
+ _.each(this.colors, function (val, key) {
+ self.$bars[val] = self.$('.bg-' + val + '-full');
+ });
+ this.$counter = this.$('.o_kanban_counter_side');
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates internal data and rendering according to new received column
+ * state.
+ *
+ * @param {Object} columnState
+ */
+ update: function (columnState) {
+ var self = this;
+
+ var subgroupCounts = {};
+ _.each(self.colors, function (val, key) {
+ var subgroupCount = columnState.progressBarValues.counts[key] || 0;
+ if (self.activeFilter === val && subgroupCount === 0) {
+ self.activeFilter = false;
+ }
+ subgroupCounts[key] = subgroupCount;
+ });
+
+ this.groupCount = columnState.count;
+ this.subgroupCounts = subgroupCounts;
+ this.prevTotalCounterValue = this.totalCounterValue;
+ this.totalCounterValue = this.sumField ? (columnState.aggregateValues[this.sumField] || 0) : columnState.count;
+ this._notifyState();
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the rendering according to internal data. This is done without
+ * qweb rendering because there are animations.
+ *
+ * @private
+ */
+ _render: function () {
+ var self = this;
+
+ // Update column display according to active filter
+ this.trigger_up('tweak_column', {
+ callback: function ($el) {
+ $el.removeClass('o_kanban_group_show');
+ _.each(self.colors, function (val, key) {
+ $el.removeClass('o_kanban_group_show_' + val);
+ });
+ if (self.activeFilter) {
+ $el.addClass('o_kanban_group_show o_kanban_group_show_' + self.colors[self.activeFilter]);
+ }
+ },
+ });
+ this.trigger_up('tweak_column_records', {
+ callback: function ($el, recordData) {
+ var categoryValue = recordData[self.fieldName];
+ _.each(self.colors, function (val, key) {
+ $el.removeClass('oe_kanban_card_' + val);
+ });
+ if (self.colors[categoryValue]) {
+ $el.addClass('oe_kanban_card_' + self.colors[categoryValue]);
+ }
+ },
+ });
+
+ // Display and animate the progress bars
+ _.each(self.colors, function (val, key) {
+ var $bar = self.$bars[val];
+ var count = self.subgroupCounts[key];
+
+ if (!$bar) {
+ return;
+ }
+
+ // Adapt tooltip
+ $bar.attr('data-original-title', count + ' ' + key);
+ $bar.tooltip({
+ delay: '0',
+ trigger:'hover',
+ placement: 'top'
+ });
+
+ // Adapt active state
+ $bar.toggleClass('active progress-bar-striped', key === self.activeFilter);
+
+ // Adapt width
+ $bar.removeClass('o_bar_has_records transition-off');
+ window.getComputedStyle($bar[0]).getPropertyValue('width'); // Force reflow so that animations work
+ if (count > 0) {
+ $bar.addClass('o_bar_has_records');
+ $bar.css('width', (count * 100 / self.groupCount) + '%');
+ } else {
+ $bar.css('width', '');
+ }
+ });
+
+ // Display and animate the counter number
+ var humanNumber;
+ var suffix = this.counterSuffix;
+ var end = this.totalCounterValue;
+ if (end >= 10000) {
+ humanNumber = utils.human_number(end, 1);
+ end = parseFloat(humanNumber.substr(0, humanNumber.length - 1));
+ suffix = humanNumber[humanNumber.length - 1] + suffix;
+ }
+ var start = this.prevTotalCounterValue || 0;
+ if (start >= 10000) {
+ humanNumber = utils.human_number(start, 1);
+ start = parseFloat(humanNumber.substr(0, humanNumber.length - 1));
+ }
+ if (end > start && this.ANIMATE) {
+ $({currentValue: start}).animate({currentValue: end}, {
+ duration: 1000,
+ start: function () {
+ self.$counter.addClass('o_kanban_grow');
+ },
+ step: function () {
+ self.$counter.html(self.counterPrefix + Math.round(this.currentValue) + suffix);
+ },
+ complete: function () {
+ self.$counter.html(self.counterPrefix + this.currentValue + suffix);
+ self.$counter.removeClass('o_kanban_grow');
+ },
+ });
+ } else {
+ this.$counter.html(this.counterPrefix + end + suffix);
+ }
+ },
+ /**
+ * Notifies the new progressBar state so that if a full rerender occurs, the
+ * new progressBar that would replace this one will be initialized with
+ * current state, so that animations are correct.
+ *
+ * @private
+ */
+ _notifyState: function () {
+ this.trigger_up('set_progress_bar_state', {
+ columnID: this.columnID,
+ values: {
+ groupCount: this.groupCount,
+ subgroupCounts: this.subgroupCounts,
+ totalCounterValue: this.totalCounterValue,
+ activeFilter: this.activeFilter,
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onProgressBarClick: function (ev) {
+ this.$clickedBar = $(ev.currentTarget);
+ var filter = this.$clickedBar.data('filter');
+ this.activeFilter = (this.activeFilter === filter ? false : filter);
+ this._notifyState();
+ this._render();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onProgressBarParentClick: function (ev) {
+ if (ev.target !== ev.currentTarget) {
+ return;
+ }
+ this.activeFilter = false;
+ this._notifyState();
+ this._render();
+ },
+});
+return KanbanColumnProgressBar;
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 7e018327060..8ba0e3fb7c3 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -78,6 +78,12 @@ var KanbanController = BasicController.extend({
* @returns {Deferred}
*/
_confirmSave: function (id) {
+ var data = this.model.get(this.handle, {raw: true});
+ var grouped = data.groupedBy.length;
+ if (grouped) {
+ var columnState = this.model.getColumn(id);
+ return this.renderer.updateColumn(columnState.id, columnState);
+ }
return this.renderer.updateRecord(this.model.get(id));
},
/**
@@ -164,7 +170,7 @@ var KanbanController = BasicController.extend({
var data = self.model.get(db_id);
self.renderer.updateColumn(db_id, data);
});
- });
+ });
}).fail(this.reload.bind(this));
},
/**
@@ -305,7 +311,7 @@ var KanbanController = BasicController.extend({
context: _.extend({default_name: name}, context),
title: _t("Create"),
disable_multiple_selection: true,
- on_saved: function(record) {
+ on_saved: function (record) {
add_record([record.res_id]);
},
}).open();
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index f714fc519df..400d73c0b7d 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -98,9 +98,25 @@ var KanbanModel = BasicModel.extend({
if (dp && dp.tooltipData) {
result.tooltipData = $.extend(true, {}, dp.tooltipData);
}
+ if (dp && dp.progressBarValues) {
+ result.progressBarValues = $.extend(true, {}, dp.progressBarValues);
+ }
return result;
},
/**
+ * Same as @see get but getting the parent element whose ID is given.
+ *
+ * @param {string} id
+ * @returns {Object}
+ */
+ getColumn: function (id) {
+ var element = this.localData[id];
+ if (element) {
+ return this.get(element.parentID);
+ }
+ return null;
+ },
+ /**
* @override
*/
load: function (params) {
@@ -148,7 +164,8 @@ var KanbanModel = BasicModel.extend({
// Manually updates groups data. Note: this is done before the actual
// save as it might need to perform a read group in some cases so those
// updated data might be overriden again.
- var resID = self.localData[recordID].res_id;
+ var record = self.localData[recordID];
+ var resID = record.res_id;
// Remove record from its current group
var old_group;
for (var i = 0; i < parent.data.length; i++) {
@@ -170,6 +187,7 @@ var KanbanModel = BasicModel.extend({
return this.notifyChanges(recordID, changes).then(function () {
return self.save(recordID);
}).then(function () {
+ record.parentID = new_group.id;
return [old_group.id, new_group.id];
});
},
@@ -182,7 +200,15 @@ var KanbanModel = BasicModel.extend({
if (options && options.groupBy && !options.groupBy.length) {
options.groupBy = this.defaultGroupedBy;
}
- return this._super(id, options);
+ var def = this._super(id, options);
+ return this._reloadProgressBarGroupFromRecord(id, def);
+ },
+ /**
+ * @override
+ */
+ save: function (recordID) {
+ var def = this._super.apply(this, arguments);
+ return this._reloadProgressBarGroupFromRecord(recordID, def);
},
//--------------------------------------------------------------------------
@@ -190,6 +216,25 @@ var KanbanModel = BasicModel.extend({
//--------------------------------------------------------------------------
/**
+ * @override
+ */
+ _makeDataPoint: function (params) {
+ var dataPoint = this._super.apply(this, arguments);
+ if (params.progressBar) {
+ dataPoint.progressBar = params.progressBar;
+ }
+ return dataPoint;
+ },
+ /**
+ * @override
+ */
+ _load: function (dataPoint, options) {
+ if (dataPoint.progressBar) {
+ return this._readProgressBarGroup(dataPoint, options);
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
* Ensures that there is no nested groups in Kanban (only the first grouping
* level is taken into account).
*
@@ -207,6 +252,34 @@ var KanbanModel = BasicModel.extend({
});
},
/**
+ * @private
+ * @param {Object} dataPoint
+ * @returns {Deferred<Object>}
+ */
+ _readProgressBarGroup: function (list, options) {
+ var self = this;
+ var groupsDef = this._readGroup(list, options);
+ var progressBarDef = this._rpc({
+ model: list.model,
+ method: 'read_progress_bar',
+ kwargs: {
+ domain: list.domain,
+ group_by: list.groupedBy[0],
+ progress_bar: list.progressBar,
+ context: list.context,
+ },
+ });
+ return $.when(groupsDef, progressBarDef).then(function (a, data) {
+ _.each(list.data, function (groupID) {
+ var group = self.localData[groupID];
+ group.progressBarValues = _.extend({
+ counts: data[group.value] || {},
+ }, list.progressBar);
+ });
+ return list;
+ });
+ },
+ /**
* Fetches tooltip specific fields on the group by relation and stores it in
* the column datapoint in a special key `tooltipData`.
* Data for the tooltips (group_by_tooltip) are fetched in batch for all
@@ -253,8 +326,37 @@ var KanbanModel = BasicModel.extend({
}
return $.when();
},
-});
+ /**
+ * Reloads all progressbar data if the given id is a record's one. This is
+ * done after given deferred and insures that the given deferred's result is
+ * not lost.
+ *
+ * @private
+ * @param {string} recordID
+ * @param {Deferred} def
+ * @returns {Deferred}
+ */
+ _reloadProgressBarGroupFromRecord: function (recordID, def) {
+ var element = this.localData[recordID];
+ if (element.type !== 'record') {
+ return def;
+ }
+ // If we updated a record, then we must potentially update columns'
+ // progressbars, so we need to load groups info again
+ var self = this;
+ while (element) {
+ if (element.progressBar) {
+ return def.then(function (data) {
+ return self._load(element, {onlyGroups: true}).then(function () {
+ return data;
+ });
+ });
+ }
+ element = this.localData[element.parentID];
+ }
+ return def;
+ },
+});
return KanbanModel;
-
});
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 5d7be3d3636..49752ca2678 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -83,6 +83,10 @@ function transformQwebTemplate(node, fields) {
var KanbanRenderer = BasicRenderer.extend({
className: 'o_kanban_view',
+ custom_events: _.extend({}, BasicRenderer.prototype.custom_events || {}, {
+ 'set_progress_bar_state': '_onSetProgressBarState',
+ }),
+
/**
* @override
*/
@@ -100,6 +104,9 @@ var KanbanRenderer = BasicRenderer.extend({
viewType: 'kanban',
});
this.columnOptions = _.extend({}, params.column_options, { qweb: this.qweb });
+ if (this.columnOptions.hasProgressBar) {
+ this.columnOptions.progressBarStates = {};
+ }
this._setState(state);
},
@@ -336,6 +343,24 @@ var KanbanRenderer = BasicRenderer.extend({
});
this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable;
},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates progressbar internal states (necessary for animations) with
+ * received data.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSetProgressBarState: function (ev) {
+ if (!this.columnOptions.progressBarStates[ev.data.columnID]) {
+ this.columnOptions.progressBarStates[ev.data.columnID] = {};
+ }
+ _.extend(this.columnOptions.progressBarStates[ev.data.columnID], ev.data.values);
+ },
});
return KanbanRenderer;
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 55a90e83242..3e808de72e3 100644
--- a/addons/web/static/src/js/views/kanban/kanban_view.js
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -6,6 +6,7 @@ var core = require('web.core');
var KanbanModel = require('web.KanbanModel');
var KanbanRenderer = require('web.KanbanRenderer');
var KanbanController = require('web.KanbanController');
+var utils = require('web.utils');
var _lt = core._lt;
@@ -21,6 +22,10 @@ var KanbanView = BasicView.extend({
Renderer: KanbanRenderer,
},
viewType: 'kanban',
+
+ /**
+ * @constructor
+ */
init: function (viewInfo, params) {
this._super.apply(this, arguments);
@@ -29,8 +34,20 @@ var KanbanView = BasicView.extend({
this.loadParams.limit = this.loadParams.limit || 40;
this.loadParams.openGroupByDefault = true;
this.loadParams.type = 'list';
-
this.loadParams.groupBy = arch.attrs.default_group_by ? [arch.attrs.default_group_by] : (params.groupBy || []);
+ var progressBar;
+ utils.traverse(arch, function (n) {
+ var isProgressBar = (n.tag === 'progressbar');
+ if (isProgressBar) {
+ progressBar = _.clone(n.attrs);
+ progressBar.colors = JSON.parse(progressBar.colors);
+ progressBar.sum = progressBar.sum || false;
+ }
+ return !isProgressBar;
+ });
+ if (progressBar) {
+ this.loadParams.progressBar = progressBar;
+ }
var activeActions = this.controllerParams.activeActions;
activeActions = _.extend(activeActions, {
@@ -44,6 +61,7 @@ var KanbanView = BasicView.extend({
deletable: activeActions.group_delete,
group_creatable: activeActions.group_create,
quick_create: params.isQuickCreateEnabled || this._isQuickCreateEnabled(viewInfo),
+ hasProgressBar: !!progressBar,
};
this.rendererParams.record_options = {
editable: activeActions.edit,
@@ -56,10 +74,19 @@ var KanbanView = BasicView.extend({
this.controllerParams.readOnlyMode = false;
this.controllerParams.hasButtons = true;
},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} viewInfo
+ */
_isQuickCreateEnabled: function (viewInfo) {
var groupBy = this.loadParams.groupBy[0];
groupBy = groupBy !== undefined ? groupBy.split(':')[0] : undefined;
- if(groupBy !== undefined && !_.contains(['char', 'boolean', 'many2one'], viewInfo.fields[groupBy].type)) {
+ if (groupBy !== undefined && !_.contains(['char', 'boolean', 'many2one'], viewInfo.fields[groupBy].type)) {
return false;
}
if (!this.controllerParams.activeActions.create) {
@@ -70,11 +97,6 @@ var KanbanView = BasicView.extend({
}
return true;
}
-
-
});
-
return KanbanView;
-
});
-
diff --git a/addons/web/static/src/less/kanban_column_progressbar.less b/addons/web/static/src/less/kanban_column_progressbar.less
new file mode 100644
index 00000000000..c1cf86adc81
--- /dev/null
+++ b/addons/web/static/src/less/kanban_column_progressbar.less
@@ -0,0 +1,115 @@
+
+.o-kanban-css-filter(@class, @accent-color) {
+ // Provide CSS reordering and highlight
+ &.o_kanban_group_show_@{class} {
+ @mix-soft: mix(@accent-color, @odoo-webclient-background-color, 5%);
+ @mix-full: mix(@accent-color, @odoo-webclient-background-color);
+
+ &, .o_kanban_header {
+ background-color: @mix-soft;
+ border-color: @mix-full;
+ }
+
+ .progress-bar.bg-@{class}-full {
+ border: 1px solid white;
+ }
+
+ .oe_kanban_card_@{class} {
+ order: 1;
+ margin-bottom: @o-kanban-inside-vgutter*0.5;
+
+ ~ .oe_kanban_card_@{class} {
+ margin-top: -@o-kanban-inside-vgutter*0.5 - 1px;
+ }
+ }
+
+ .o_kanban_load_more {
+ order: 2;
+ padding: @o-kanban-inside-vgutter*0.5 0 @o-kanban-inside-vgutter;
+ }
+
+ .o_kanban_record:not(.oe_kanban_card_@{class}) {
+ order: 3;
+ opacity: 0.5;
+ box-shadow: none;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.o_kanban_counter {
+ position: relative;
+ .o-flex-display();
+ .o-align-items(center);
+ height: 30px;
+ margin-bottom: @o-kanban-record-margin*2;
+
+ > .o_kanban_counter_progress {
+ width: 76%;
+ height: @font-size-base;
+ margin-bottom: 0;
+ background: fade(black, 7%);
+ box-shadow: none;
+
+ .progress-bar {
+ margin-bottom: 0;
+ box-shadow: none;
+ cursor: pointer;
+
+ // Be sure that a bar with data has a minimun width
+ &.o_bar_has_records {
+ min-width: 10px;
+ }
+ }
+
+ .o_kanban_counter_label {
+ font-size: 10px;
+ .user-select(none);
+ }
+ }
+
+ > .o_kanban_counter_side {
+ width: 21%;
+ margin-left: 3%;
+ color: @headings-color;
+ font-weight: bold;
+ text-align: right;
+
+ &.o_kanban_grow {
+ .o-animation(grow, 400ms, ease-in, 200ms);
+ }
+ }
+}
+.o_column_folded .o_kanban_counter {
+ display: none;
+}
+
+.o_kanban_view .o_kanban_group {
+ .o-kanban-css-filter(success, @brand-success);
+ .o-kanban-css-filter(warning, @brand-warning);
+ .o-kanban-css-filter(danger, @brand-danger);
+
+ &.o_kanban_group_show {
+ .o-flex-display();
+ .o-flex-flow(column, nowrap);
+
+ > * {
+ .o-flex(0, 0, auto);
+ }
+ }
+}
+
+@keyframes grow {
+ 0% {
+ transform: scale3d(1, 1, 1);
+ }
+ 50% {
+ transform: scale3d(1.5, 1.5, 1.5);
+ }
+ 100% {
+ transform: scale3d(1, 1, 1);
+ }
+}
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index c7e7c83e836..acde7fcc739 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -91,4 +91,19 @@
</tbody>
</table>
</t>
+
+<t t-name="KanbanView.ColumnProgressBar">
+ <div class="o_kanban_counter">
+ <div class="o_kanban_counter_progress progress">
+ <t t-foreach="widget.colors" t-as="color">
+ <t t-set="count" t-value="widget.subgroupCounts[color] || 0"/>
+ <div t-attf-class="progress-bar transition-off bg-#{color_value}-full#{count ? ' o_bar_has_records' : ''}#{widget.activeFilter ? ' active progress-bar-striped' : ''}"
+ t-att-data-filter="color"
+ t-attf-data-original-title="#{count} #{color}"
+ t-attf-style="width: #{count ? (count * 100 / widget.groupCount) : 0}%;"/>
+ </t>
+ </div>
+ <div class="o_kanban_counter_side"><t t-esc="widget.totalCounterValue"/></div>
+ </div>
+</t>
</template>
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index 98b61fb0086..94fb0c4630c 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -1994,7 +1994,7 @@ QUnit.module('basic_fields', {
QUnit.test('datetime field with date/datetime widget (with day change)', function (assert) {
assert.expect(2);
-
+
this.data.partner.records[0].p = [2];
this.data.partner.records[1].datetime = "2017-02-08 02:00:00"; // UTC
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index 3f25582315e..3cf69b323b8 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -698,6 +698,40 @@ var MockServer = Class.extend({
return result;
},
/**
+ * Simulates a 'read_progress_bar' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Object} kwargs
+ * @returns {Object[][]}
+ */
+ _mockReadProgressBar: function (model, kwargs) {
+ var domain = kwargs.domain;
+ var groupBy = kwargs.groupBy;
+ var progress_bar = kwargs.progress_bar;
+
+ var records = this._getRecords(model, domain || []);
+
+ var data = {};
+ _.each(records, function (record) {
+ var groupByValue = record[groupBy]; // always technical value here
+
+ if (!(groupByValue in data)) {
+ data[groupByValue] = {};
+ _.each(progress_bar.colors, function (val, key) {
+ data[groupByValue][key] = 0;
+ });
+ }
+
+ var fieldValue = record[progress_bar.field];
+ if (fieldValue in data[groupByValue]) {
+ data[groupByValue][fieldValue]++;
+ }
+ });
+
+ return data;
+ },
+ /**
* Simulate a 'search_count' operation
*
* @private
@@ -889,6 +923,9 @@ var MockServer = Class.extend({
case 'read_group':
return $.when(this._mockReadGroup(args.model, args.kwargs));
+ case 'read_progress_bar':
+ return $.when(this._mockReadProgressBar(args.model, args.kwargs));
+
case 'search_count':
return $.when(this._mockSearchCount(args.model, args.args));
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 6ce6e8ce190..f070a6928a2 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -5,10 +5,18 @@ 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 createView = testUtils.createView;
QUnit.module('Views', {
+ before: function () {
+ this._initialKanbanProgressBarAnimate = KanbanColumnProgressBar.prototype.ANIMATE;
+ KanbanColumnProgressBar.prototype.ANIMATE = false;
+ },
+ after: function () {
+ KanbanColumnProgressBar.prototype.ANIMATE = this._initialKanbanProgressBarAnimate;
+ },
beforeEach: function () {
this.data = {
partner: {
@@ -51,7 +59,7 @@ QUnit.module('Views', {
]
},
};
- }
+ },
}, function () {
QUnit.module('KanbanView');
@@ -1934,6 +1942,34 @@ QUnit.module('Views', {
delete widgetRegistry.map.test;
});
+ QUnit.test('column progressbars properly work', function (assert) {
+ assert.expect(2);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_counter').length, this.data.product.records.length,
+ "kanban counters should have been created");
+
+ assert.strictEqual(parseInt(kanban.$('.o_kanban_counter_side').last().text()), 36,
+ "counter should display the sum of int_field values");
+ kanban.destroy();
+ });
});
});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 4aa0eb812da..bd9e93c130f 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -121,6 +121,7 @@
<link rel="stylesheet" type="text/less" href="/web/static/src/less/list_view.less"/>
<link rel="stylesheet" type="text/less" href="/web/static/src/less/list_view_extra.less"/>
<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/web_calendar.less"/>
<link rel="stylesheet" type="text/less" href="/web/static/src/less/search_view.less"/>
@@ -208,6 +209,7 @@
<script type="text/javascript" src="/web/static/src/js/views/graph/graph_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_column.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_quick_create.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_column_progressbar.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_model.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_controller.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_record.js"></script>