summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
authorMathieu Duckerts-Antoine <dam@odoo.com>2019-04-02 15:00:53 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2019-08-02 18:34:18 +0000
commit49a97305b1f214b7d27f3de3bd0a91b6c551f8cd (patch)
tree7951ce91fb4d3798f351d88caa7427623079466f /addons/web/static/src/js
parent1ef258f6cbb8214e765fee50100857dc2a237c19 (diff)
[REF] *: pivot view refactoring
The main aim of the present refactoring is twofold: - simplify the code that was largely complexified at the time of the introduction of comparisons in the pivot view (Time Ranges menu), - factorize the method expandHeader and _loadData that were too much complex. Along the way, the pivot model structure has been simplified and many tests have been added. Performances in mobile mode have been improved by avoiding unecessary operations due to useless column groupbys in that mode. It should now be easier to understand and modify the pivot view in future. Co-Authored-By: Aaron Bohy <aab@odoo.com> closes odoo/odoo#33615 Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
Diffstat (limited to 'addons/web/static/src/js')
-rw-r--r--addons/web/static/src/js/core/data_comparison_utils.js70
-rw-r--r--addons/web/static/src/js/core/math_utils.js2
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js219
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js2072
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js469
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js18
6 files changed, 1606 insertions, 1244 deletions
diff --git a/addons/web/static/src/js/core/data_comparison_utils.js b/addons/web/static/src/js/core/data_comparison_utils.js
index b55cc4b93d8..ad848824c3d 100644
--- a/addons/web/static/src/js/core/data_comparison_utils.js
+++ b/addons/web/static/src/js/core/data_comparison_utils.js
@@ -68,67 +68,60 @@ var DateClasses = Class.extend({
/**
* @param {Number} value
* @param {Number} comparisonValue
- * @returns {Object}
+ * @returns {Number}
*/
-function computeVariation (value, comparisonValue) {
- var magnitude;
- var signClass;
-
- if (!isNaN(value) && !isNaN(comparisonValue)) {
- if (comparisonValue === 0) {
- if (value === 0) {
- magnitude = 0;
- } else if (value > 0){
- magnitude = 1;
- } else {
- magnitude = -1;
- }
+function computeVariation(value, comparisonValue) {
+ if (isNaN(value) || isNaN(comparisonValue)) {
+ return NaN;
+ }
+ if (comparisonValue === 0) {
+ if (value === 0) {
+ return 0;
+ } else if (value > 0) {
+ return 1;
} else {
- magnitude = (value - comparisonValue) / Math.abs(comparisonValue);
+ return -1;
}
- if (magnitude > 0) {
- signClass = ' o_positive';
- } else if (magnitude < 0) {
- signClass = ' o_negative';
- } else if (magnitude === 0) {
- signClass = ' o_null';
- }
- return {magnitude: magnitude, signClass: signClass};
- } else {
- return {magnitude: NaN};
}
+ return (value - comparisonValue) / Math.abs(comparisonValue);
}
/**
- * @param {Object} variation
+ * @param {Number} variation
* @param {Object} field
* @param {Object} options
* @returns {Object}
*/
-function renderVariation (variation, field, options) {
- var $variation;
- if (!isNaN(variation.magnitude)) {
- $variation = $('<div>', {class: 'o_variation' + variation.signClass}).html(
- fieldUtils.format.percentage(variation.magnitude, field, options
- ));
+function renderVariation(variation, field, options) {
+ var className = 'o_variation';
+ var value;
+ if (!isNaN(variation)) {
+ if (variation > 0) {
+ className += ' o_positive';
+ } else if (variation < 0) {
+ className += ' o_negative';
+ } else {
+ className += ' o_null';
+ }
+ value = fieldUtils.format.percentage(variation, field, options);
} else {
- $variation = $('<div>', {class: 'o_variation'}).html('-');
+ value = '-';
}
- return $variation;
+ return $('<div>', {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(
$('<div>', {class: 'o_comparison'})
.html(formatter(value, field, options) + ' <span>vs</span> ' + 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,140 +247,120 @@ 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 {MouseEvent} ev
+ * @param {OdooEvent} 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) {
- 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 context = _.omit(state.context, function (val, key) {
- return key === 'group_by' || _.str.startsWith(key, 'search_default_');
- });
-
- this.do_action({
- type: 'ir.actions.act_window',
- name: this.title,
- res_model: this.modelName,
- views: this.views,
- view_mode: 'list',
- target: 'current',
- context: context,
- domain: state.domain.concat(rowDomain, colDomain),
- });
+ _onCloseGroup: function (ev) {
+ this.model.closeGroup(ev.data.groupId, ev.data.type);
+ this.update({}, {reload: false});
},
/**
- * 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.
+ * 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
*/
_onClosedHeaderClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
var $target = $(ev.target);
- var id = $target.data('id');
- var header = this.model.getHeader(id);
- var groupbys = header.root.groupbys;
+ var groupId = $target.data('groupId');
+ var type = $target.data('type');
+
+ var group = {
+ rowValues: groupId[0],
+ colValues: groupId[1],
+ type: type
+ };
- if (header.path.length - 1 < groupbys.length) {
+ 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
- .expandHeader(header, groupbys[header.path.length - 1])
+ .expandGroup(this.selectedGroup, groupBy)
.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._renderGroupBySelection(top, left);
}
},
/**
- * This handler is called when the user selects a field in the dropdown menu
+ * This handler is called when the user selects a groupby in the dropdown menu.
*
* @private
* @param {MouseEvent} ev
*/
- _onFieldMenuSelection: function (ev) {
+ _onGroupByMenuSelection: function (ev) {
ev.preventDefault();
var $target = $(ev.target);
if ($target.hasClass('disabled')) {
ev.stopPropagation();
return;
}
- var field = $target.data('field');
+
+ var groupBy = $target.data('field');
var interval = $target.data('interval');
- var header = this.model.getHeader(this.lastHeaderSelected);
if (interval) {
- field = field + ':' + interval;
+ groupBy = groupBy + ':' + interval;
}
+ this.model.addGroupBy(groupBy, this.selectedGroup.type);
this.model
- .expandHeader(header, field)
+ .expandGroup(this.selectedGroup, groupBy)
.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
+ * @param {OdooEvent} 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});
+ _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',
+ name: this.title,
+ res_model: this.modelName,
+ views: this.views,
+ view_mode: 'list',
+ target: 'current',
+ context: context,
+ domain: domain,
+ });
},
/**
- * 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
+ * @param {OdooEvent} 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
+ */
+ 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
*/
- 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);
+ 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];
- });
- }));
+ expandGroup: function (group, groupBy) {
+ var leftDivisors;
+ var rightDivisors;
+
+ if (group.type === 'row') {
+ leftDivisors = [[groupBy]];
+ rightDivisors = sections(this._getGroupBys().colGroupBys);
+ } else {
+ leftDivisors = sections(this._getGroupBys().rowGroupBys);
+ rightDivisors = [[groupBy]];
}
- 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,
- };
+ var divisors = cartesian(leftDivisors, 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);
- }
- });
+ 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) {
- return {
- is_bold: is_bold,
- value: (value === undefined) ? "" : value,
- };
- }
+ // process headers
+ var headers = table.headers;
+ var colGroupHeaderRows;
+ var measureRow = [];
+ var originRow = [];
- function makeMeasure (name) {
+ function processHeader(header) {
+ var inTotalColumn = header.groupId[1].length === 0;
return {
- is_bold: false,
- measure: name
+ title: header.title,
+ width: header.width,
+ height: header.height,
+ is_bold: !!header.measure && inTotalColumn
};
}
- // 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);
}
+ }
+
+ // 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 {
- headers: _.initial(headers),
- measure_row: measureRow,
- rows: rows,
- nbr_measures: measureNbr,
+ 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 {boolean} [params.compare=false]
+ * @param {Object} params.context
+ * @param {Object} params.fields
+ * @param {Array[]} [params.comparisonTimeRange=[]]
* @param {string[]} [params.groupedBy]
- * @param {string[]} [params.colGroupBys]
- * @param {string[]} params.domain
- * @param {string[]} params.rowGroupBys
+ * @param {Array[]} [params.timeRange=[]]
* @param {string[]} params.colGroupBys
+ * @param {Array[]} params.domain
* @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 {Object} params.fields
- * @param {string} params.default_order
+ * @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,
+ sortRows: function (sortedColumn) {
+ var self = this;
+ var colGroupValues = sortedColumn.groupId[1];
+ sortedColumn.originIndexes = sortedColumn.originIndexes || [0];
+ this.data.sortedColumn = sortedColumn;
+
+ 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;
+ };
};
- function _getValue (id1, id2) {
- if ((id1 in cells) && (id2 in cells[id1])) {
- return cells[id1][id2];
- }
- if (id2 in cells) return cells[id2][id1];
- }
- 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];
- }
+ 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 () {
+ /**
+ * 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
+ * @param {Object} groupTree, either this.rowGroupTree or this.colGroupTree
+ * @param {string[]} labels
+ * @param {Array} values
+ */
+ _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: {},
+ };
+ },
+ /**
+ * 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}
+ */
+ _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;
+ },
+ /**
+ * 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}
+ */
+ _getCellValue: function (groupId, measure, originIndexes) {
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];
+ var key = JSON.stringify(groupId);
+ if (!self.measurements[key]) {
+ return;
+ }
+ var values = originIndexes.map(function (originIndex) {
+ return self.measurements[key][originIndex][measure];
});
- 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);
+ if (originIndexes.length > 1) {
+ return computeVariation(values[0], values[1]);
+ } else {
+ return values[0];
}
- 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)],
- });
+ },
+ /**
+ * 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));
}
- result.push(measure_row);
- return result;
+ 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];
},
- _computeRows: function () {
+ /**
+ * Returns the group sanitized labels.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} groupBys
+ * @returns {string[]}
+ */
+ _getGroupLabels: function (group, groupBys) {
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 groupBys.map(function (groupBy) {
+ return self._sanitizeLabel(group[groupBy], groupBy);
});
- 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
+ * 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 @static
- * @param {any} root
- * @param {any} path
- * @returns
+ * @private
+ * @param {Object} group
+ * @param {string[]} rowGroupBy
+ * @param {string[]} colGroupBy
+ * @returns {Promise}
*/
- _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;
+ _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
+ };
+ });
},
- _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];
+ /**
+ * 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]);
+ });
},
/**
- * @param {any} value
- * @param {any} root
- * @param {any} i
- * @param {any} j
- * @param {any} parent
- * @returns {Object}
+ * Returns the leaf counts of each group inside the given tree.
+ *
+ * @private
+ * @param {Object} tree
+ * @returns {Object} keys are group ids
*/
- _getHeader: function (value, root, i, j, parent) {
- var path;
- var total = _t("Total");
- if (parent) {
- path = parent.path.concat(value.slice(i,j));
+ _getLeafCounts: function (tree) {
+ var self = this;
+ var leafCounts = {};
+ var leafCount;
+ if (_.isEmpty(tree.directSubTrees)) {
+ leafCount = 1;
} else {
- path = [total].concat(value.slice(i,j));
+ 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
+ );
}
- return this._findPathInTree(root, path);
+
+ leafCounts[JSON.stringify(tree.root.values)] = leafCount;
+ return leafCounts;
},
/**
- * @private @static
- * @param {any} header
- * @returns {integer}
+ * 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}
*/
- _getHeaderDepth: function (header) {
- var depth = 1;
- this._traverseTree(header, function (hdr) {
- depth = Math.max(depth, hdr.path.length);
- });
- return depth;
+ _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;
+ },
+ {}
+ );
},
- _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++;
+ /**
+ * 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 {width: width, depth: depth};
+
+ return measureRow;
},
- _getHeaderWidth: function (header) {
+ /**
+ * 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;
- if (!header.children.length) return 1;
- if (!header.expanded) return 1;
- return header.children.reduce(function (s, c) {
- return s + self._getHeaderWidth(c);
- }, 0);
+ 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;
+ },
+ []
+ );
},
/**
- * @param {any} value
- * @param {any} field
+ * 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[]}
- */
- _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;
- },
- /**
- * @returns {Promise}
+ * Returns a description of the origins row of the pivot table
+ *
+ * @private
+ * @param {Object[]} columns for which origin cells must be generated
+ * @returns {Object[]}
*/
- _loadData: function () {
+ _getOriginsRow: function (columns) {
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;
- }
- });
+ var sortedColumn = this.data.sortedColumn || {};
+ var originRow = [];
- 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];
- });
- });
- 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];
- });
- }));
- }
+ 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];
- 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]);
+ 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);
+ }
+
});
- 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);
});
+
+ return originRow;
},
/**
- * @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
+ * 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
- _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;
+ // 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);
+ });
}
- 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;
+ 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 header;
+
+ return headers;
},
/**
- * 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'}.
+ * Returns the list of body rows of the pivot table for a given tree.
*
- * 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.
+ * @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
*
- * @param {Object[][]} data
- * @param {Object[][]} comparisonData
- * @param {string[][]} groupBys
- * @returns {Object[][]}
+ * @private
+ * @param {Object} tree, a groupTree
+ * @returns {number}
*/
- _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;
+ _getTreeHeight: function (tree) {
+ var subTreeHeights = _.values(tree.directSubTrees).map(this._getTreeHeight.bind(this));
+ return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1;
},
/**
- * @param {Object[][]} data
+ * @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}
*/
- _prepareData: function (data) {
+ _loadData: function () {
var self = this;
- _.extend(this.data, {
- main_row: {},
- main_col: {},
- headers: {},
- cells: [],
+
+ 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));
});
+ },
+ /**
+ * 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 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;
- }
+ 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;
+ }
- 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++;
- }
+ var groupColValues = group.colValues;
+ var groupColLabels = [];
+ if (groupColValues.length) {
+ root = this._findGroup(this.colGroupTree, groupColValues).root;
+ groupColLabels = root.labels;
}
- main_row_header.other_root = main_col_header;
- main_col_header.other_root = main_row_header;
+ 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;
- main_row_header.groupbys = rowGroupBys;
- main_col_header.groupbys = colGroupBys;
+ if (!(key in self.measurements)) {
+ self.measurements[key] = self.data.origins.map(function () {
+ return self._getMeasurements({});
+ });
+ }
+ self.measurements[key][originIndex] = self._getMeasurements(subGroup);
- this.data.main_row.root = main_row_header;
- this.data.main_col.root = main_col_header;
+ 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;
+ }
+ });
+ });
+
+ if (this.data.sortedColumn) {
+ this.sortRows(this.data.sortedColumn, rowSubTree);
+ }
},
/**
* 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 config = require('web.config');
var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
var field_utils = require('web.field_utils');
-var config = require('web.config');
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 = $('<table>').appendTo($fragment);
- var $thead = $('<thead>').appendTo($table);
- var $tbody = $('<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($('<col>'));
- }
- } else {
- for (i=0; i < nbrCols + 1; i++) {
- $table.prepend($('<col>'));
- }
- }
- 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 = $('<thead>');
+ var $tbody = $('<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 = $('<tr>');
- for (j = 0; j < headers[i].length; j++) {
- cell = headers[i][j];
- if (i == 0 && j == 0) {
- $cell = $('<th>')
- .text(cell.title)
- .attr('rowspan', cell.height + 1)
- .attr('colspan', cell.width);
- } else {
- $cell = $('<th>')
- .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 = $('<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 = $('<tr>');
- 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 = $('<th>')
- .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($('<th>', 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 = $('<tr>');
- $header = $('<td>')
- .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 = $('<td>')
- .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($('<div>', {class: 'o_value'}).html(formatter(
- value[origin],
- measure
- )));
- } else if (origin === 'comparisonData') {
- $cell.append($('<div>', {class: 'o_comparison_value'}).html(formatter(
- value[origin],
- measure
- )));
- } else {
- $cell.append($('<div>', {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);
- }
+ // 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;
+ },
+ {}
+ );
+
+ // 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 = $('<tr>');
+ var paddingLeft = 5 + row.indent * self.paddingLeftHeaderTabWidth;
+ $tr.append($('<th>', {
+ 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',
+ };
+ }
+ 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 {
- for (var l=0; l < 3; l++) {
- $cell = $('<td>')
- .data('id', rows[i].id)
- .toggleClass('o_empty', true)
- .addClass('o_pivot_cell_value text-right');
- $row.append($cell);
- }
+ var formatType = self.fieldWidgets[measure] || measureTypes[measure];
+ var formatter = field_utils.format[formatType];
+ $value = $('<div>', {
+ class: 'o_value',
+ html: formatter(measurement.value, measureField),
+ });
}
+ cellParams.html = $value;
} else {
- $cell = $('<td>')
- .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($('<div>', {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);
-
- $cell.toggleClass('d-none d-md-table-cell', j < length - nbrMeasures);
+ cellParams.class += ' o_empty'
}
- }
- $tbody.append($row);
- }
+ $tr.append($('<td>', 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