From 33f9e90bd5a9ad3e9239f1b414ea012bb94fa1c0 Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Thu, 14 Sep 2017 22:43:43 +0200 Subject: [PATCH] [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: Also: - Properly update record model data's parentID when moving a record - ... --- addons/crm/views/crm_lead_views.xml | 1 + addons/project/views/project_views.xml | 1 + addons/web/models/__init__.py | 1 + addons/web/models/models.py | 40 +++ .../static/src/js/views/basic/basic_model.js | 6 +- .../src/js/views/kanban/kanban_column.js | 47 +++- .../views/kanban/kanban_column_progressbar.js | 247 ++++++++++++++++++ .../src/js/views/kanban/kanban_controller.js | 10 +- .../src/js/views/kanban/kanban_model.js | 110 +++++++- .../src/js/views/kanban/kanban_renderer.js | 25 ++ .../static/src/js/views/kanban/kanban_view.js | 36 ++- .../src/less/kanban_column_progressbar.less | 115 ++++++++ addons/web/static/src/xml/kanban.xml | 15 ++ .../static/tests/fields/basic_fields_tests.js | 2 +- .../web/static/tests/helpers/mock_server.js | 37 +++ addons/web/static/tests/views/kanban_tests.js | 38 ++- addons/web/views/webclient_templates.xml | 2 + doc/reference/views.rst | 22 +- 18 files changed, 733 insertions(+), 22 deletions(-) create mode 100644 addons/web/models/models.py create mode 100644 addons/web/static/src/js/views/kanban/kanban_column_progressbar.js create mode 100644 addons/web/static/src/less/kanban_column_progressbar.less 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 @@ + 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 @@ +
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} + */ + _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; - }); 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 @@ + + +
+
+ + +
+ +
+
+
+ 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 @@ -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)); 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: + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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 @@ + @@ -208,6 +209,7 @@ + diff --git a/doc/reference/views.rst b/doc/reference/views.rst index d5e5b10c58c..149d22b277b 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -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 ```` 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