', {class: className, html: value});
}
/**
* @param {JQuery} $node
* @param {Number} value
* @param {Number} comparisonValue
- * @param {Object} variation (with key 'magnitude' and 'signClass')
+ * @param {Number} variation
* @param {function} formatter
* @param {Object} field
* @param {Object} options
* @returns {Object}
*/
-function renderComparison ($node, value, comparisonValue, variation, formatter, field, options) {
+function renderComparison($node, value, comparisonValue, variation, formatter, field, options) {
var $variation = renderVariation(variation, field, options);
$node.append($variation);
- if (!isNaN(variation.magnitude)) {
+ if (!isNaN(variation)) {
$node.append(
$('
', {class: 'o_comparison'})
.html(formatter(value, field, options) + '
vs ' + formatter(comparisonValue, field, options))
@@ -139,7 +132,8 @@ function renderComparison ($node, value, comparisonValue, variation, formatter,
return {
computeVariation: computeVariation,
DateClasses: DateClasses,
- renderComparison: renderComparison
+ renderComparison: renderComparison,
+ renderVariation: renderVariation,
};
});
diff --git a/addons/web/static/src/js/core/math_utils.js b/addons/web/static/src/js/core/math_utils.js
index 0eeb631125b..6f560624ffc 100644
--- a/addons/web/static/src/js/core/math_utils.js
+++ b/addons/web/static/src/js/core/math_utils.js
@@ -34,7 +34,7 @@ function _cartesian() {
* Returns the product of any number n of arrays.
* The internal structures of their elements is preserved.
* For n = 1, no brackets are put around the unique parameter elements
- * For n = 0, the singleton [undefined] is returned since it is the unit
+ * For n = 0, [undefined] is returned since it is the unit
* of the cartesian product (up to isomorphism).
*
* @returns {Array}
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index b5257a987c7..7875dcef52e 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -13,6 +13,7 @@ odoo.define('web.PivotController', function (require) {
*/
var AbstractController = require('web.AbstractController');
+var config = require('web.config');
var core = require('web.core');
var framework = require('web.framework');
var session = require('web.session');
@@ -23,19 +24,19 @@ var QWeb = core.qweb;
var PivotController = AbstractController.extend({
contentTemplate: 'PivotView',
events: {
- 'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
+ 'click .o_pivot_field_menu a': '_onGroupByMenuSelection',
'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
- 'click .o_pivot_field_menu a': '_onFieldMenuSelection',
- 'click td': '_onCellClick',
- 'click .o_pivot_measure_row': '_onMeasureRowClick',
},
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ close_group: '_onCloseGroup',
+ open_view: '_onOpenView',
+ sort_rows: '_onSortRows',
+ }),
/**
* @override
* @param {Object} params
- * @param {Object} params.groupableFields a map from field name to field
+ * @param {Object} params.groupableFields a map from field names to field
* props
- * @param {boolean} params.enableLinking configure the pivot view to allow
- * opening a list view by clicking on a cell with some data.
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
@@ -43,18 +44,17 @@ var PivotController = AbstractController.extend({
this.measures = params.measures;
this.groupableFields = params.groupableFields;
this.title = params.title;
- this.enableLinking = params.enableLinking;
// views to use in the action triggered when a data cell is clicked
this.views = params.views;
- this.lastHeaderSelected = null;
+ this.groupSelected = null;
},
/**
* @override
*/
start: function () {
- this.$fieldSelection = this.$('.o_field_selection');
+ this.$groupBySelection = this.$('.o_field_selection');
core.bus.on('click', this, function () {
- this.$fieldSelection.empty();
+ this.$groupBySelection.empty();
});
return this._super();
},
@@ -82,7 +82,7 @@ var PivotController = AbstractController.extend({
* @returns {Object}
*/
getOwnedQueryParams: function () {
- var state = this.model.get();
+ var state = this.model.get({raw: true});
return {
context: {
pivot_measures: state.measures,
@@ -102,7 +102,11 @@ var PivotController = AbstractController.extend({
*/
renderButtons: function ($node) {
if ($node) {
- var context = {measures: _.sortBy(_.pairs(_.omit(this.measures, '__count')), function (x) { return x[1].string.toLowerCase(); })};
+ var context = {
+ measures: _.sortBy(_.pairs(_.omit(this.measures, '__count')), function (x) {
+ return x[1].string.toLowerCase();
+ }),
+ };
this.$buttons = $(QWeb.render('PivotView.buttons', context));
this.$buttons.click(this._onButtonClick.bind(this));
this.$buttons.find('button').tooltip();
@@ -111,6 +115,16 @@ var PivotController = AbstractController.extend({
this._updateButtons();
}
},
+ /**
+ * @override
+ * @param {Object} params
+ */
+ update: function (params) {
+ if (config.device.isMobile && params.context) {
+ params.context.pivot_column_groupby = [];
+ }
+ return this._super.apply(this, arguments);
+ },
//--------------------------------------------------------------------------
// Private
@@ -119,18 +133,19 @@ var PivotController = AbstractController.extend({
/**
* Export the current pivot table data in a xls file. For this, we have to
* serialize the current state, then call the server /web/pivot/export_xls.
+ * Force a reload before exporting to ensure to export up-to-date data.
*
* @private
*/
_downloadTable: function () {
- var table = this.model.exportData();
- if(table.measure_row.length + 1 > 256) {
+ var self = this;
+ if (self.model.getTableWidth() > 256) {
this.call('crash_manager', 'show_message', _t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures."));
framework.unblockUI();
return;
}
- framework.blockUI();
- table.title = this.title;
+ var table = self.model.exportData();
+ table.title = self.title;
session.get_file({
url: '/web/pivot/export_xls',
data: {data: JSON.stringify(table)},
@@ -146,30 +161,32 @@ var PivotController = AbstractController.extend({
* @param {number} top top coordinate where we have to render the menu
* @param {number} left left coordinate for the menu
*/
- _renderFieldSelection: function (top, left) {
+ _renderGroupBySelection: function (top, left) {
var state = this.model.get({raw: true});
var groupedFields = state.rowGroupBys
.concat(state.colGroupBys)
- .map(function (f) { return f.split(':')[0];});
+ .map(function (f) {
+ return f.split(':')[0];
+ });
var fields = _.chain(this.groupableFields)
.pairs()
- .sortBy(function (f) { return f[1].string; })
+ .sortBy(function (f) {
+ return f[1].string;
+ })
.map(function (f) {
return [f[0], f[1], _.contains(groupedFields, f[0])];
})
.value();
- this.$fieldSelection.html(QWeb.render('PivotView.FieldSelection', {
+ this.$groupBySelection.html(QWeb.render('PivotView.GroupBySelection', {
fields: fields
}));
var cssProps = {top: top};
- cssProps[_t.database.parameters.direction === 'rtl' ? 'right' : 'left'] =
- _t.database.parameters.direction === 'rtl' ? this.$el.width() - left : left;
- this.$fieldSelection.find('.dropdown-menu').first()
- .css(cssProps)
- .addClass('show');
+ var isRTL = _t.database.parameters.direction === 'rtl';
+ cssProps[isRTL ? 'right' : 'left'] = isRTL ? this.$el.width() - left : left;
+ this.$groupBySelection.find('.dropdown-menu').first().css(cssProps).addClass('show');
},
/**
* @override
@@ -179,6 +196,7 @@ var PivotController = AbstractController.extend({
return this.renderer.appendTo(this.$('.o_pivot'));
},
/**
+ * @override
* @private
*/
_update: function () {
@@ -199,8 +217,13 @@ var PivotController = AbstractController.extend({
self.$buttons.find('.dropdown-item[data-field="' + name + '"]')
.toggleClass('selected', isSelected);
});
- var noDataDisplayed = !state.has_data || !state.measures.length;
- this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed);
+ var noDataDisplayed = !state.hasData || !state.measures.length;
+ var $flipButton = this.$buttons.find('.o_pivot_flip_button');
+ if (config.device.isMobile) {
+ $flipButton.toggleClass('o_hidden', true);
+ } else {
+ $flipButton.prop('disabled', noDataDisplayed);
+ }
this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed);
this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed);
},
@@ -224,51 +247,102 @@ var PivotController = AbstractController.extend({
this.update({}, {reload: false});
}
if ($target.hasClass('o_pivot_expand_button')) {
- this.model
- .expandAll()
- .then(this.update.bind(this, {}, {reload: false}));
+ this.model.expandAll().then(this.update.bind(this, {}, {reload: false}));
}
if ($target.parents('.o_pivot_measures_list').length) {
- var field = $target.data('field');
ev.preventDefault();
ev.stopPropagation();
- this.model
- .toggleMeasure(field)
- .then(this.update.bind(this, {}, {reload: false}));
+ var field = $target.data('field');
+ this.model.toggleMeasure(field).then(this.update.bind(this, {}, {reload: false}));
}
if ($target.hasClass('o_pivot_download')) {
this._downloadTable();
}
},
/**
- * When the user clicks on a cell, and the view is configured to allow
- * 'linking' (with enableLinking), we want to open a list view with the
- * corresponding record.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCloseGroup: function (ev) {
+ this.model.closeGroup(ev.data.groupId, ev.data.type);
+ this.update({}, {reload: false});
+ },
+ /**
+ * When we click on a closed row (col) header, we either want to open the
+ * dropdown menu to select a new field to add to rowGroupBys (resp. colGroupBys),
+ * or we want to open the clicked header, if rowGroupBys (resp. colGroupBys)
+ * has length strictly greater than header
*
* @private
* @param {MouseEvent} ev
*/
- _onCellClick: function (ev) {
- var $target = $(ev.currentTarget);
- if ($target.hasClass('o_pivot_header_cell_closed') ||
- $target.hasClass('o_pivot_header_cell_opened') ||
- $target.hasClass('o_empty') ||
- $target.data('type') === 'variation' ||
- !this.enableLinking) {
+ _onClosedHeaderClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ var $target = $(ev.target);
+ var groupId = $target.data('groupId');
+ var type = $target.data('type');
+
+ var group = {
+ rowValues: groupId[0],
+ colValues: groupId[1],
+ type: type
+ };
+
+ var state = this.model.get({raw: true});
+
+ var groupValues = type === 'row' ? groupId[0] : groupId[1];
+ var groupBys = type === 'row' ?
+ state.rowGroupBys :
+ state.colGroupBys;
+
+ this.selectedGroup = group;
+ if (groupValues.length < groupBys.length) {
+ var groupBy = groupBys[groupValues.length];
+ this.model
+ .expandGroup(this.selectedGroup, groupBy)
+ .then(this.update.bind(this, {}, {reload: false}));
+ } else {
+ var position = $target.position();
+ var top = position.top + $target.height();
+ var left = position.left + ev.offsetX;
+ this._renderGroupBySelection(top, left);
+ }
+ },
+ /**
+ * This handler is called when the user selects a groupby in the dropdown menu.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onGroupByMenuSelection: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.target);
+ if ($target.hasClass('disabled')) {
+ ev.stopPropagation();
return;
}
- var state = this.model.get(this.handle);
- var colDomain, rowDomain;
- if ($target.data('type') === 'comparisonData') {
- colDomain = this.model.getHeader($target.data('col_id')).comparisonDomain || [];
- rowDomain = this.model.getHeader($target.data('id')).comparisonDomain || [];
- } else {
- colDomain = this.model.getHeader($target.data('col_id')).domain || [];
- rowDomain = this.model.getHeader($target.data('id')).domain || [];
+
+ var groupBy = $target.data('field');
+ var interval = $target.data('interval');
+ if (interval) {
+ groupBy = groupBy + ':' + interval;
}
- var context = _.omit(state.context, function (val, key) {
- return key === 'group_by' || _.str.startsWith(key, 'search_default_');
- });
+ this.model.addGroupBy(groupBy, this.selectedGroup.type);
+ this.model
+ .expandGroup(this.selectedGroup, groupBy)
+ .then(this.update.bind(this, {}, {reload: false}));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenView: function (ev) {
+ var context = ev.data.context;
+ var group = ev.data.group;
+ var domain = this.model._getGroupDomain(group);
this.do_action({
type: 'ir.actions.act_window',
@@ -278,86 +352,15 @@ var PivotController = AbstractController.extend({
view_mode: 'list',
target: 'current',
context: context,
- domain: state.domain.concat(rowDomain, colDomain),
+ domain: domain,
});
},
/**
- * When we click on a closed header cell, we either want to open the
- * dropdown menu to select a new groupby level, or we want to open the
- * clicked header, if a corresponging groupby has already been selected.
- *
* @private
- * @param {MouseEvent} ev
+ * @param {OdooEvent} ev
*/
- _onClosedHeaderClick: function (ev) {
- var $target = $(ev.target);
- var id = $target.data('id');
- var header = this.model.getHeader(id);
- var groupbys = header.root.groupbys;
-
- if (header.path.length - 1 < groupbys.length) {
- this.model
- .expandHeader(header, groupbys[header.path.length - 1])
- .then(this.update.bind(this, {}, {reload: false}));
- } else {
- this.lastHeaderSelected = id;
- var position = $target.position();
- var top = position.top + $target.height();
- var left = position.left + ev.offsetX;
- this._renderFieldSelection(top, left);
- ev.stopPropagation();
- }
- },
- /**
- * This handler is called when the user selects a field in the dropdown menu
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onFieldMenuSelection: function (ev) {
- ev.preventDefault();
- var $target = $(ev.target);
- if ($target.hasClass('disabled')) {
- ev.stopPropagation();
- return;
- }
- var field = $target.data('field');
- var interval = $target.data('interval');
- var header = this.model.getHeader(this.lastHeaderSelected);
- if (interval) {
- field = field + ':' + interval;
- }
- this.model
- .expandHeader(header, field)
- .then(this.update.bind(this, {}, {reload: false}));
- },
- /**
- * If the user clicks on a measure row, we can perform an in-memory sort
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onMeasureRowClick: function (ev) {
- var $target = $(ev.target);
- var col_id = $target.data('id');
- var measure = $target.data('measure');
- var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc');
- var dataType = $target.data('data_type');
- this.model.sortRows(col_id, measure, isAscending, dataType);
- this.update({}, {reload: false});
- },
- /**
- * This method is called when someone clicks on an open header. When that
- * happens, we want to close the header, then redisplay the view.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onOpenHeaderClick: function (ev) {
- ev.preventDefault();
- ev.stopImmediatePropagation();
- var headerID = $(ev.target).data('id');
- this.model.closeHeader(headerID);
+ _onSortRows: function (ev) {
+ this.model.sortRows(ev.data.sortedColumn);
this.update({}, {reload: false});
},
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index a544ab15593..cb09f4a40f9 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -11,21 +11,276 @@ odoo.define('web.PivotModel', function (require) {
* structure.
*
* However, we need to be able to manipulate the data in a somewhat efficient
- * way, and to transform it into a list of lines to be displayed by the renderer
+ * way, and to transform it into a list of lines to be displayed by the renderer.
*
- * @todo add a full description/specification of the data layout
+ * Basicaly the pivot table presents aggregated values for various groups of records
+ * in one domain. If a comparison is asked for, two domains are considered.
+ *
+ * Let us consider a simple example and let us fix the vocabulary:
+ * ___________________________________________________________________________________________________________________________________________
+ * | | Total |
+ * | |_____________________________________________________________________________________________________________________|
+ * | | Sale Team 1 | Sale Team 2 | |
+ * | |_______________________________________|______________________________________|______________________________________|
+ * | | Sales total | Sales total | Sales total |
+ * | |_______________________________________|______________________________________|______________________________________|
+ * | | This Month | Last Month | Variation | This Month | Last Month | Variation | This Month | Last Month | Variation |
+ * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________|
+ * | Total | 110 | 85 | 29.4% | 30 | 40 | -25% | 140 | 125 | 12% |
+ * | Europe | 35 | 25 | 40% | 30 | 40 | -25% | 65 | 65 | 0% |
+ * | Brussels | 15 | 0 | 100% | 30 | 30 | 0% | 45 | 30 | 50% |
+ * | Paris | 20 | 25 | -20% | 0 | 10 | -100% | 20 | 35 | -42.8% |
+ * | North America | 75 | 60 | 25% | | | | 75 | 60 | 25% |
+ * | Washington | 75 | 60 | 25% | | | | 75 | 60 | 25% |
+ * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________|
+ *
+ *
+ * META DATA:
+ *
+ * In the above pivot table, the records have been grouped using the fields
+ *
+ * continent_id, city_id
+ *
+ * for rows and
+ *
+ * sale_team_id
+ *
+ * for columns.
+ *
+ * The measure is the field 'sales_total'.
+ *
+ * Two domains are considered: 'This Month' and 'Last Month'.
+ *
+ * In the model,
+ *
+ * - rowGroupBys is the list [continent_id, city_id]
+ * - colGroupBys is the list [sale_team_id]
+ * - measures is the list [sales_total]
+ * - domains is the list [d1, d2] with d1 and d2 domain expressions
+ * for say sale_date in this month and last month, for instance
+ * d1 = [['sale_date', >=, 2019-05-01], ['sale_date', '<', 2019-05-31]]
+ * - origins is the list ['This Month', 'Last Month']
+ *
+ * DATA:
+ *
+ * Recall that a group is constituted by records (in a given domain)
+ * that have the same (raw) values for a list of fields.
+ * Thus the group itself is identified by this list and the domain.
+ * In comparison mode, the same group (forgetting the domain part or 'originIndex')
+ * can be eventually found in the two domains.
+ * This defines the way in which the groups are identified or not.
+ *
+ * In the above table, (forgetting the domain) the following groups are found:
+ *
+ * the 'row groups'
+ * - Total
+ * - Europe
+ * - America
+ * - Europe, Brussels
+ * - Europe, Paris
+ * - America, Washington
+ *
+ * the 'col groups'
+ *
+ * - Total
+ * - Sale Team 1
+ * - Sale Team 2
+ *
+ * and all non trivial combinations of row groups and col groups
+ *
+ * - Europe, Sale Team 1
+ * - Europe, Brussels, Sale Team 2
+ * - America, Washington, Sale Team 1
+ * - ...
+ *
+ * The list of fields is created from the concatenation of two lists of fields, the first in
+ *
+ * [], [f1], [f1, f2], ... [f1, f2, ..., fn] for [f1, f2, ..., fn] the full list of groupbys
+ * (called rowGroupBys) used to create row groups
+ *
+ * In the example: [], [continent_id], [continent_id, city_id].
+ *
+ * and the second in
+ * [], [g1], [g1, g2], ... [g1, g2, ..., gm] for [g1, g2, ..., gm] the full list of groupbys
+ * (called colGroupBys) used to create col groups.
+ *
+ * In the example: [], [sale_team_id].
+ *
+ * Thus there are (n+1)*(m+1) lists of fields possible.
+ *
+ * In the example: 6 lists possible, namely [],
+ * [continent_id], [sale_team_id],
+ * [continent_id, sale_team_id], [continent_id, city_id],
+ * [continent_id, city_id, sale_team_id]
+ *
+ * A given list is thus of the form [f1,..., fi, g1,..., gj] or better [[f1,...,fi], [g1,...,gj]]
+ *
+ * For each list of fields possible and each domain considered, one read_group is done
+ * and gives results of the form (an exceptions for list [])
+ *
+ * g = {
+ * f1: v1, ..., fi: vi,
+ * g1: w1, ..., gj: wj,
+ * m1: x1, ..., mk: xk,
+ * __count: c,
+ * __domain: d
+ * }
+ *
+ * where v1,...,vi,w1,...,Wj are 'values' for the corresponding fields and
+ * m1,...,mk are the fields selected as measures.
+ *
+ * For example, g = {
+ * continent_id: [1, 'Europe']
+ * sale_team_id: [1, 'Sale Team 1']
+ * sales_count: 35,
+ * __count: 4
+ * __domain: [
+ * ['sale_date', >=, 2019-05-01], ['sale_date', '<', 2019-05-31],
+ * ['continent_id', '=', 1],
+ * ['sale_team_id', '=', 1]
+ * ]
+ * }
+ *
+ * Thus the above group g is fully determined by [[v1,...,vi], [w1,...,wj]] and the base domain
+ * or the corresponding 'originIndex'.
+ *
+ * When j=0, g corresponds to a row group (or also row header) and is of the form [[v1,...,vi], []] or more simply [v1,...vi]
+ * (not forgetting the list [v1,...vi] comes from left).
+ * When i=0, g corresponds to a col group (or col header) and is of the form [[], [w1,...,wj]] or more simply [w1,...,wj].
+ *
+ * A generic group g as above [[v1,...,vi], [w1,...,wj]] corresponds to the two headers [[v1,...,vi], []]
+ * and [[], [w1,...,wj]].
+ *
+ * Here is a description of the data structure manipulated by the pivot model.
+ *
+ * Five objects contain all the data from the read_groups
+ *
+ * - rowGroupTree: contains information on row headers
+ * the nodes correspond to the groups of the form [[v1,...,vi], []]
+ * The root is [[], []].
+ * A node [[v1,...,vl], []] has as direct children the nodes of the form [[v1,...,vl,v], []],
+ * this means that a direct child is obtained by grouping records using the single field fi+1
+ *
+ * The structure at each level is of the form
+ *
+ * {
+ * root: {
+ * values: [v1,...,vl],
+ * labels: [la1,...,lal]
+ * },
+ * directSubTrees: {
+ * JSON.stringify(v): {
+ * root: {
+ * values: [v1,...,vl,v]
+ * labels: [label1,...,labell,label]
+ * },
+ * directSubTrees: {...}
+ * },
+ * JSON.stringify(v'): {...},
+ * ...
+ * }
+ * }
+ *
+ * In the example, the rowGroupTree is:
+ *
+ * {
+ * root: {
+ * values: [],
+ * labels: []
+ * },
+ * directSubTrees: {
+ * 1: {
+ * root: {
+ * values: [1],
+ * labels: ['Europe'],
+ * },
+ * directSubTrees: {
+ * 1: {
+ * root: {
+ * values: [1, 1],
+ * labels: ['Europe', 'Brussels'],
+ * },
+ * directSubTrees: {},
+ * },
+ * 2: {
+ * root: {
+ * values: [1, 2],
+ * labels: ['Europe', 'Paris'],
+ * },
+ * directSubTrees: {},
+ * },
+ * },
+ * },
+ * 2: {
+ * root: {
+ * values: [2],
+ * labels: ['America'],
+ * },
+ * directSubTrees: {
+ * 3: {
+ * root: {
+ * values: [2, 3],
+ * labels: ['America', 'Washington'],
+ * }
+ * directSubTrees: {},
+ * },
+ * },
+ * },
+ * },
+ * }
+ *
+ * - colGroupTree: contains information on col headers
+ * The same as above with right instead of left
+ *
+ * - measurements: contains information on measure values for all the groups
+ *
+ * the object keys are of the form JSON.stringify([[v1,...,vi], [w1,...,wj]])
+ * and values are arrays of length equal to number of origins containing objects of the form
+ * {m1: x1,...,mk: xk}
+ * The structure looks like
+ *
+ * {
+ * JSON.stringify([[], []]): [{m1: x1,...,mk: xk}, {m1: x1',...,mk: xk'},...]
+ * ....
+ * JSON.stringify([[v1,...,vi], [w1,...,wj]]): [{m1: y1',...,mk: yk'}, {m1: y1',...,mk: yk'},...],
+ * ....
+ * JSON.stringify([[v1,...,vn], [w1,...,wm]]): [{m1: z1',...,mk: zk'}, {m1: z1',...,mk: zk'},...],
+ * }
+ * Thus the structure contains all information for all groups and all origins on measure values.
+ *
+ *
+ * this.measurments["[[], []]"][0]['foo'] gives the value of the measure 'foo' for the group 'Total' and the
+ * first domain (origin).
+ *
+ * In the example:
+ * {
+ * "[[], []]": [{'sales_total': 110}, {'sales_total': 85}] (total/total)
+ * ...
+ * "[[1, 2], [2]]": [{'sales_total': 0}, {'sales_total': 10}] (Europe/Paris/Sale Team 2)
+ * ...
+ * }
+ *
+ * - counts: contains information on the number of records in each groups
+ * The structure is similar to the above but the arrays contains numbers (counts)
+ * - groupDomains:
+ * The structure is similar to the above but the arrays contains domains
+ *
+ * With this light data structures, all manipulation done by the model are eased and redundancies are limited.
+ * Each time a rendering or an export of the data has to be done, the pivot table is generated by the _getTable function.
*/
var AbstractModel = require('web.AbstractModel');
var concurrency = require('web.concurrency');
-var dataComparisonUtils = require('web.dataComparisonUtils');
var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
+var mathUtils = require('web.mathUtils');
var session = require('web.session');
-var utils = require('web.utils');
-var computeVariation = dataComparisonUtils.computeVariation;
var _t = core._t;
+var cartesian = mathUtils.cartesian;
+var computeVariation = dataComparisonUtils.computeVariation;
+var sections = mathUtils.sections;
var PivotModel = AbstractModel.extend({
/**
@@ -44,338 +299,259 @@ var PivotModel = AbstractModel.extend({
//--------------------------------------------------------------------------
/**
- * Close a header. This method is actually synchronous, but returns a
- * promise.
+ * Add a groupBy to rowGroupBys or colGroupBys according to provided type.
*
- * @param {any} headerID
- * @returns {Promise}
+ * @param {string} groupBy
+ * @param {'row'|'col'} type
*/
- closeHeader: function (headerID) {
- var header = this.data.headers[headerID];
- header.expanded = false;
- header.children = [];
- var newGroupbyLength = this._getHeaderDepth(header.root) - 1;
- header.root.groupbys.splice(newGroupbyLength);
+ addGroupBy: function (groupBy, type) {
+ if (type === 'row') {
+ this.data.expandedRowGroupBys.push(groupBy);
+ } else {
+ this.data.expandedColGroupBys.push(groupBy);
+ }
},
/**
+ * Close the group with id given by groupId. A type must be specified
+ * in case groupId is [[], []] (the id of the group 'Total') because this
+ * group is present in both colGroupTree and rowGroupTree.
+ *
+ * @param {Array[]} groupId
+ * @param {'row'|'col'} type
+ */
+ closeGroup: function (groupId, type) {
+ var groupBys;
+ var expandedGroupBys;
+ var group;
+ var tree;
+ if (type === 'row') {
+ groupBys = this.data.rowGroupBys;
+ expandedGroupBys = this.data.expandedRowGroupBys;
+ tree = this.rowGroupTree;
+ group = this._findGroup(this.rowGroupTree, groupId[0]);
+ } else {
+ groupBys = this.data.colGroupBys;
+ expandedGroupBys = this.data.expandedColGroupBys;
+ tree = this.colGroupTree;
+ group = this._findGroup(this.colGroupTree, groupId[1]);
+ }
+ group.directSubTrees = {};
+ delete group.sortedKeys;
+ var newGroupBysLength = this._getTreeHeight(tree) - 1;
+ if (newGroupBysLength <= groupBys.length) {
+ expandedGroupBys.splice(0);
+ groupBys.splice(newGroupBysLength);
+ } else {
+ expandedGroupBys.splice(newGroupBysLength - groupBys.length);
+ }
+ },
+ /**
+ * Reload the view with the current rowGroupBys and colGroupBys
+ * This is the easiest way to expand all the groups that are not expanded
+ *
* @returns {Promise}
*/
expandAll: function () {
return this._loadData();
},
/**
- * Expand (open up) a given header, be it a row or a column.
+ * Expand a group by using groupBy to split it.
*
- * @todo: add discussion on the number of read_group that it will generate,
- * which is (r+1) or (c+1) I think
- *
- * @param {any} header
- * @param {any} field
- * @returns
+ * @param {Object} group
+ * @param {string} groupBy
+ * @returns {Promise}
*/
- expandHeader: function (header, field) {
- var self = this;
- var other_root = header.root.other_root;
- var other_groupbys = header.root.other_root.groupbys;
- var measures = _.map(this.data.measures, function(measure) {
- var type = self.fields[measure].type;
- return (type === 'many2one') ? measure + ":count_distinct" : measure;
- });
- var groupBys = [];
- for (var i = 0; i <= other_groupbys.length; i++) {
- groupBys.push([field].concat(other_groupbys.slice(0,i)));
- }
- var defs = [];
- if ((typeof header.count === 'object' && header.count.data) || (typeof header.count === 'number' && header.count)) {
- defs = defs.concat(groupBys.map(function (groupBy) {
- return self._rpc({
- model: self.modelName,
- method: 'read_group',
- context: self.data.context,
- domain: header.domain ||
- self.data.domain.concat(self.data.timeRange),
- fields: measures,
- groupBy: groupBy,
- lazy: false,
- }).then(function (result) {
- return ['data', result];
- });
- }));
- }
- if (header.comparisonCount && this.data.compare) {
- defs = defs.concat(groupBys.map(function (groupBy) {
- return self._rpc({
- model: self.modelName,
- method: 'read_group',
- context: self.data.context,
- domain: header.comparisonDomain ||
- self.data.domain.concat(self.data.comparisonTimeRange),
- fields: measures,
- groupBy: groupBy,
- lazy: false,
- }).then(function (result) {
- return ['comparisonData', result];
- });
- }));
- }
- return Promise.all(defs).then(function () {
- var results = Array.prototype.slice.call(arguments[0]);
- var data = [];
- var comparisonData = [];
- _.each(results, function (result) {
- if (result[0] === 'data') {
- data.push(result[1]);
- } else {
- comparisonData.push(result[1]);
- }
- });
- var allData = self._mergeData(data, comparisonData, groupBys);
- var dataPoint, attrs, j, l, row, col, cell_value;
- for (i = 0; i < allData.length; i++) {
- for (j = 0; j < allData[i].length; j++){
- dataPoint = allData[i][j];
- groupBys = [field].concat(other_groupbys.slice(0,i));
- attrs = {
- value: self._getValue(dataPoint, groupBys),
- domain: dataPoint.__domain,
- comparisonDomain: dataPoint.__comparisonDomain,
- length: dataPoint.__count.data ? dataPoint.__count.data : dataPoint.__count,
- comparisonLength: dataPoint.__comparisonCount|| 0,
- };
+ expandGroup: function (group, groupBy) {
+ var leftDivisors;
+ var rightDivisors;
- if (i === 0) {
- row = self._makeHeader(attrs.value, attrs.domain, attrs.comparisonDomain, attrs.length, attrs.comparisonLength , header.root, 0, 1, header);
- } else {
- row = self._getHeader(attrs.value, header.root, 0, 1, header);
- }
- col = self._getHeader(attrs.value, other_root, 1, i + 1);
- if (!col) {
- continue;
- }
- for (cell_value = {}, l=0; l < self.data.measures.length; l++) {
- var _value = dataPoint[self.data.measures[l]];
- if (_value instanceof Array) {
- // when a many2one field is used as a measure AND as
- // a grouped field, bad things happen. The server
- // will only return the grouped value and will not
- // aggregate it. Since there is a nameclash, we are
- // then in the situation where this value is an
- // array. Fortunately, if we group by a field,
- // then we can say for certain that the group contains
- // exactly one distinct value for that field.
- if (self.data.compare) {
- _value = dataPoint[self.data.measures[l] + 'Aggregate'];
- } else {
- _value = 1;
- }
- }
- cell_value[self.data.measures[l]] =_value;
- }
- // cell_value.__count = attrs.length;
- if (!self.data.cells[row.id]) {
- self.data.cells[row.id] = [];
- }
- self.data.cells[row.id][col.id] = cell_value;
- }
- }
- if (!_.contains(header.root.groupbys, field)) {
- header.root.groupbys.push(field);
- }
- });
+ if (group.type === 'row') {
+ leftDivisors = [[groupBy]];
+ rightDivisors = sections(this._getGroupBys().colGroupBys);
+ } else {
+ leftDivisors = sections(this._getGroupBys().rowGroupBys);
+ rightDivisors = [[groupBy]];
+ }
+ var divisors = cartesian(leftDivisors, rightDivisors);
+
+ delete group.type;
+ return this._subdivideGroup(group, divisors);
},
/**
- * Export the current pivot view in a simple JS object.
+ * Export model data in a form suitable for an easy encoding of the pivot
+ * table in excell.
*
* @returns {Object}
*/
exportData: function () {
- var self = this;
- var measureNbr = this.data.measures.length;
- var headers = this._computeHeaders();
- if (this.data.compare) {
- _.each(headers, function (headerGroup) {
- _.each(headerGroup, function (header) {
- header.width = header.width ? 3 * header.width : 3;
- });
- });
- }
- var measureRow = measureNbr >= 1 ? _.last(headers) : [];
- var rows = this._computeRows();
- var i, j, value, values, is_bold, additionalHeaders = [];
- // remove the empty headers on left side
- headers[0].splice(0,1);
+ var measureCount = this.data.measures.length;
+ var originCount = this.data.origins.length;
- function isBold (i, j) {
- return (i === 0) ||
- ((self.data.main_col.width > 1) &&
- (j >= rows[i].values.length - measureNbr));
- }
+ var table = this._getTable();
- function makeValue (value, is_bold) {
+ // process headers
+ var headers = table.headers;
+ var colGroupHeaderRows;
+ var measureRow = [];
+ var originRow = [];
+
+ function processHeader(header) {
+ var inTotalColumn = header.groupId[1].length === 0;
return {
- is_bold: is_bold,
- value: (value === undefined) ? "" : value,
+ title: header.title,
+ width: header.width,
+ height: header.height,
+ is_bold: !!header.measure && inTotalColumn
};
}
- function makeMeasure (name) {
- return {
- is_bold: false,
- measure: name
- };
- }
-
- // process measureRow
- additionalHeaders = [];
- for (i = 0; i < measureRow.length; i++) {
- if (this.data.compare) {
- measureRow[i].title = this.fields[measureRow[i].measure].string;
- measureRow[i].height = 1;
- measureRow[i].expanded = true;
- additionalHeaders = additionalHeaders.concat(
- _.map(
- [
- this.data.timeRangeDescription.toString(),
- this.data.comparisonTimeRangeDescription.toString(),
- _t('Variation')
- ],
- makeMeasure
- )
- );
- } else {
- measureRow[i].measure = this.fields[measureRow[i].measure].string;
- }
- }
- if (this.data.compare) {
- for (i =0, j, value; i < rows.length; i++) {
- values = [];
- for (j = 0; j < rows[i].values.length; j++) {
- value = rows[i].values[j];
- is_bold = isBold(i, j);
- if (value instanceof Object) {
- for (var origin in value) {
- if (origin === 'variation') {
- values.push(makeValue(value[origin].magnitude * 100, is_bold));
- } else {
- values.push(makeValue(value[origin], is_bold));
- }
- }
- } else {
- for (var l = 0; l < 3; l++) {
- values.push(makeValue(undefined, isBold(i, j)));
- }
- }
- }
- rows[i].values = values;
- }
- headers.push(additionalHeaders);
- return {
- headers: _.initial(headers),
- measure_row: additionalHeaders,
- rows: rows,
- nbr_measures: 3 * measureNbr,
- };
+ if (originCount > 1) {
+ colGroupHeaderRows = headers.slice(0, headers.length - 2);
+ measureRow = headers[headers.length - 2].map(processHeader);
+ originRow = headers[headers.length - 1].map(processHeader);
} else {
- // process all rows
- for (i =0, j, value; i < rows.length; i++) {
- for (j = 0; j < rows[i].values.length; j++) {
- rows[i].values[j] = makeValue(rows[i].values[j], isBold(i, j));
- }
+ colGroupHeaderRows = headers.slice(0, headers.length - 1);
+ if (measureCount > 1) {
+ measureRow = headers[headers.length - 1].map(processHeader);
}
- return {
- headers: _.initial(headers),
- measure_row: measureRow,
- rows: rows,
- nbr_measures: measureNbr,
- };
}
+
+ // remove the empty headers on left side
+ colGroupHeaderRows[0].splice(0, 1);
+
+ colGroupHeaderRows = colGroupHeaderRows.map(function (headerRow) {
+ return headerRow.map(processHeader);
+ });
+
+ // process rows
+ var tableRows = table.rows.map(function (row) {
+ return {
+ title: row.title,
+ indent: row.indent,
+ values: row.subGroupMeasurements.map(function (measurement) {
+ var value = measurement.value;
+ if (value === undefined) {
+ value = "";
+ } else if (measurement.originIndexes.length > 1) {
+ // in that case the value is a variation and a
+ // number between 0 and 1
+ value = value * 100;
+ }
+ return {
+ is_bold: measurement.isBold,
+ value: value,
+ };
+ }),
+ };
+ });
+
+ return {
+ col_group_headers: colGroupHeaderRows,
+ measure_headers: measureRow,
+ origin_headers: originRow,
+ rows: tableRows,
+ measure_count: measureCount,
+ origin_count: originCount,
+ };
},
/**
- * Swap the columns and the rows. It is a synchronous operation.
+ * Swap the pivot columns and the rows. It is a synchronous operation.
*/
flip: function () {
// swap the data: the main column and the main row
- var temp = this.data.main_col;
- this.data.main_col = this.data.main_row;
- this.data.main_row = temp;
+ var temp = this.rowGroupTree;
+ this.rowGroupTree = this.colGroupTree;
+ this.colGroupTree = temp;
- // we need to update the record metadata: row and col groupBys
- temp = this.data.groupedBy;
+ // we need to update the record metadata: (expanded) row and col groupBys
+ temp = this.data.rowGroupBys;
this.data.groupedBy = this.data.colGroupBys;
+ this.data.rowGroupBys = this.data.colGroupBys;
this.data.colGroupBys = temp;
+ temp = this.data.expandedColGroupBys;
+ this.data.expandedColGroupBys = this.data.expandedRowGroupBys;
+ this.data.expandedRowGroupBys = temp;
+
+ function twistKey(key) {
+ return JSON.stringify(JSON.parse(key).reverse());
+ }
+ function twist(object) {
+ var newObject = {};
+ Object.keys(object).forEach(function (key) {
+ var value = object[key];
+ newObject[twistKey(key)] = value;
+ });
+ return newObject;
+ }
+ this.measurements = twist(this.measurements);
+ this.counts = twist(this.counts);
+ this.groupDomains = twist(this.groupDomains);
},
/**
* @override
+ *
* @param {Object} [options]
* @param {boolean} [options.raw=false]
* @returns {Object}
*/
get: function (options) {
- var isRaw = options && options.raw;
- if (!this.data.has_data) {
- return {
- has_data: false,
- colGroupBys: this.data.main_col.root.groupbys,
- // colGroupBys: this.data.main_col.groupbys,
- // colGroupBys: this.data.colGroupBys,
- rowGroupBys: this.data.main_row.root.groupbys,
- // rowGroupBys: this.data.main_row.groupbys,
- // rowGroupBys: this.data.groupedBy,
- measures: this.data.measures,
- };
- }
- return {
- colGroupBys: this.data.main_col.root.groupbys,
- // colGroupBys: this.data.main_col.groupbys,
- // colGroupBys: this.data.colGroupBys,
+ options = options || {};
+ var raw = options.raw || false;
+ var groupBys = this._getGroupBys();
+ var state = {
+ colGroupBys: groupBys.colGroupBys,
context: this.data.context,
domain: this.data.domain,
- compare: this.data.compare,
fields: this.fields,
- headers: !isRaw && this._computeHeaders(),
- has_data: true,
- mainColWidth: this.data.main_col.width,
+ hasData: this._hasData(),
measures: this.data.measures,
- rows: !isRaw && this._computeRows(),
- rowGroupBys: this.data.main_row.root.groupbys,
- // rowGroupBys: this.data.main_row.groupbys,
- // rowGroupBys: this.data.groupedBy,
- sortedColumn: this.data.sorted_column,
+ origins: this.data.origins,
+ rowGroupBys: groupBys.rowGroupBys,
};
+ if (!raw && state.hasData) {
+ state.table = this._getTable();
+ }
+ return state;
},
/**
- * @param {string} id
- * @returns {object}
+ * Returns the total number of columns of the pivot table.
+ *
+ * @returns {integer}
*/
- getHeader: function (id) {
- return this.data.headers[id];
+ getTableWidth: function () {
+ var leafCounts = this._getLeafCounts(this.colGroupTree);
+ return leafCounts[JSON.stringify(this.colGroupTree.root.values)] + 2;
},
/**
* @override
+ *
* @param {Object} params
- * @param {string[]} [params.groupedBy]
- * @param {string[]} [params.colGroupBys]
- * @param {string[]} params.domain
- * @param {string[]} params.rowGroupBys
- * @param {string[]} params.colGroupBys
- * @param {string[]} params.measures
- * @param {string[]} params.timeRange
- * @param {string[]} params.comparisonTimeRange
- * @param {string[]} params.timeRangeDescription
- * @param {string[]} params.comparisonTimeRangeDescription
- * @param {string[]} params.compare
+ * @param {boolean} [params.compare=false]
+ * @param {Object} params.context
* @param {Object} params.fields
- * @param {string} params.default_order
+ * @param {Array[]} [params.comparisonTimeRange=[]]
+ * @param {string[]} [params.groupedBy]
+ * @param {Array[]} [params.timeRange=[]]
+ * @param {string[]} params.colGroupBys
+ * @param {Array[]} params.domain
+ * @param {string[]} params.measures
+ * @param {string[]} params.rowGroupBys
+ * @param {string} [params.comparisonTimeRangeDescription=""]
+ * @param {string} [params.default_order]
+ * @param {string} [params.timeRangeDescription=""]
+ * @param {string} params.modelName
* @returns {Promise}
*/
load: function (params) {
- var self = this;
-
this.initialDomain = params.domain;
this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys;
+ this.defaultGroupedBy = params.groupedBy;
+
this.fields = params.fields;
this.modelName = params.modelName;
this.data = {
+ expandedRowGroupBys: [],
+ expandedColGroupBys: [],
domain: this.initialDomain,
timeRange: params.timeRange || [],
timeRangeDescription: params.timeRangeDescription || "",
@@ -386,26 +562,43 @@ var PivotModel = AbstractModel.extend({
groupedBy: params.context.pivot_row_groupby || params.groupedBy,
colGroupBys: params.context.pivot_column_groupby || params.colGroupBys,
measures: this._processMeasures(params.context.pivot_measures) || params.measures,
- sorted_column: {},
};
- this.variationData = {};
- this.defaultGroupedBy = params.groupedBy;
- return this._loadData().then(function () {
- if (params.default_order) {
- var info = params.default_order.split(' ');
- self.sortRows(self.data.main_col.root.id, info[0], info[1] === 'desc');
- }
- });
+ this.data.domains = this._getDomains();
+ this.data.origins = this._getOrigins();
+ this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys;
+
+ var defaultOrder = params.default_order && params.default_order.split(' ');
+ if (defaultOrder) {
+ this.data.sortedColumn = {
+ groupId: [[], []],
+ measure: defaultOrder[0],
+ order: defaultOrder[1] ? defaultOrder [1] : 'asc',
+ };
+ }
+ return this._loadData();
},
/**
* @override
+ *
* @param {any} handle this parameter is ignored
* @param {Object} params
+ * @param {boolean} [params.compare=false]
+ * @param {Object} params.context
+ * @param {Array[]} [params.comparisonTimeRange=[]]
+ * @param {string[]} [params.groupedBy]
+ * @param {Array[]} [params.timeRange=[]]
+ * @param {Array[]} params.domain
+ * @param {string[]} params.groupBy
+ * @param {string[]} params.measures
+ * @param {string} [params.comparisonTimeRangeDescription=""]
+ * @param {string} [params.timeRangeDescription=""]
* @returns {Promise}
*/
reload: function (handle, params) {
var self = this;
+ var oldColGroupBys = this.data.colGroupBys;
+ var oldRowGroupBys = this.data.rowGroupBys;
if ('context' in params) {
this.data.context = params.context;
this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys;
@@ -437,22 +630,29 @@ var PivotModel = AbstractModel.extend({
if ('groupBy' in params) {
this.data.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy;
}
- if (!this.data.has_data) {
+
+ this.data.domains = this._getDomains();
+ this.data.origins = this._getOrigins();
+ this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys;
+
+ if (!_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) {
+ this.data.expandedRowGroupBys = [];
+ }
+ if (!_.isEqual(oldColGroupBys, self.data.colGroupBys)) {
+ this.data.expandedColGroupBys = [];
+ }
+ if (!this._hasData()) {
return this._loadData();
}
- var old_row = this.data.main_row;
- var old_col = this.data.main_col;
+ var oldRowGroupTree = this.rowGroupTree;
+ var oldColGroupTree = this.colGroupTree;
return this._loadData().then(function () {
- if (!('groupBy' in params) && !('pivot_row_groupby' in (params.context || {}))) {
- // we only update the row groupbys according to the old groupbys
- // if we don't have the key 'groupBy' in params. In that case,
- // we want to have the full open state for the groupbys.
- self._updateTree(old_row.root, self.data.main_row.root);
+ if (_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) {
+ self._pruneTree(self.rowGroupTree, oldRowGroupTree);
}
-
- if (!('pivot_column_groupby' in (params.context || {}))) {
- self._updateTree(old_col.root, self.data.main_col.root);
+ if (_.isEqual(oldColGroupBys, self.data.colGroupBys)) {
+ self._pruneTree(self.colGroupTree, oldColGroupTree);
}
});
},
@@ -460,70 +660,45 @@ var PivotModel = AbstractModel.extend({
* Sort the rows, depending on the values of a given column. This is an
* in-memory sort.
*
- * @param {any} col_id
- * @param {any} measure
- * @param {any} descending
- * @param {'data'|'comparisonData'|'variation'} [dataType]
+ * @param {Object} sortedColumn
*/
- sortRows: function (col_id, measure, descending, dataType) {
- var cells = this.data.cells;
- var comparisonFunction = compare;
- if (this.data.compare) {
- dataType = dataType || 'data';
- comparisonFunction = specialCompare;
- }
- this._traverseTree(this.data.main_row.root, function (header) {
- header.children.sort(comparisonFunction);
- });
- this.data.sorted_column = {
- id: col_id,
- measure: measure,
- order: descending ? 'desc' : 'asc',
- dataType: dataType,
- };
- function _getValue (id1, id2) {
- if ((id1 in cells) && (id2 in cells[id1])) {
- return cells[id1][id2];
- }
- if (id2 in cells) return cells[id2][id1];
- }
+ sortRows: function (sortedColumn) {
+ var self = this;
+ var colGroupValues = sortedColumn.groupId[1];
+ sortedColumn.originIndexes = sortedColumn.originIndexes || [0];
+ this.data.sortedColumn = sortedColumn;
- function compare (row1, row2) {
- var values1 = _getValue(row1.id, col_id);
- var values2 = _getValue(row2.id, col_id);
- var value1 = values1 ? values1[measure] : 0;
- var value2 = values2 ? values2[measure] : 0;
- return descending ? value1 - value2 : value2 - value1;
- }
- function specialCompare (row1, row2) {
- var values1 = _getValue(row1.id, col_id);
- var values2 = _getValue(row2.id, col_id);
- var value1 = values1 ? values1[measure] : {data: 0, comparisonData: 0, variation: {magnitude: 0}};
- var value2 = values2 ? values2[measure] : {data: 0, comparisonData: 0, variation: {magnitude: 0}};
- if (dataType === 'variation') {
- return descending ?
- value1[dataType].magnitude - value2[dataType].magnitude:
- value2[dataType].magnitude - value1[dataType].magnitude;
- }
- return descending ?
- value1[dataType] - value2[dataType]:
- value2[dataType] - value1[dataType];
- }
+ var sortFunction = function (tree) {
+ return function (subTreeKey) {
+ var subTree = tree.directSubTrees[subTreeKey];
+ var groupIntersectionId = [subTree.root.values, colGroupValues];
+ var value = self._getCellValue(
+ groupIntersectionId,
+ sortedColumn.measure,
+ sortedColumn.originIndexes
+ ) || 0;
+ return sortedColumn.order === 'asc' ? value : -value;
+ };
+ };
+
+ this._sortTree(sortFunction, this.rowGroupTree);
},
/**
- * Toggle the active state for a given measure, then reload the data.
+ * Toggle the active state for a given measure, then reload the data
+ * if this turns out to be necessary.
*
- * @param {string} field
+ * @param {string} fieldName
* @returns {Promise}
*/
- toggleMeasure: function (field) {
- if (_.contains(this.data.measures, field)) {
- this.data.measures = _.without(this.data.measures, field);
+ toggleMeasure: function (fieldName) {
+ var index = this.data.measures.indexOf(fieldName);
+ if (index !== -1) {
+ this.data.measures.splice(index, 1);
// in this case, we already have all data in memory, no need to
// actually reload a lesser amount of information
return Promise.resolve();
} else {
- this.data.measures.push(field);
+ this.data.measures.push(fieldName);
}
return this._loadData();
},
@@ -532,539 +707,678 @@ var PivotModel = AbstractModel.extend({
// Private
//--------------------------------------------------------------------------
- _computeHeaders: function () {
- var self = this;
- var main_col_dims = this._getHeaderDim(this.data.main_col.root);
- var depth = main_col_dims.depth;
- var width = main_col_dims.width;
- var nbr_measures = this.data.measures.length;
- var result = [[{width:1, height: depth + 1}]];
- var col_ids = [];
- this.data.main_col.width = width;
- this._traverseTree(this.data.main_col.root, function (header) {
- var index = header.path.length - 1;
- var cell = {
- width: self._getHeaderWidth(header) * nbr_measures,
- height: header.expanded ? 1 : depth - index,
- title: header.path[header.path.length-1],
- id: header.id,
- expanded: header.expanded,
- };
- if (!header.expanded) col_ids.push(header.id);
- if (result[index]) result[index].push(cell);
- else result[index] = [cell];
- });
- col_ids.push(this.data.main_col.root.id);
- this.data.main_col.width = width;
- if (width > 1) {
- var total_cell = {width:nbr_measures, height: depth, title:""};
- if (nbr_measures === 1) {
- total_cell.total = true;
- }
- result[0].push(total_cell);
- }
- var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures;
- for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) {
- measure = this.data.measures[i % nbr_measures];
- measure_row.push({
- measure: measure,
- is_bold: (width > 1) && (i >= nbr_measures*width),
- id: col_ids[Math.floor(i / nbr_measures)],
- });
- }
- result.push(measure_row);
- return result;
- },
- _computeRows: function () {
- var self = this;
- var aggregates, i;
- var result = [];
- this._traverseTree(this.data.main_row.root, function (header) {
- var values = [],
- col_ids = [];
- result.push({
- id: header.id,
- col_ids: col_ids,
- indent: header.path.length - 1,
- title: header.path[header.path.length-1],
- expanded: header.expanded,
- values: values,
- });
- self._traverseTree(self.data.main_col.root, add_cells, header.id, values, col_ids);
- if (self.data.main_col.width > 1) {
- aggregates = self._getCellValue(header.id, self.data.main_col.root.id);
- for (i = 0; i < self.data.measures.length; i++) {
- values.push(aggregates && aggregates[self.data.measures[i]]);
- }
- col_ids.push( self.data.main_col.root.id);
- }
- });
- return result;
- function add_cells (col_hdr, row_id, values, col_ids) {
- if (col_hdr.expanded) return;
- col_ids.push(col_hdr.id);
- aggregates = self._getCellValue(row_id, col_hdr.id);
- for (i = 0; i < self.data.measures.length; i++) {
- values.push(aggregates && aggregates[self.data.measures[i]]);
- }
- }
- },
/**
- * Static helper method
+ * Add labels/values in the provided groupTree. A new leaf is created in
+ * the groupTree with a root object corresponding to the group with given
+ * labels/values.
*
- * @private @static
- * @param {any} root
- * @param {any} path
- * @returns
+ * @private
+ * @param {Object} groupTree, either this.rowGroupTree or this.colGroupTree
+ * @param {string[]} labels
+ * @param {Array} values
*/
- _findPathInTree: function (root, path) {
- var i,
- l = root.path.length;
- if (l === path.length) {
- return (root.path[l-1] === path[l - 1]) ? root : null;
- }
- for (i = 0; i < root.children.length; i++) {
- if (root.children[i].path[l] === path[l]) {
- return this._findPathInTree(root.children[i], path);
- }
- }
- return null;
- },
- _getCellValue: function (id1, id2) {
- if ((id1 in this.data.cells) && (id2 in this.data.cells[id1])) {
- return this.data.cells[id1][id2];
- }
- if (id2 in this.data.cells) return this.data.cells[id2][id1];
+ _addGroup: function (groupTree, labels, values) {
+ var tree = groupTree;
+ // we assume here that the group with value value.slice(value.length - 2) has already been added.
+ values.slice(0, values.length - 1).forEach(function (value) {
+ var key = JSON.stringify(value);
+ tree = tree.directSubTrees[key];
+ });
+ tree.directSubTrees[JSON.stringify(values[values.length - 1])] = {
+ root: {
+ labels: labels,
+ values: values,
+ },
+ directSubTrees: {},
+ };
},
/**
- * @param {any} value
- * @param {any} root
- * @param {any} i
- * @param {any} j
- * @param {any} parent
+ * Compute what should be used as rowGroupBys by the pivot view
+ *
+ * @private
+ * @returns {string[]}
+ */
+ _computeRowGroupBys: function () {
+ return !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys;
+ },
+ /**
+ * Find a group with given values in the provided groupTree, either
+ * this.rowGrouptree or this.colGroupTree.
+ *
+ * @private
+ * @param {Object} groupTree
+ * @param {Array} values
* @returns {Object}
*/
- _getHeader: function (value, root, i, j, parent) {
- var path;
- var total = _t("Total");
- if (parent) {
- path = parent.path.concat(value.slice(i,j));
- } else {
- path = [total].concat(value.slice(i,j));
- }
- return this._findPathInTree(root, path);
+ _findGroup: function (groupTree, values) {
+ var tree = groupTree;
+ values.slice(0, values.length).forEach(function (value) {
+ var key = JSON.stringify(value);
+ tree = tree.directSubTrees[key];
+ });
+ return tree;
},
/**
- * @private @static
- * @param {any} header
- * @returns {integer}
+ * In case originIndex is an array of length 1, thus a single origin
+ * index, returns the given measure for a group determined by the id
+ * groupId and the origin index.
+ * If originIndexes is an array of length 2, we compute the variation
+ * ot the measure values for the groups determined by groupId and the
+ * different origin indexes.
+ *
+ * @private
+ * @param {Array[]} groupId
+ * @param {string} measure
+ * @param {number[]} originIndexes
+ * @returns {number}
*/
- _getHeaderDepth: function (header) {
- var depth = 1;
- this._traverseTree(header, function (hdr) {
- depth = Math.max(depth, hdr.path.length);
- });
- return depth;
- },
- _getHeaderDim: function (header) {
- var depth = 1;
- var width = 0;
- this._traverseTree(header, function (hdr) {
- depth = Math.max(depth, hdr.path.length);
- if (!hdr.expanded) width++;
- });
- return {width: width, depth: depth};
- },
- _getHeaderWidth: function (header) {
+ _getCellValue: function (groupId, measure, originIndexes) {
var self = this;
- if (!header.children.length) return 1;
- if (!header.expanded) return 1;
- return header.children.reduce(function (s, c) {
- return s + self._getHeaderWidth(c);
- }, 0);
+ var key = JSON.stringify(groupId);
+ if (!self.measurements[key]) {
+ return;
+ }
+ var values = originIndexes.map(function (originIndex) {
+ return self.measurements[key][originIndex][measure];
+ });
+ if (originIndexes.length > 1) {
+ return computeVariation(values[0], values[1]);
+ } else {
+ return values[0];
+ }
},
/**
- * @param {any} value
- * @param {any} field
+ * Returns the principal domains used by the pivot model to fetch data.
+ * The domains represent two main groups of records.
+ *
+ * @private
+ * @returns {Array[][]}
+ */
+ _getDomains: function () {
+ var domains = [this.data.domain.concat(this.data.timeRange)];
+ if (this.data.compare) {
+ domains.push(this.data.domain.concat(this.data.comparisonTimeRange));
+ }
+ return domains;
+ },
+ /**
+ * Returns the rowGroupBys and colGroupBys arrays that
+ * are actually used by the pivot view internally
+ * (for read_group or other purpose)
+ *
+ * @private
+ * @returns {Object} with keys colGroupBys and rowGroupBys
+ */
+ _getGroupBys: function () {
+ return {
+ colGroupBys: this.data.colGroupBys.concat(this.data.expandedColGroupBys),
+ rowGroupBys: this.data.rowGroupBys.concat(this.data.expandedRowGroupBys),
+ };
+ },
+ /**
+ * Returns a domain representation of a group
+ *
+ * @private
+ * @param {Object} group
+ * @param {Array} group.colValues
+ * @param {Array} group.rowValues
+ * @param {number} group.originIndex
+ * @returns {Array[]}
+ */
+ _getGroupDomain: function (group) {
+ var key = JSON.stringify([group.rowValues, group.colValues]);
+ return this.groupDomains[key][group.originIndex];
+ },
+ /**
+ * Returns the group sanitized labels.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} groupBys
+ * @returns {string[]}
+ */
+ _getGroupLabels: function (group, groupBys) {
+ var self = this;
+ return groupBys.map(function (groupBy) {
+ return self._sanitizeLabel(group[groupBy], groupBy);
+ });
+ },
+ /**
+ * Returns a promise that returns the annotated read_group results
+ * corresponding to a partition of the given group obtained using the given
+ * rowGroupBy and colGroupBy.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} rowGroupBy
+ * @param {string[]} colGroupBy
+ * @returns {Promise}
+ */
+ _getGroupSubdivision: function (group, rowGroupBy, colGroupBy) {
+ var groupDomain = this._getGroupDomain(group);
+ var measureSpecs = this._getMeasureSpecs();
+ var groupBy = rowGroupBy.concat(colGroupBy);
+ return this._rpc({
+ model: this.modelName,
+ method: 'read_group',
+ context: this.data.context,
+ domain: groupDomain,
+ fields: measureSpecs,
+ groupBy: groupBy,
+ lazy: false,
+ }).then(function (subGroups) {
+ return {
+ group: group,
+ subGroups: subGroups,
+ rowGroupBy: rowGroupBy,
+ colGroupBy: colGroupBy
+ };
+ });
+ },
+ /**
+ * Returns the group sanitized values.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} groupBys
+ * @returns {Array}
+ */
+ _getGroupValues: function (group, groupBys) {
+ var self = this;
+ return groupBys.map(function (groupBy) {
+ return self._sanitizeValue(group[groupBy]);
+ });
+ },
+ /**
+ * Returns the leaf counts of each group inside the given tree.
+ *
+ * @private
+ * @param {Object} tree
+ * @returns {Object} keys are group ids
+ */
+ _getLeafCounts: function (tree) {
+ var self = this;
+ var leafCounts = {};
+ var leafCount;
+ if (_.isEmpty(tree.directSubTrees)) {
+ leafCount = 1;
+ } else {
+ leafCount = _.values(tree.directSubTrees).reduce(
+ function (acc, subTree) {
+ var subLeafCounts = self._getLeafCounts(subTree);
+ _.extend(leafCounts, subLeafCounts);
+ return acc + leafCounts[JSON.stringify(subTree.root.values)];
+ },
+ 0
+ );
+ }
+
+ leafCounts[JSON.stringify(tree.root.values)] = leafCount;
+ return leafCounts;
+ },
+ /**
+ * Returns the group sanitized measure values for the measures in
+ * this.data.measures (that migth contain '__count', not really a fieldName).
+ *
+ * @private
+ * @param {Object} group
+ * @returns {Array}
+ */
+ _getMeasurements: function (group) {
+ var self = this;
+ return this.data.measures.reduce(
+ function (measurements, fieldName) {
+ var measurement = group[fieldName];
+ if (measurement instanceof Array) {
+ // case field is many2one and used as measure and groupBy simultaneously
+ measurement = 1;
+ }
+ if (self.fields[fieldName].type === 'boolean' && measurement instanceof Boolean) {
+ measurement = measurement ? 1 : 0;
+ }
+ if (self.data.origins.length > 1 && !measurement) {
+ measurement = 0;
+ }
+ measurements[fieldName] = measurement;
+ return measurements;
+ },
+ {}
+ );
+ },
+ /**
+ * Returns a description of the measures row of the pivot table
+ *
+ * @private
+ * @param {Object[]} columns for which measure cells must be generated
+ * @returns {Object[]}
+ */
+ _getMeasuresRow: function (columns) {
+ var self = this;
+ var sortedColumn = this.data.sortedColumn || {};
+ var measureRow = [];
+
+ columns.forEach(function (column) {
+ self.data.measures.forEach(function (measure) {
+ var measureCell = {
+ groupId: column.groupId,
+ height: 1,
+ measure: measure,
+ title: self.fields[measure].string,
+ width: 2 * self.data.origins.length - 1,
+ };
+ if (sortedColumn.measure === measure &&
+ _.isEqual(sortedColumn.groupId, column.groupId)) {
+ measureCell.order = sortedColumn.order;
+ }
+ measureRow.push(measureCell);
+ });
+ });
+
+ return measureRow;
+ },
+ /**
+ * Returns the list of measure specs associated with data.measures, i.e.
+ * a measure 'fieldName' becomes 'fieldName:groupOperator' where
+ * groupOperator is the value specified on the field 'fieldName' for
+ * the key group_operator.
+ *
+ * @private
+ * @return {string[]}
+ */
+ _getMeasureSpecs: function () {
+ var self = this;
+ return this.data.measures.reduce(
+ function (acc, measure) {
+ if (measure === '__count') {
+ acc.push(measure);
+ return acc;
+ }
+ var type = self.fields[measure].type;
+ var groupOperator = self.fields[measure].group_operator;
+ if (type === 'many2one') {
+ groupOperator = 'count_distinct';
+ }
+ if (groupOperator === undefined) {
+ throw new Error("No aggregate function has been provided for the measure '" + measure + "'");
+ }
+ acc.push(measure + ':' + groupOperator);
+ return acc;
+ },
+ []
+ );
+ },
+ /**
+ * Make sure that the labels of different many2one values are distinguished
+ * by numbering them if necessary.
+ *
+ * @private
+ * @param {Array} label
+ * @param {string} fieldName
* @returns {string}
*/
- _getNumberedValue: function (value, field) {
- var id = value[0];
- var name = value[1];
- this.numbering[field] = this.numbering[field] || {};
- this.numbering[field][name] = this.numbering[field][name] || {};
- var numbers = this.numbering[field][name];
+ _getNumberedLabel: function (label, fieldName) {
+ var id = label[0];
+ var name = label[1];
+ this.numbering[fieldName] = this.numbering[fieldName] || {};
+ this.numbering[fieldName][name] = this.numbering[fieldName][name] || {};
+ var numbers = this.numbering[fieldName][name];
numbers[id] = numbers[id] || _.size(numbers) + 1;
return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : "");
},
/**
- * @param {any} dataPoint
- * @param {any} fields
- * @returns {string[]}
+ * Returns a description of the origins row of the pivot table
+ *
+ * @private
+ * @param {Object[]} columns for which origin cells must be generated
+ * @returns {Object[]}
*/
- _getValue: function (dataPoint, fields) {
- var result = [];
- var value;
- for (var i = 0; i < fields.length; i++) {
- value = this._sanitizeValue(dataPoint[fields[i]],fields[i]);
- result.push(value);
- }
- return result;
+ _getOriginsRow: function (columns) {
+ var self = this;
+ var sortedColumn = this.data.sortedColumn || {};
+ var originRow = [];
+
+ columns.forEach(function (column) {
+ var groupId = column.groupId;
+ var measure = column.measure;
+ var isSorted = sortedColumn.measure === measure &&
+ _.isEqual(sortedColumn.groupId, groupId);
+ var isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1];
+ var isSortedByVariation = isSorted && sortedColumn.originIndexes[1];
+
+ self.data.origins.forEach(function (origin, originIndex) {
+ var originCell = {
+ groupId: groupId,
+ height: 1,
+ measure: measure,
+ originIndexes: [originIndex],
+ title: origin,
+ width: 1,
+ };
+ if (isSortedByOrigin && sortedColumn.originIndexes[0] === originIndex) {
+ originCell.order = sortedColumn.order;
+ }
+ originRow.push(originCell);
+
+ if (originIndex > 0) {
+ var variationCell = {
+ groupId: groupId,
+ height: 1,
+ measure: measure,
+ originIndexes: [originIndex - 1, originIndex],
+ title: _t('Variation'),
+ width: 1,
+ };
+ if (isSortedByVariation && sortedColumn.originIndexes[1] === originIndex) {
+ variationCell.order = sortedColumn.order;
+ }
+ originRow.push(variationCell);
+ }
+
+ });
+ });
+
+ return originRow;
},
/**
- * @returns {Promise}
+ * Create an array with the origin descriptions.
+ *
+ * @private
+ * @returns {string[]}
+ */
+ _getOrigins: function () {
+ var origins = [this.data.timeRangeDescription || ""];
+ if (this.data.compare) {
+ origins.push(this.data.comparisonTimeRangeDescription);
+ }
+ return origins;
+ },
+ /**
+ * Returns a description of the pivot table.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getTable: function () {
+ var headers = this._getTableHeaders();
+ return {
+ headers: headers,
+ rows: this._getTableRows(this.rowGroupTree, headers[headers.length - 1]),
+ };
+ },
+ /**
+ * Returns the list of header rows of the pivot table: the col group rows
+ * (depending on the col groupbys), the measures row and optionnaly the
+ * origins row (if there are more than one origins).
+ *
+ * @private
+ * @returns {Object[]}
+ */
+ _getTableHeaders: function () {
+ var colGroupBys = this._getGroupBys().colGroupBys;
+ var height = colGroupBys.length + 1;
+ var measureCount = this.data.measures.length;
+ var originCount = this.data.origins.length;
+ var leafCounts = this._getLeafCounts(this.colGroupTree);
+ var headers = [];
+ var measureColumns = []; // used to generate the measure cells
+
+ // 1) generate col group rows (total row + one row for each col groupby)
+ var colGroupRows = (new Array(height)).fill(0).map(function () {
+ return [];
+ });
+ // blank top left cell
+ colGroupRows[0].push({
+ height: height + 1 + (originCount > 1 ? 1 : 0), // + measures rows [+ origins row]
+ title: "",
+ width: 1,
+ });
+ // col groupby cells with group values
+ /**
+ * Recursive function that generates the header cells corresponding to
+ * the groups of a given tree.
+ *
+ * @param {Object} tree
+ */
+ function generateTreeHeaders(tree) {
+ var group = tree.root;
+ var rowIndex = group.values.length;
+ var row = colGroupRows[rowIndex];
+ var groupId = [[], group.values];
+ var isLeaf = _.isEmpty(tree.directSubTrees);
+ var leafCount = leafCounts[JSON.stringify(tree.root.values)];
+ var cell = {
+ groupId: groupId,
+ height: isLeaf ? (colGroupBys.length + 1 - rowIndex) : 1,
+ isLeaf: isLeaf,
+ title: group.labels[group.labels.length - 1] || _t('Total'),
+ width: leafCount * measureCount * (2 * originCount - 1),
+ };
+ row.push(cell);
+ if (isLeaf) {
+ measureColumns.push(cell);
+ }
+
+ _.values(tree.directSubTrees).forEach(function (subTree) {
+ generateTreeHeaders(subTree);
+ });
+ }
+ generateTreeHeaders(this.colGroupTree);
+ // blank top right cell for 'Total' group (if there is more that one leaf)
+ if (leafCounts[JSON.stringify(this.colGroupTree.root.values)] > 1) {
+ var groupId = [[], []];
+ var totalTopRightCell = {
+ groupId: groupId,
+ height: height,
+ title: "",
+ width: measureCount * (2 * originCount - 1),
+ };
+ colGroupRows[0].push(totalTopRightCell);
+ measureColumns.push(totalTopRightCell);
+ }
+ headers = headers.concat(colGroupRows);
+
+ // 2) generate measures row
+ var measuresRow = this._getMeasuresRow(measureColumns);
+ headers.push(measuresRow);
+
+ // 3) generate origins row if more than one origin
+ if (originCount > 1) {
+ headers.push(this._getOriginsRow(measuresRow));
+ }
+
+ return headers;
+ },
+ /**
+ * Returns the list of body rows of the pivot table for a given tree.
+ *
+ * @private
+ * @param {Object} tree
+ * @param {Object[]} columns
+ * @returns {Object[]}
+ */
+ _getTableRows: function (tree, columns) {
+ var self = this;
+
+ var rows = [];
+ var group = tree.root;
+ var rowGroupId = [group.values, []];
+ var title = group.labels[group.labels.length - 1] || _t('Total');
+ var indent = group.labels.length;
+ var isLeaf = _.isEmpty(tree.directSubTrees);
+
+ var subGroupMeasurements = columns.map(function (column) {
+ var colGroupId = column.groupId;
+ var groupIntersectionId = [rowGroupId[0], colGroupId[1]];
+ var measure = column.measure;
+ var originIndexes = column.originIndexes || [0];
+
+ var value = self._getCellValue(groupIntersectionId, measure, originIndexes);
+
+ var measurement = {
+ groupId: groupIntersectionId,
+ originIndexes: originIndexes,
+ measure: measure,
+ value: value,
+ isBold: !groupIntersectionId[0].length || !groupIntersectionId[1].length,
+ };
+ return measurement;
+ });
+
+ rows.push({
+ title: title,
+ groupId: rowGroupId,
+ indent: indent,
+ isLeaf: isLeaf,
+ subGroupMeasurements: subGroupMeasurements
+ });
+
+ var subTreeKeys = tree.sortedKeys || Object.keys(tree.directSubTrees);
+ subTreeKeys.forEach(function (subTreeKey) {
+ var subTree = tree.directSubTrees[subTreeKey];
+ rows = rows.concat(self._getTableRows(subTree, columns));
+ });
+
+ return rows;
+ },
+ /**
+ * returns the height of a given groupTree
+ *
+ * @private
+ * @param {Object} tree, a groupTree
+ * @returns {number}
+ */
+ _getTreeHeight: function (tree) {
+ var subTreeHeights = _.values(tree.directSubTrees).map(this._getTreeHeight.bind(this));
+ return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1;
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _hasData: function () {
+ return (this.counts[JSON.stringify([[], []])] || []).some(function (count) {
+ return count > 0;
+ });
+ },
+ /**
+ * Initilize/Reinitialize this.rowGroupTree, colGroupTree, measurements,
+ * counts and subdivide the group 'Total' as many times it is necessary.
+ * A first subdivision with no groupBy (divisors.slice(0, 1)) is made in
+ * order to see if there is data in the intersection of the group 'Total'
+ * and the various origins. In case there is none, nonsupplementary rpc
+ * will be done (see the code of subdivideGroup).
+ * Once the promise resolves, this.rowGroupTree, colGroupTree,
+ * measurements, counts are correctly set.
+ *
+ * @private
+ * @return {Promise}
*/
_loadData: function () {
var self = this;
- var groupBys = [];
- var rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys;
- var colGroupBys = this.data.colGroupBys;
- var measures = _.map(this.data.measures, function(measure) {
- if (self.fields[measure].type === 'many2one') {
- return measure + ":count_distinct";
- }
- else {
- return measure;
- }
- });
- for (var i = 0; i < rowGroupBys.length + 1; i++) {
- for (var j = 0; j < colGroupBys.length + 1; j++) {
- groupBys.push(rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j)));
- }
- }
- var defs = groupBys.map(function (groupBy) {
- return self._rpc({
- model: self.modelName,
- method: 'read_group',
- context: self.data.context,
- domain: self.data.domain.concat(self.data.timeRange),
- fields: measures,
- groupBy: groupBy,
- lazy: false,
- }).then(function (result) {
- return ['data', result];
- });
+ this.rowGroupTree = {root: {labels: [], values: []}, directSubTrees: {}};
+ this.colGroupTree = {root: {labels: [], values: []}, directSubTrees: {}};
+ this.measurements = {};
+ this.counts = {};
+
+ var key = JSON.stringify([[],[]]);
+ this.groupDomains = {};
+ this.groupDomains[key] = this.data.domains.slice(0);
+
+
+ var group = {rowValues: [], colValues: []};
+ var groupBys = this._getGroupBys();
+ var leftDivisors = sections(groupBys.rowGroupBys);
+ var rightDivisors = sections(groupBys.colGroupBys);
+ var divisors = cartesian(leftDivisors, rightDivisors);
+
+ return this._subdivideGroup(group, divisors.slice(0, 1)).then(function () {
+ return self._subdivideGroup(group, divisors.slice(1));
});
- if (this.data.compare) {
- defs = defs.concat(groupBys.map(function (groupBy) {
- return self._rpc({
- model: self.modelName,
- method: 'read_group',
- context: self.data.context,
- domain: self.data.domain.concat(self.data.comparisonTimeRange),
- fields: measures,
- groupBy: groupBy,
- lazy: false,
- }).then(function (result) {
- return ['comparisonData', result];
+ },
+ /**
+ * Extract the information in the read_group results (groupSubdivisions)
+ * and develop this.rowGroupTree, colGroupTree, measurements, counts, and
+ * groupDomains.
+ * If a column needs to be sorted, the rowGroupTree corresponding to the
+ * group is sorted.
+ *
+ * @private
+ * @param {Object} group
+ TO DO
+ * @param {Object[]} groupSubdivisions
+ */
+ _prepareData: function (group, groupSubdivisions) {
+ var self = this;
+
+ var groupRowValues = group.rowValues;
+ var groupRowLabels = [];
+ var rowSubTree = this.rowGroupTree;
+ var root;
+ if (groupRowValues.length) {
+ // we should have labels information on hand! regretful!
+ rowSubTree = this._findGroup(this.rowGroupTree, groupRowValues);
+ root = rowSubTree.root;
+ groupRowLabels = root.labels;
+ }
+
+ var groupColValues = group.colValues;
+ var groupColLabels = [];
+ if (groupColValues.length) {
+ root = this._findGroup(this.colGroupTree, groupColValues).root;
+ groupColLabels = root.labels;
+ }
+
+ groupSubdivisions.forEach(function (groupSubdivision) {
+ groupSubdivision.subGroups.forEach(function (subGroup) {
+
+ var rowValues = groupRowValues.concat(self._getGroupValues(subGroup, groupSubdivision.rowGroupBy));
+ var rowLabels = groupRowLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.rowGroupBy));
+
+ var colValues = groupColValues.concat(self._getGroupValues(subGroup, groupSubdivision.colGroupBy));
+ var colLabels = groupColLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.colGroupBy));
+
+ if (!colValues.length && rowValues.length) {
+ self._addGroup(self.rowGroupTree, rowLabels, rowValues);
+ }
+ if (colValues.length && !rowValues.length) {
+ self._addGroup(self.colGroupTree, colLabels, colValues);
+ }
+
+ var key = JSON.stringify([rowValues, colValues]);
+ var originIndex = groupSubdivision.group.originIndex;
+
+ if (!(key in self.measurements)) {
+ self.measurements[key] = self.data.origins.map(function () {
+ return self._getMeasurements({});
});
- }));
- }
+ }
+ self.measurements[key][originIndex] = self._getMeasurements(subGroup);
- return this._loadDataDropPrevious.add(Promise.all(defs)).then(function (results) {
- var data = [];
- var comparisonData = [];
- _.each(results, function (result) {
- if (result[0] === 'data') {
- data.push(result[1]);
- } else {
- comparisonData.push(result[1]);
+ if (!(key in self.counts)) {
+ self.counts[key] = self.data.origins.map(function () {
+ return 0;
+ });
+ }
+ self.counts[key][originIndex] = subGroup.__count;
+
+ if (!(key in self.groupDomains)) {
+ self.groupDomains[key] = self.data.origins.map(function () {
+ return [[0, '=', 1]];
+ });
+ }
+ // if __domain is not defined this means that we are in the
+ // case where
+ // groupSubdivision.rowGroupBy = groupSubdivision.rowGroupBy = []
+ if (subGroup.__domain) {
+ self.groupDomains[key][originIndex] = subGroup.__domain;
}
});
- var allData = self._mergeData(data, comparisonData, groupBys);
- if (allData[0][0].__count === 0 && allData[0][0].__comparisonCount === 0) {
- self.data.has_data = false;
- }
- self._prepareData(allData);
- });
- },
- /**
- * @param {any} value
- * @param {any} domain
- * @param {any} comparisonDomain
- * @param {any} root
- * @param {any} count
- * @param {any} comparisonCount
- * @param {any} i
- * @param {any} j
- * @param {any} parent_header
- * @returns {Object}
- */
-
- _makeHeader: function (value, domain, comparisonDomain, count, comparisonCount, root, i, j, parent_header) {
- var total = _t("Total");
- var title = value.length ? value[value.length - 1] : total;
- var path, parent;
- if (parent_header) {
- path = parent_header.path.concat(title);
- parent = parent_header;
- } else {
- path = [total].concat(value.slice(i,j-1));
- parent = value.length ? this._findPathInTree(root, path) : null;
- }
- var header = {
- id: utils.generateID(),
- expanded: false,
- domain: domain,
- comparisonDomain: comparisonDomain,
- count: count,
- comparisonCount: comparisonCount,
- children: [],
- path: value.length ? parent.path.concat(title) : [title]
- };
- this.data.headers[header.id] = header;
- header.root = root || header;
- if (parent) {
- parent.children.push(header);
- parent.expanded = true;
- }
- return header;
- },
- /**
- * Here data and comparisonData are arrays of arrays of objects.
- * Each one of those objects is called a dataPoint and represents a group of records
- * (determined by some groupbys values) together with measure values aggregated.
- * An exception: if a many2one is selected as a measure and a groupby simultaneously,
- * we have only the corresponding groupby value which is the of the form ['id', 'name_get']
- * but the measure value can be inferred, it is indeed 1.
- * In case 'this.data.compare' is true (a comparison is required),
- * the dataPoint are transformed in such a way that the measures values become objects of the form
- *
- * {'data': 'some value', 'comparisonData': 'other value', 'variation': 'yet another value'}.
- *
- * In case two dataPoints have the same associated group
- * (they then come necessarily from 'data' and 'comparisonData'),
- * they are merged into a single dataPoint of the form above.
- *
- * @param {Object[][]} data
- * @param {Object[][]} comparisonData
- * @param {string[][]} groupBys
- * @returns {Object[][]}
- */
- _mergeData: function (data, comparisonData, groupBys) {
- if (!this.data.compare) {
- return data;
- }
- var allData = [];
- var dataPoints;
- var value, groupIdentifier, dataPoint, m, measureName, measureValue, measureComparisonValue;
- for (var index = 0; index < groupBys.length; index++) {
- dataPoints = {};
- // Consider dataPoints comming from 'data'. The dataPoint measure values are objects with
- // zeros values for the 'comparisonData' key since we don't know at this stage
- // if the group is represented in the 'comparisonData'.
- if (data.length) {
- for (var k = 0; k < data[index].length; k++) {
- dataPoint = data[index][k];
- if (_.isEmpty(dataPoint)){
- break;
- }
- value = this._getValue(dataPoint, groupBys[index]);
- groupIdentifier = value.join();
- for (m=0; m < this.data.measures.length; m++) {
- measureName = this.data.measures[m];
- measureValue = dataPoint[measureName];
- if (typeof measureValue === 'boolean') {
- measureValue = measureValue ? 1 : 0;
- }
- if (measureValue === null) {
- measureValue = 0;
- }
- if (!(measureValue instanceof Array) && measureName !== '__count') {
- dataPoint[measureName] = {
- data: measureValue,
- comparisonData : 0,
- variation: computeVariation(measureValue, 0),
- };
- }
- if (measureValue instanceof Array) {
- dataPoint[measureName + 'Aggregate'] = {
- data: 1,
- comparisonData: 0,
- variation: computeVariation(1, 0),
- };
- }
- }
- dataPoint.__count = {
- data: dataPoint.__count,
- comparisonData: 0,
- variation: computeVariation(dataPoint.__count, 0)
- };
- dataPoints[groupIdentifier] = dataPoint;
- }
- }
- if (comparisonData.length) {
- for (var l = 0; l < comparisonData[index].length; l++) {
- dataPoint = comparisonData[index][l];
- if (_.isEmpty(dataPoint)){
- break;
- }
- value = this._getValue(dataPoint, groupBys[index]);
- groupIdentifier = value.join();
- if (!dataPoints[groupIdentifier]) {
- // Here we know that the group is not represented in 'data'.
- for (m=0; m < this.data.measures.length; m++) {
- measureName = this.data.measures[m];
- measureComparisonValue = dataPoint[measureName];
- if (typeof(measureComparisonValue) === 'boolean') {
- measureComparisonValue = measureComparisonValue ? 1 : 0;
- }
- if (measureComparisonValue === null) {
- measureComparisonValue = 0;
- }
- if (!(measureComparisonValue instanceof Array) && measureName !== '__count') {
- dataPoint[measureName] = {
- data: 0,
- comparisonData: measureComparisonValue,
- variation: computeVariation(0, measureComparisonValue),
- };
- }
- if (measureComparisonValue instanceof Array) {
- dataPoint[measureName + 'Aggregate'] = {
- data: 0,
- comparisonData: 1,
- variation: computeVariation(0,1),
- };
- }
- }
- dataPoint.__count = {
- data: 0,
- comparisonData: dataPoint.__count,
- variation: computeVariation(0, dataPoint.__count)
- };
- dataPoint.__comparisonCount = dataPoint.__count;
- dataPoint.__comparisonDomain = dataPoint.__domain;
- dataPoints[groupIdentifier] = _.omit(dataPoint, '__domain');
- } else {
- // Here we know that the group is represented in 'data'.
- // Therefore we modify the corresonding dataPoint:
- // we modify the key 'comparisonData' and recompute 'variation'.
- for (m=0; m < this.data.measures.length; m++) {
- measureName = this.data.measures[m];
- measureComparisonValue = dataPoint[measureName];
- if (typeof(measureComparisonValue) === 'boolean') {
- measureComparisonValue = measureComparisonValue ? 1 : 0;
- }
- if (measureComparisonValue === null) {
- measureComparisonValue = 0;
- }
- if (!(measureComparisonValue instanceof Array) && measureName !== '__count') {
- dataPoints[groupIdentifier][measureName].comparisonData = measureComparisonValue;
- dataPoints[groupIdentifier][measureName].variation = computeVariation(
- dataPoints[groupIdentifier][measureName].data,
- measureComparisonValue
- );
- }
- if (measureComparisonValue instanceof Array) {
- dataPoints[groupIdentifier][measureName + 'Aggregate'].comparisonData = 1;
- dataPoints[groupIdentifier][measureName + 'Aggregate'].variation = computeVariation(
- dataPoints[groupIdentifier][measureName + 'Aggregate'].data,
- 1
- );
-
- }
- }
- dataPoints[groupIdentifier].__count.comparisonData = dataPoint.__count;
- dataPoints[groupIdentifier].__count.variation = computeVariation(
- dataPoints[groupIdentifier].__count.data,
- dataPoint.__count
- );
- dataPoints[groupIdentifier].__comparisonCount = dataPoint.__count;
- dataPoints[groupIdentifier].__comparisonDomain = dataPoint.__domain;
- }
- }
- }
- allData.push(_.values(dataPoints));
- }
- return allData;
- },
- /**
- * @param {Object[][]} data
- */
- _prepareData: function (data) {
- var self = this;
- _.extend(this.data, {
- main_row: {},
- main_col: {},
- headers: {},
- cells: [],
});
- var index = 0;
- var rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice();
- this.data.groupedBy = rowGroupBys;
- var colGroupBys = this.data.colGroupBys;
- var dataPoint, row, col, attrs, cell_value;
- var main_row_header, main_col_header;
- var groupBys;
- var m;
-
- for (var i = 0; i < rowGroupBys.length + 1; i++) {
- for (var j = 0; j < colGroupBys.length + 1; j++) {
- for (var k = 0; k < data[index].length; k++) {
- dataPoint = data[index][k];
- groupBys = rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j));
- attrs = {
- // value could be named 'groupIdentifier'
- value: self._getValue(dataPoint, groupBys),
- domain: dataPoint.__domain,
- comparisonDomain: dataPoint.__comparisonDomain,
- };
- if (dataPoint.__count) {
- attrs.length = dataPoint.__count instanceof Object ? dataPoint.__count.data : dataPoint.__count;
- attrs.comparisonLength = dataPoint.__comparisonCount;
- }
-
- if (j === 0) {
- row = this._makeHeader(attrs.value, attrs.domain, attrs.comparisonDomain, attrs.length, attrs.comparisonLength, main_row_header, 0, i);
- } else {
- row = this._getHeader(attrs.value, main_row_header, 0, i);
- }
- if (i === 0) {
- col = this._makeHeader(attrs.value, attrs.domain, attrs.comparisonDomain, attrs.length, attrs.comparisonLength, main_col_header, i, i+j);
- } else {
- col = this._getHeader(attrs.value, main_col_header, i, i+j);
- }
- if (i + j === 0) {
- this.data.has_data = attrs.length > 0 || attrs.comparisonLength > 0;
- main_row_header = row;
- main_col_header = col;
- }
- if (!this.data.cells[row.id]) this.data.cells[row.id] = [];
- for (cell_value = {}, m=0; m < this.data.measures.length; m++) {
- var _value = dataPoint[this.data.measures[m]];
- if (_value instanceof Array) {
- // when a many2one field is used as a measure AND as
- // a grouped field, bad things happen. The server
- // will only return the grouped value and will not
- // aggregate it. Since there is a nameclash, we are
- // then in the situation where this value is an
- // array. Fortunately, if we group by a field,
- // then we can say for certain that the group contains
- // exactly one distinct value for that field.
- if (this.data.compare) {
- _value = dataPoint[this.data.measures[m] + 'Aggregate'];
- } else {
- _value = 1;
- }
- }
- cell_value[this.data.measures[m]] = _value;
- }
- this.data.cells[row.id][col.id] = cell_value;
- }
- index++;
- }
+ if (this.data.sortedColumn) {
+ this.sortRows(this.data.sortedColumn, rowSubTree);
}
-
- main_row_header.other_root = main_col_header;
- main_col_header.other_root = main_row_header;
-
- main_row_header.groupbys = rowGroupBys;
- main_col_header.groupbys = colGroupBys;
-
- this.data.main_row.root = main_row_header;
- this.data.main_col.root = main_col_header;
},
/**
* In the preview implementation of the pivot view (a.k.a. version 2),
@@ -1079,8 +1393,9 @@ var PivotModel = AbstractModel.extend({
* This function replaces in the given array of measures occurences of
* '__count__' by '__count'.
*
+ * @private
* @param {Array[string] || undefined} measures
- * @return {Array[string] || undefined}
+ * @returns {Array[string] || undefined}
*/
_processMeasures: function (measures) {
if (measures) {
@@ -1090,69 +1405,118 @@ var PivotModel = AbstractModel.extend({
}
},
/**
- * Format a value to a usable string, for the renderer to display.
+ * Make any group in tree a leaf if it was a leaf in oldTree.
*
- * @param {any} value
- * @param {any} field
+ * @private
+ * @param {Object} tree
+ * @param {Object} oldTree
+ */
+ _pruneTree: function (tree, oldTree) {
+ if (_.isEmpty(oldTree.directSubTrees)) {
+ tree.directSubTrees = {};
+ delete tree.sortedKeys;
+ return;
+ }
+ var self = this;
+ Object.keys(tree.directSubTrees).forEach(function (subTreeKey) {
+ var index = Object.keys(oldTree.directSubTrees).indexOf(subTreeKey);
+ var subTree = tree.directSubTrees[subTreeKey];
+ if (index === -1) {
+ subTree.directSubTrees = {};
+ delete subTreeKey.sortedKeys;
+ } else {
+ var oldSubTree = oldTree.directSubTrees[subTreeKey];
+ self._pruneTree(subTree, oldSubTree);
+ }
+ });
+ },
+ /**
+ * Extract from a groupBy value a label.
+ *
+ * @private
+ * @param {any} value
+ * @param {string} groupBy
* @returns {string}
*/
- _sanitizeValue: function (value, field) {
+ _sanitizeLabel: function (value, groupBy) {
+ var fieldName = groupBy.split(':')[0];
if (value === false) {
return _t("Undefined");
}
if (value instanceof Array) {
- return this._getNumberedValue(value, field);
+ return this._getNumberedLabel(value, fieldName);
}
- if (field && this.fields[field] && (this.fields[field].type === 'selection')) {
- var selected = _.where(this.fields[field].selection, {0: value})[0];
+ if (fieldName && this.fields[fieldName] && (this.fields[fieldName].type === 'selection')) {
+ var selected = _.where(this.fields[fieldName].selection, {0: value})[0];
return selected ? selected[1] : value;
}
return value;
},
/**
- * @private @static
- * @param {any} root
- * @param {any} f
- * @param {any} arg1
- * @param {any} arg2
- * @param {any} arg3
- * @returns
+ * Extract from a groupBy value the raw value of that groupBy (discarding
+ * a label if any)
+ *
+ * @private
+ * @param {any} value
+ * @returns {any}
*/
- _traverseTree: function (root, f, arg1, arg2, arg3) {
- f(root, arg1, arg2, arg3);
- if (!root.expanded) return;
- for (var i = 0; i < root.children.length; i++) {
- this._traverseTree(root.children[i], f, arg1, arg2, arg3);
+ _sanitizeValue: function (value) {
+ if (value instanceof Array) {
+ return value[0];
}
+ return value;
},
/**
- * @param {Object} old_tree
- * @param {Object} new_tree
+ * Get all partitions of a given group using the provided list of divisors
+ * and enrich the objects of this.rowGroupTree, colGroupTree,
+ * measurements, counts.
+ *
+ * @private
+ * @param {Object} group
+ * @param {Array[]} divisors
+ * @returns
*/
- _updateTree: function (old_tree, new_tree) {
- if (!old_tree.expanded) {
- new_tree.expanded = false;
- new_tree.children = [];
- return;
- }
- var tree, j, old_title, new_title;
- for (var i = 0; i < new_tree.children.length; i++) {
- tree = undefined;
- new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1];
- for (j = 0; j < old_tree.children.length; j++) {
- old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1];
- if (old_title === new_title) {
- tree = old_tree.children[j];
- break;
+ _subdivideGroup: function (group, divisors) {
+ var self = this;
+
+ var key = JSON.stringify([group.rowValues, group.colValues]);
+
+ var proms = this.data.origins.reduce(
+ function (acc, origin, originIndex) {
+ // if no information on group content is available, we fetch data.
+ // if group is known to be empty for the given origin,
+ // we don't need to fetch data fot that origin.
+ if (!self.counts[key] || self.counts[key][originIndex] > 0) {
+ var subGroup = {rowValues: group.rowValues, colValues: group.colValues, originIndex: originIndex};
+ divisors.forEach(function (divisor) {
+ acc.push(self._getGroupSubdivision(subGroup, divisor[0], divisor[1]));
+ });
}
+ return acc;
+ },
+ []
+ );
+ return this._loadDataDropPrevious.add(Promise.all(proms)).then(function (groupSubdivisions) {
+ if (groupSubdivisions.length) {
+ self._prepareData(group, groupSubdivisions);
}
- if (tree) {
- this._updateTree(tree, new_tree.children[i]);
- } else {
- new_tree.children[i].expanded = false;
- new_tree.children[i].children = [];
- }
- }
+ });
+ },
+ /**
+ * Sort recursively the subTrees of tree using sortFunction.
+ * In the end each node of the tree has its direct children sorted
+ * according to the criterion reprensented by sortFunction.
+ *
+ * @private
+ * @param {Function} sortFunction
+ * @param {Object} tree
+ */
+ _sortTree: function (sortFunction, tree) {
+ var self = this;
+ tree.sortedKeys = _.sortBy(Object.keys(tree.directSubTrees), sortFunction(tree));
+ _.values(tree.directSubTrees).forEach(function (subTree) {
+ self._sortTree(sortFunction, subTree);
+ });
},
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js
index f2d2d24bea8..b74b408c877 100644
--- a/addons/web/static/src/js/views/pivot/pivot_renderer.js
+++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js
@@ -2,72 +2,58 @@ odoo.define('web.PivotRenderer', function (require) {
"use strict";
var AbstractRenderer = require('web.AbstractRenderer');
-var core = require('web.core');
-var field_utils = require('web.field_utils');
var config = require('web.config');
+var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
+var field_utils = require('web.field_utils');
var QWeb = core.qweb;
+
var _t = core._t;
var PivotRenderer = AbstractRenderer.extend({
tagName: 'table',
className: 'table-hover table-sm table-bordered',
events: _.extend({}, AbstractRenderer.prototype.events, {
- 'hover td': '_onTdHover',
+ 'click td.o_pivot_cell_value': '_onCellValueClicked',
+ 'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
+ 'click .o_pivot_measure_row': '_onSpecialRowClick',
+ 'click .o_pivot_origin_row': '_onSpecialRowClick',
+ 'mouseenter thead tr:last th': '_onMouseenterCell',
+ 'mouseenter tbody td': '_onMouseenterCell',
+ 'mouseleave thead tr:last th': '_onMouseleaveCell',
+ 'mouseleave tbody td': '_onMouseleaveCell',
}),
/**
- * @overide
- *
+ * @override
* @param {Widget} parent
* @param {Object} state
* @param {Object} params
+ * @param {boolean} params.enableLinking configure the pivot view to allow
+ * opening a list view by clicking on a cell with some data.
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
- this.compare = state.compare;
+ this.enableLinking = params.enableLinking;
this.fieldWidgets = params.widgets || {};
- this.timeRangeDescription = params.timeRangeDescription;
- this.comparisonTimeRangeDescription = params.comparisonTimeRangeDescription;
this.paddingLeftHeaderTabWidth = config.device.isMobile ? 5 : 30;
},
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @param {Object} state
- * @param {Object} params
- */
- updateState: function (state, params) {
- if (params.context !== undefined) {
- var timeRangeMenuData = params.context.timeRangeMenuData;
- if (timeRangeMenuData) {
- this.timeRangeDescription = timeRangeMenuData.timeRangeDescription;
- this.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription;
- } else {
- this.timeRangeDescription = undefined;
- this.comparisonTimeRangeDescription = undefined;
- }
- }
- this.compare = state.compare;
- return this._super.apply(this, arguments);
- },
-
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
- * Used to determine whether or not to display the no content helper.
- *
* @private
- * @returns {boolean}
+ * @param {string[]} groupBy list of 'fieldName[:period]'
+ * @returns {string[]} list of fields label
*/
- _hasContent: function () {
- return this.state.has_data && this.state.measures.length;
+ _getGroupByLabels: function (groupBy) {
+ var self = this;
+ return _.map(groupBy, function (gb) {
+ return self.state.fields[gb.split(':')[0]].string;
+ });
},
/**
* @override
@@ -75,7 +61,8 @@ var PivotRenderer = AbstractRenderer.extend({
* @returns {Promise}
*/
_render: function () {
- if (!this._hasContent()) {
+ var hasContent = this.state.hasData && this.state.measures.length;
+ if (!hasContent) {
// display the nocontent helper
this._replaceElement(QWeb.render('View.NoContentHelper', {
description: _t("Try to add some records, or make sure that there is at least " +
@@ -84,223 +71,145 @@ var PivotRenderer = AbstractRenderer.extend({
return this._super.apply(this, arguments);
}
- if (!this.$el.is('table')) {
- // coming from the no content helper, so the root element has to be
- // re-rendered before rendering and appending its content
- this.renderElement();
- }
- var $fragment = $(document.createDocumentFragment());
- var $table = $('
').appendTo($fragment);
- var $thead = $('').appendTo($table);
- var $tbody = $(' ').appendTo($table);
- var nbr_measures = this.state.measures.length;
- var nbrCols = (this.state.mainColWidth === 1) ?
- nbr_measures :
- (this.state.mainColWidth + 1) * nbr_measures;
- var i;
- if (this.compare) {
- for (i=0; i < 3 * nbrCols + 1; i++) {
- $table.prepend($(' '));
- }
- } else {
- for (i=0; i < nbrCols + 1; i++) {
- $table.prepend($(' '));
- }
- }
- this._renderHeaders($thead, this.state.headers, nbrCols);
- this._renderRows($tbody, this.state.rows);
- // todo: make sure the next line does something
- $table.find('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip();
- this.$el.html($table.contents());
+ this.renderElement(); // in case we come from no content helper
+ this.$el.toggleClass('o_enable_linking', this.enableLinking);
+
+ var $thead = $('');
+ var $tbody = $(' ');
+ this._renderHeaders($thead);
+ this._renderRows($tbody);
+ this.$el.empty().append($thead).append($tbody);
+ this.$('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip();
+
return this._super.apply(this, arguments);
},
/**
* @private
- * @param {jQueryElement} $thead
- * @param {jQueryElement} headers
+ * @param {jQuery} $thead
*/
- _renderHeaders: function ($thead, headers, nbrCols) {
- var self = this;
- var i, j, cell, $row, $cell;
- var measure, dataType;
- var id, measureCellIds = [];
+ _renderHeaders: function ($thead) {
+ // groupbyLabels is the list of col groupby fields label
+ var groupbyLabels = this._getGroupByLabels(this.state.colGroupBys);
- var groupbyLabels = _.map(this.state.colGroupBys, function (gb) {
- return self.state.fields[gb.split(':')[0]].string;
- });
-
- for (i = 0; i < headers.length; i++) {
- $row = $('');
- for (j = 0; j < headers[i].length; j++) {
- cell = headers[i][j];
- if (i == 0 && j == 0) {
- $cell = $('')
- .text(cell.title)
- .attr('rowspan', cell.height + 1)
- .attr('colspan', cell.width);
- } else {
- $cell = $(' ')
- .text(cell.title)
- .attr('rowspan', cell.height)
- .attr('colspan', (cell.width && self.compare) ? 3 * cell.width : cell.width || (self.compare && cell.measure && 3));
- }
- if (i > 0) {
- $cell.attr('title', groupbyLabels[i-1]);
- }
- if (cell.expanded !== undefined) {
- $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
- $cell.data('id', cell.id);
+ this.state.table.headers.forEach(function (row, rowIndex) {
+ var $tr = $(' ');
+ row.forEach(function (cell) {
+ var cellParams = {
+ text: cell.title,
+ colspan: cell.width,
+ rowspan: cell.height,
+ data: {
+ groupId: cell.groupId,
+ type: 'col',
+ }
}
+ var className;
if (cell.measure) {
- measure = cell.measure;
- measureCellIds.push(cell.id);
- $cell.addClass('o_pivot_measure_row text-muted')
- .text(this.state.fields[measure].string);
- $cell.data('id', cell.id).data('measure', measure);
- if (cell.id === this.state.sortedColumn.id &&
- measure === this.state.sortedColumn.measure) {
- $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order);
- if (this.state.sortedColumn.order == 'asc') {
- $cell.attr('aria-sorted', 'ascending');
+ if (cell.originIndexes) {
+ cellParams.data.originIndexes = cell.originIndexes
+ className = 'o_pivot_origin_row';
+ } else {
+ className = 'o_pivot_measure_row';
+ }
+ className += ' text-muted';
+ if (cell.order) {
+ className += ' o_pivot_sort_order_' + cell.order;
+ if (cell.order === 'asc') {
+ cellParams['aria-sorted'] = 'ascending'
} else {
- $cell.attr('aria-sorted', 'descending');
+ cellParams['aria-sorted'] = 'descending'
}
}
- }
- $row.append($cell);
-
- $cell.toggleClass('d-none d-md-table-cell', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.state.measures.length));
- if (cell.height > 1) {
- $cell.css('padding', 0);
- }
- }
- $thead.append($row);
- }
- if (this.compare) {
- var colLabels = [this.timeRangeDescription, this.comparisonTimeRangeDescription, _t('Variation')];
- var dataTypes = ['data', 'comparisonData', 'variation'];
- $row = $(' ');
- for (i = 0; i < 3 * nbrCols; i++) {
- id = measureCellIds[~~(i / 3)];
- measure = this.state.measures[(~~(i / 3)) % this.state.measures.length];
- dataType = dataTypes[i % 3];
- $cell = $('')
- .addClass('o_pivot_measure_row text-muted')
- .data('data_type', dataType)
- .data('id', id)
- .data('measure', measure)
- .text(colLabels[i % 3])
- .attr('rowspan', 1)
- .attr('colspan', 1);
- if (dataType === this.state.sortedColumn.dataType &&
- id === this.state.sortedColumn.id &&
- measure === this.state.sortedColumn.measure) {
- $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order);
- if (this.state.sortedColumn.order === 'asc') {
- $cell.attr('aria-sorted', 'ascending');
- } else {
- $cell.attr('aria-sorted', 'descending');
+ cellParams.data.measure = cell.measure;
+ } else if ('isLeaf' in cell) {
+ if (rowIndex > 0) {
+ cellParams.title = groupbyLabels[rowIndex - 1];
+ }
+ className = 'o_pivot_header_cell';
+ if (!config.device.isMobile) {
+ className += cell.isLeaf ? '_closed' : '_opened';
}
}
- $row.append($cell);
- }
- $thead.append($row);
- }
+ cellParams.class = className;
+
+ $tr.append($(' ', cellParams));
+ });
+ $thead.append($tr);
+ });
},
/**
* @private
- * @param {jQueryElement} $tbody
- * @param {jQueryElement} rows
+ * @param {jQuery} $tbody
*/
- _renderRows: function ($tbody, rows) {
+ _renderRows: function ($tbody) {
var self = this;
- var i, j, value, measure, name, formatter, $row, $cell, $header;
- var nbrMeasures = this.state.measures.length;
- var length = rows[0].values.length;
- var shouldDisplayTotal = this.state.mainColWidth > 1;
- var groupbyLabels = _.map(this.state.rowGroupBys, function (gb) {
- return self.state.fields[gb.split(':')[0]].string;
- });
- var measureTypes = this.state.measures.map(function (name) {
- var type = self.state.fields[name].type;
- return type === 'many2one' ? 'integer' : type;
- });
- for (i = 0; i < rows.length; i++) {
- $row = $(' ');
- $header = $('')
- .text(rows[i].title)
- .data('id', rows[i].id)
- .css('padding-left', (5 + rows[i].indent * self.paddingLeftHeaderTabWidth) + 'px')
- .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
- if (rows[i].indent > 0) $header.attr('title', groupbyLabels[rows[i].indent - 1]);
- $header.appendTo($row);
- for (j = 0; j < length; j++) {
- value = rows[i].values[j];
- name = this.state.measures[j % nbrMeasures];
- formatter = field_utils.format[this.fieldWidgets[name] || measureTypes[j % nbrMeasures]];
- measure = this.state.fields[name];
- if (this.compare) {
- if (value instanceof Object) {
- for (var origin in value) {
- $cell = $(' ')
- .data('id', rows[i].id)
- .data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)])
- .data('type' , origin)
- .toggleClass('o_empty', false)
- .addClass('o_pivot_cell_value text-right');
- if (origin === 'data') {
- $cell.append($('', {class: 'o_value'}).html(formatter(
- value[origin],
- measure
- )));
- } else if (origin === 'comparisonData') {
- $cell.append($('
', {class: 'o_comparison_value'}).html(formatter(
- value[origin],
- measure
- )));
- } else {
- $cell.append($('
', {class: 'o_variation' + value[origin].signClass}).html(
- field_utils.format.percentage(
- value[origin].magnitude,
- measure
- )
- ));
- }
- if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){
- $cell.css('font-weight', 'bold');
- }
- $cell.toggleClass('d-none d-md-table-cell', j < length - nbrMeasures);
- $row.append($cell);
- }
- } else {
- for (var l=0; l < 3; l++) {
- $cell = $('
')
- .data('id', rows[i].id)
- .toggleClass('o_empty', true)
- .addClass('o_pivot_cell_value text-right');
- $row.append($cell);
- }
- }
- } else {
- $cell = $(' ')
- .data('id', rows[i].id)
- .data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)])
- .toggleClass('o_empty', _.isUndefined(value))
- .addClass('o_pivot_cell_value text-right');
- if (value !== undefined) {
- $cell.append($('', {class: 'o_value'}).html(formatter(value, measure)));
- }
- if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){
- $cell.css('font-weight', 'bold');
- }
- $row.append($cell);
+ // measureTypes is a mapping from measure fields to their field type,
+ // with a special case for many2one fields which are mapped to the
+ // 'integer' type (as their group_operator is 'count_distinct')
+ var measureTypes = this.state.measures.reduce(
+ function (acc, measureName) {
+ var type = self.state.fields[measureName].type;
+ acc[measureName] = type === 'many2one' ? 'integer' : type;
+ return acc;
+ },
+ {}
+ );
- $cell.toggleClass('d-none d-md-table-cell', j < length - nbrMeasures);
+ // groupbyLabels is the list of row groupby fields label
+ var groupbyLabels = this._getGroupByLabels(this.state.rowGroupBys);
+
+ this.state.table.rows.forEach(function (row) {
+ var $tr = $('
');
+ var paddingLeft = 5 + row.indent * self.paddingLeftHeaderTabWidth;
+ $tr.append($('', {
+ text: row.title,
+ title: row.indent > 0 ? groupbyLabels[row.indent - 1] : null,
+ data: {
+ groupId: row.groupId,
+ type: 'row',
+ },
+ css: {
+ 'padding-left': paddingLeft + 'px',
+ },
+ class: 'o_pivot_header_cell_' + (row.isLeaf ? 'closed' : 'opened'),
+ }));
+
+ row.subGroupMeasurements.forEach(function (measurement) {
+ var cellParams = {
+ data: {
+ groupId: measurement.groupId,
+ originIndexes: measurement.originIndexes,
+ },
+ class: 'o_pivot_cell_value text-right',
+ };
+ if (measurement.isBold) {
+ cellParams.css = {
+ 'font-weight': 'bold',
+ };
}
- }
- $tbody.append($row);
- }
+ if (measurement.value !== undefined) {
+ var measure = measurement.measure;
+ var measureField = self.state.fields[measure];
+ var $value;
+ if (measurement.originIndexes.length > 1) {
+ $value = dataComparisonUtils.renderVariation(measurement.value, measureField);
+ } else {
+ var formatType = self.fieldWidgets[measure] || measureTypes[measure];
+ var formatter = field_utils.format[formatType];
+ $value = $('', {
+ class: 'o_value',
+ html: formatter(measurement.value, measureField),
+ });
+ }
+ cellParams.html = $value;
+ } else {
+ cellParams.class += ' o_empty'
+ }
+ $tr.append($('
', cellParams));
+ });
+ $tbody.append($tr);
+ });
},
//--------------------------------------------------------------------------
@@ -308,12 +217,104 @@ var PivotRenderer = AbstractRenderer.extend({
//--------------------------------------------------------------------------
/**
+ * When the user clicks on a non empty cell, and the view is configured to
+ * allow 'linking' (with enableLinking), we want to open a list view with
+ * the corresponding record.
+ *
* @private
* @param {MouseEvent} ev
*/
- _onTdHover: function (ev) {
- var $td = $(event.target);
- $td.closest('table').find('col:eq(' + $td.index()+')').toggleClass('hover');
+ _onCellValueClicked: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ var $target = $(ev.currentTarget);
+ if ($target.hasClass('o_empty') || !this.enableLinking) {
+ return;
+ }
+
+ var context = _.omit(this.state.context, function (val, key) {
+ return key === 'group_by' || _.str.startsWith(key, 'search_default_');
+ });
+
+ var groupId = $target.data('groupId');
+ var originIndexes = $target.data('originIndexes');
+
+ var group = {
+ rowValues: groupId[0],
+ colValues: groupId[1],
+ originIndex: originIndexes[0]
+ };
+
+ this.trigger_up('open_view', {
+ group: group,
+ context: context,
+ });
+ },
+ /**
+ * Highlight the column when hovering a cell.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseenterCell: function (ev) {
+ var index = $(ev.currentTarget).index();
+ if ($(ev.currentTarget).is('th')) { // header cell
+ index += 1; // increment by 1 to compensate the top left empty cell
+ }
+ this.$("td").filter(":nth-child(" + (index + 1) + ")").addClass("o_cell_hover");
+ },
+ /**
+ * @private
+ */
+ _onMouseleaveCell: function () {
+ this.$('.o_cell_hover').removeClass('o_cell_hover');
+ },
+ /**
+ * This method is called when someone clicks on an open header. When that
+ * happens, we want to close the header, then redisplay the view.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onOpenHeaderClick: function (ev) {
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+
+ var $target = $(ev.target);
+ var groupId = $target.data('groupId');
+ var type = $target.data('type');
+
+ this.trigger_up('close_group', {
+ groupId: groupId,
+ type: type,
+ });
+ },
+ /**
+ * If the user clicks on a measure or origin row, we perform an in-memory sort.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSpecialRowClick: function (ev) {
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+
+ var $target = $(ev.target);
+ var groupId = $target.data('groupId');
+ var measure = $target.data('measure');
+ var originIndexes = $target.data('originIndexes');
+ var isAscending = $target.hasClass('o_pivot_sort_order_asc');
+ var order = isAscending ? 'desc' : 'asc';
+
+ this.trigger_up('sort_rows', {
+ sortedColumn: {
+ groupId: groupId,
+ measure: measure,
+ order: order,
+ originIndexes: originIndexes,
+ }
+ });
},
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index cf58e1abf4b..78e7c68cbe6 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -8,6 +8,7 @@ odoo.define('web.PivotView', function (require) {
*/
var AbstractView = require('web.AbstractView');
+var config = require('web.config');
var core = require('web.core');
var PivotModel = require('web.PivotModel');
var PivotController = require('web.PivotController');
@@ -65,12 +66,9 @@ var PivotView = AbstractView.extend({
if (field.attrs.interval) {
name += ':' + field.attrs.interval;
}
-
if (field.attrs.widget) {
widgets[name] = field.attrs.widget;
}
-
-
if (field.attrs.invisible && py.eval(field.attrs.invisible)) {
delete measures[name];
return;
@@ -82,15 +80,12 @@ var PivotView = AbstractView.extend({
// the measure should be allowed. However, be careful if you define
// a measure in your pivot view: non stored functional fields will
// probably not work (their aggregate will always be 0).
-
- if (field.attrs.type === 'measure' && !(field.attrs.name in measures)) {
- measures[field.attrs.name] = self.fields[field.attrs.name];
+ if (field.attrs.type === 'measure' && !(name in measures)) {
+ measures[name] = self.fields[name];
}
-
if (field.attrs.string) {
measures[name].string = field.attrs.string;
}
-
if (field.attrs.type === 'measure' || 'operator' in field.attrs) {
activeMeasures.push(name);
measures[name] = self.fields[name];
@@ -111,11 +106,16 @@ var PivotView = AbstractView.extend({
this.loadParams.rowGroupBys = rowGroupBys;
this.loadParams.fields = this.fields;
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
+ if (config.device.isMobile) {
+ this.loadParams.colGroupBys = [];
+ this.loadParams.context.pivot_column_groupby = [];
+ }
+
this.rendererParams.widgets = widgets;
+ this.rendererParams.enableLinking = !this.arch.attrs.disable_linking;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
- this.controllerParams.enableLinking = !this.arch.attrs.disable_linking;
this.controllerParams.measures = measures;
this.controllerParams.groupableFields = groupableFields;
// retrieve form and list view ids from the action to open those views
diff --git a/addons/web/static/src/scss/pivot_view.scss b/addons/web/static/src/scss/pivot_view.scss
index 6bc25ee114e..ada67f4edfa 100644
--- a/addons/web/static/src/scss/pivot_view.scss
+++ b/addons/web/static/src/scss/pivot_view.scss
@@ -1,15 +1,6 @@
.o_pivot {
overflow: auto;
- // Inform the user that he may click on a cell to be redirected to a list view of the
- // items corresponding to the clicked cell
- &.o_enable_linking {
- .o_pivot_cell_value:hover {
- color: $o-brand-primary;
- cursor: pointer;
-
- }
- }
.o_pivot_cell_value {
font-size: 1em;
.o_comparison {
@@ -29,7 +20,17 @@
background-color: $o-view-background-color;
width: auto; // bootstrap override
- thead th:not(.o_pivot_header_cell_closed):not(.o_pivot_header_cell_opened) {
+ // Inform the user that he may click on a cell to be redirected to a list view of the
+ // items corresponding to the clicked cell
+ &.o_enable_linking {
+ .o_pivot_cell_value:not(.o_empty):hover {
+ color: $o-brand-primary;
+ cursor: pointer;
+
+ }
+ }
+
+ thead th:not(.o_pivot_header_cell_closed):not(.o_pivot_header_cell_opened):not(.o_pivot_header_cell) {
text-align: center;
}
@@ -52,6 +53,11 @@
@include o-pivot-header-cell;
}
+ .o_pivot_header_cell {
+ white-space: nowrap;
+ user-select: none;
+ }
+
.o_pivot_header_cell_closed {
@include o-pivot-header-cell;
&::before {
@@ -70,14 +76,18 @@
}
}
- .o_pivot_measure_row_sorted_asc::after {
+ .o_pivot_sort_order_asc::after {
+ @include o-caret-up;
+ margin-left: 5px;
+ }
+
+ .o_pivot_sort_order_desc::after {
@include o-caret-down;
margin-left: 5px;
}
- .o_pivot_measure_row_sorted_desc::after {
- @include o-caret-up;
- margin-left: 5px;
+ .o_pivot_cell_value.o_cell_hover {
+ background-color: $table-hover-bg;
}
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index da8e376e7d3..743187bf624 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -442,7 +442,7 @@
-
+