flectra/addons/web/static/src/js/views/pivot/pivot_model.js

752 lines
27 KiB
JavaScript

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;
});