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