[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
- ...
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import ir_qweb
|
||||
from . import ir_http
|
||||
from . import models
|
||||
|
||||
@@ -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
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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`.
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -697,6 +697,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
|
||||
*
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
+20
-2
@@ -797,7 +797,7 @@ following attributes:
|
||||
Pivots
|
||||
------
|
||||
|
||||
The pivot view is used to visualize aggregations as a `pivot table`_. Its root
|
||||
The pivot view is used to visualize aggregations as a `pivot table`_. Its root
|
||||
element is ``<pivot>`` which can take the following attributes:
|
||||
|
||||
``disable_linking``
|
||||
@@ -854,6 +854,24 @@ Possible children of the view element are:
|
||||
``name`` (required)
|
||||
the name of the field to fetch
|
||||
|
||||
``progressbar``
|
||||
declares a progressbar element to put on top of kanban columns.
|
||||
|
||||
Possible attributes are:
|
||||
|
||||
``field`` (required)
|
||||
the name of the field whose values are used to subgroup column's records in
|
||||
the progressbar
|
||||
|
||||
``colors`` (required)
|
||||
JSON mapping the above field values to either "danger", "warning" or
|
||||
"success" colors
|
||||
|
||||
``sum`` (optional)
|
||||
the name of the field whose column's records' values will be summed and
|
||||
displayed next to the progressbar (if omitted, displays the total number of
|
||||
records)
|
||||
|
||||
``templates``
|
||||
defines a list of :ref:`reference/qweb` templates. Cards definition may be
|
||||
split into multiple templates for clarity, but kanban views *must* define at
|
||||
@@ -1067,7 +1085,7 @@ take the following attributes:
|
||||
``gantt`` classic gantt view (default)
|
||||
|
||||
``consolidate`` values of the first children are consolidated in the gantt's task
|
||||
|
||||
|
||||
``planning`` children are displayed in the gantt's task
|
||||
``consolidation``
|
||||
field name to display consolidation value in record cell
|
||||
|
||||
Reference in New Issue
Block a user