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