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
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);
},
//--------------------------------------------------------------------------
@@ -319,6 +346,22 @@ var KanbanColumn = Widget.extend({
event.preventDefault();
this.trigger_up('column_toggle_fold');
},
+ /**
+ * @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
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;
+
+ //
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,8 +98,24 @@ 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
*/
@@ -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,13 +200,40 @@ 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);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
+ /**
+ * @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).
@@ -206,6 +251,34 @@ var KanbanModel = BasicModel.extend({
return self._readTooltipFields(list).then(_.constant(result));
});
},
+ /**
+ * @private
+ * @param {Object} dataPoint
+ * @returns {Deferred