flectra.define('web.PivotModel', function (require) { "use strict"; /** * Pivot Model * * The pivot model keeps an in-memory representation of the pivot table that is * displayed on the screen. The exact layout of this representation is not so * simple, because a pivot table is at its core a 2-dimensional object, but * with a 'tree' component: some rows/cols can be expanded so we zoom into the * 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 * * @todo add a full description/specification of the data layout */ var AbstractModel = require('web.AbstractModel'); var concurrency = require('web.concurrency'); var core = require('web.core'); var session = require('web.session'); var utils = require('web.utils'); var _t = core._t; var PivotModel = AbstractModel.extend({ /** * @override * @param {Object} params */ init: function () { this._super.apply(this, arguments); this.numbering = {}; this.data = null; this._loadDataDropPrevious = new concurrency.DropPrevious(); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Close a header. This method is actually synchronous, but returns a * deferred. * * @param {any} headerID * @returns {Deferred} */ 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); }, /** * @returns {Deferred} */ expandAll: function () { return this._loadData(); }, /** * Expand (open up) a given header, be it a row or a column. * * @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 */ expandHeader: function (header, field) { var self = this; var other_root = header.root.other_root; var other_groupbys = header.root.other_root.groupbys; var fields = [].concat(field, other_groupbys, this.data.measures); var groupbys = []; for (var i = 0; i <= other_groupbys.length; i++) { groupbys.push([field].concat(other_groupbys.slice(0,i))); } return $.when.apply(null, groupbys.map(function (groupBy) { return self._rpc({ model: self.modelName, method: 'read_group', context: self.data.context, domain: header.domain.length ? header.domain : self.data.domain, fields: _.map(fields, function (field) { return field.split(':')[0]; }), groupBy: groupBy, lazy: false, }); })).then(function () { var data = Array.prototype.slice.call(arguments); var datapt, attrs, j, l, row, col, cell_value, groupBys; for (i = 0; i < data.length; i++) { for (j = 0; j < data[i].length; j++){ datapt = data[i][j]; groupBys = [field].concat(other_groupbys.slice(0,i)); attrs = { value: self._getValue(datapt, groupBys), domain: datapt.__domain || [], length: datapt.__count, }; if (i === 0) { row = self._makeHeader(attrs.value, attrs.domain, 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++) { cell_value[self.data.measures[l]] = datapt[self.data.measures[l]]; } // 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); } }); }, /** * Export the current pivot view in a simple JS object. * * @returns {Object} */ exportData: function () { var measureNbr = this.data.measures.length; var headers = this._computeHeaders(); var measureRow = measureNbr >= 1 ? _.last(headers) : []; var rows = this._computeRows(); var i, j, value; headers[0].splice(0,1); // process measureRow for (i = 0; i < measureRow.length; i++) { measureRow[i].measure = this.fields[measureRow[i].measure].string; } // process all rows for (i =0, j, value; i < rows.length; i++) { for (j = 0; j < rows[i].values.length; j++) { value = rows[i].values[j]; rows[i].values[j] = { is_bold: (i === 0) || ((this.data.main_col.width > 1) && (j >= rows[i].values.length - measureNbr)), value: (value === undefined) ? "" : value, }; } } return { headers: _.initial(headers), measure_row: measureRow, rows: rows, nbr_measures: measureNbr, }; }, /** * Swap the 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; // we need to update the record metadata: row and col groupBys temp = this.data.groupedBy; this.data.groupedBy = this.data.colGroupBys; this.data.colGroupBys = temp; }, /** * @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}; } return { colGroupBys: this.data.main_col.groupbys, context: this.data.context, domain: this.data.domain, fields: this.fields, headers: !isRaw && this._computeHeaders(), has_data: true, mainColWidth: this.data.main_col.width, measures: this.data.measures, rows: !isRaw && this._computeRows(), rowGroupBys: this.data.main_row.groupbys, sortedColumn: this.data.sorted_column, }; }, /** * @param {string} id * @returns {object} */ getHeader: function (id) { return this.data.headers[id]; }, /** * @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 {Object} params.fields * @returns {Deferred} */ load: function (params) { this.initialDomain = params.domain; this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys; this.fields = params.fields; this.modelName = params.modelName; this.data = { domain: params.domain, context: _.extend({}, session.user_context, params.context), groupedBy: params.groupedBy, colGroupBys: params.context.pivot_column_groupby || params.colGroupBys, measures: this._processMeasures(params.context.pivot_measures) || params.measures, sorted_column: {}, }; this.defaultGroupedBy = params.groupedBy; return this._loadData(); }, /** * @override * @param {any} handle this parameter is ignored * @param {Object} params * @returns {Deferred} */ reload: function (handle, params) { var self = this; if ('context' in params) { this.data.context = params.context; this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy; } if ('domain' in params) { this.data.domain = params.domain; } else { this.data.domain = this.initialDomain; } if ('groupBy' in params) { this.data.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy; } if (!this.data.has_data) { return this._loadData(); } var old_row_root = this.data.main_row.root; var old_col_root = this.data.main_col.root; return this._loadData().then(function () { var new_groupby_length; 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); new_groupby_length = self._getHeaderDepth(self.data.main_row.root) - 1; self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length); } self._updateTree(old_col_root, self.data.main_col.root); new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1; self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length); }); }, /** * 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 */ sortRows: function (col_id, measure, descending) { var cells = this.data.cells; this._traverseTree(this.data.main_row.root, function (header) { header.children.sort(compare); }); this.data.sorted_column = { id: col_id, measure: measure, order: descending ? 'desc' : 'asc', }; function _getValue (id1, id2) { if ((id1 in cells) && (id2 in cells[id1])) { return cells[id1][id2]; } if (id2 in cells) return cells[id2][id1]; } function compare (row1, row2) { var values1 = _getValue(row1.id, col_id), values2 = _getValue(row2.id, col_id), value1 = values1 ? values1[measure] : 0, value2 = values2 ? values2[measure] : 0; return descending ? value1 - value2 : value2 - value1; } }, /** * Toggle the active state for a given measure, then reload the data. * * @param {string} field * @returns {Deferred} */ toggleMeasure: function (field) { if (_.contains(this.data.measures, field)) { this.data.measures = _.without(this.data.measures, field); // in this case, we already have all data in memory, no need to // actually reload a lesser amount of information return $.when(); } else { this.data.measures.push(field); } return this._loadData(); }, //-------------------------------------------------------------------------- // 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 * * @private @static * @param {any} root * @param {any} path * @returns */ _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]; }, /** * @param {any} value * @param {any} root * @param {any} i * @param {any} j * @param {any} parent * @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); }, /** * @private @static * @param {any} header * @returns {integer} */ _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) { 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); }, /** * @param {any} value * @param {any} field * @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]; numbers[id] = numbers[id] || _.size(numbers) + 1; return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); }, /** * @param {any} datapt * @param {any} fields * @returns {string[]} */ _getValue: function (datapt, fields) { var result = []; var value; for (var i = 0; i < fields.length; i++) { value = this._sanitizeValue(datapt[fields[i]],fields[i]); result.push(value); } return result; }, /** * @returns {Deferred} */ _loadData: function () { var self = this; var groupBys = []; var rowGroupBys = this.data.groupedBy.length ? this.data.groupedBy : this.initialRowGroupBys; var colGroupBys = this.data.colGroupBys; var fields = [].concat(rowGroupBys, colGroupBys, this.data.measures); 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))); } } return this._loadDataDropPrevious.add($.when.apply(null, groupBys.map(function (groupBy) { return self._rpc({ model: self.modelName, method: 'read_group', context: self.data.context, domain: self.data.domain, fields: _.map(fields, function (field) { return field.split(':')[0]; }), groupBy: groupBy, lazy: false, }); }))).then(function () { var data = Array.prototype.slice.call(arguments); if (data[0][0].__count === 0) { self.data.has_data = false; } self._prepareData(data); }); }, /** * @param {any} value * @param {any} domain * @param {any} root * @param {any} i * @param {any} j * @param {any} parent_header * @returns {Object} */ _makeHeader: function (value, domain, 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 || [], 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; }, /** * @param {Object} data */ _prepareData: function (data) { var self = this; _.extend(self.data, { main_row: {}, main_col: {}, headers: {}, cells: [], }); var index = 0; var rowGroupBys = this.data.groupedBy.length ? this.data.groupedBy : this.initialRowGroupBys; var colGroupBys = this.data.colGroupBys; var datapt, 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++) { datapt = data[index][k]; groupBys = rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j)); attrs = { value: self._getValue(datapt, groupBys), domain: datapt.__domain || [], length: datapt.__count, }; if (j === 0) { row = this._makeHeader(attrs.value, attrs.domain, 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, 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; 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++) { cell_value[this.data.measures[m]] = datapt[this.data.measures[m]]; } this.data.cells[row.id][col.id] = cell_value; } index++; } } this.data.main_row.groupbys = rowGroupBys; this.data.main_col.groupbys = colGroupBys; 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), * the virtual field used to display the number of records was named * __count__, whereas __count is actually the one used in xml. So * basically, activating a filter specifying __count as measures crashed. * Unfortunately, as __count__ was used in the JS, all filters saved as * favorite at that time were saved with __count__, and not __count. * So in order the make them still work with the new implementation, we * handle both __count__ and __count. * * This function replaces in the given array of measures occurences of * '__count__' by '__count'. * * @param {Array[string] || undefined} measures * @return {Array[string] || undefined} */ _processMeasures: function (measures) { if (measures) { return _.map(measures, function (measure) { return measure === '__count__' ? '__count' : measure; }); } }, /** * Format a value to a usable string, for the renderer to display. * * @param {any} value * @param {any} field * @returns {string} */ _sanitizeValue: function (value, field) { if (value === false) { return _t("Undefined"); } if (value instanceof Array) { return this._getNumberedValue(value, field); } if (field && this.fields[field] && (this.fields[field].type === 'selection')) { var selected = _.where(this.fields[field].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 */ _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); } }, /** * @param {Object} old_tree * @param {Object} new_tree */ _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; } } if (tree) { this._updateTree(tree, new_tree.children[i]); } else { new_tree.children[i].expanded = false; new_tree.children[i].children = []; } } }, }); return PivotModel; });