odoo.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 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; }, //-------------------------------------------------------------------------- // 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; } 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_col_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 $.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; return; } 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; });
odoo.define('web.GraphModel', function (require) { "use strict"; /** * The graph model is responsible for fetching and processing data from the * server. It basically just do a read_group and format/normalize data. */ var core = require('web.core'); var AbstractModel = require('web.AbstractModel'); var _t = core._t; return AbstractModel.extend({ /** * @override * @param {Widget} parent */ init: function () { this._super.apply(this, arguments); this.chart = null; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * We defend against outside modifications by extending the chart data. It * may be overkill. * * @todo Adding the fields parameter looks wrong. If the renderer or the * controller need the fields, they should get it via their init method. * * @returns {Object} */ get: function () { return _.extend({}, this.chart, { fields: this.fields }); }, /** * Initial loading. * * @todo All the work to fall back on the graph_groupbys keys in the context * should be done by the graphView I think. * * @param {Object} params * @param {string} params.mode one of 'pie', 'bar', 'line * @param {string} params.measure a valid field name * @param {string[]} params.groupBys a list of valid field names * @param {Object} params.context * @param {string[]} params.domain * @param {Object} params.intervalMapping object linking fieldNames with intervals. * this could be useful to simplify the code. For now this parameter is not used. * @returns {Deferred} The deferred does not return a handle, we don't need * to keep track of various entities. */ load: function (params) { var groupBys = params.context.graph_groupbys || params.groupBys; this.initialGroupBys = groupBys; this.fields = params.fields; this.modelName = params.modelName; this.chart = { compare: params.compare, comparisonTimeRange: params.comparisonTimeRange, data: [], groupedBy: params.groupedBy.length ? params.groupedBy : groupBys, // this parameter is not used anywhere for now. // the idea would be to seperate intervals from // fieldnames in groupbys. This could be done // in graph view only or everywhere but this is // a big refactoring. intervalMapping: params.intervalMapping, measure: params.context.graph_measure || params.measure, mode: params.context.graph_mode || params.mode, timeRange: params.timeRange, domain: params.domain, context: params.context, }; return this._loadGraph(); }, /** * Reload data. It is similar to the load function. Note that we ignore the * handle parameter, we always expect our data to be in this.chart object. * * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This * is insane. * * @param {any} handle ignored! * @param {Object} params * @param {string[]} [params.domain] * @param {string[]} [params.groupBy] * @param {string} [params.mode] one of 'bar', 'pie', 'line' * @param {string} [params.measure] a valid field name * @returns {Deferred} */ reload: function (handle, params) { if ('context' in params) { this.chart.context = params.context; this.chart.groupedBy = params.context.graph_groupbys || this.chart.groupedBy; this.chart.measure = params.context.graph_measure || this.chart.measure; this.chart.mode = params.context.graph_mode || this.chart.mode; var timeRangeMenuData = params.context.timeRangeMenuData; if (timeRangeMenuData) { this.chart.timeRange = timeRangeMenuData.timeRange || []; this.chart.comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; this.chart.compare = this.chart.comparisonTimeRange.length > 0; } else { this.chart.timeRange = []; this.chart.comparisonTimeRange = []; this.chart.compare = false; this.chart = _.omit(this.chart, 'comparisonData'); } } if ('domain' in params) { this.chart.domain = params.domain; } if ('groupBy' in params) { this.chart.groupedBy = params.groupBy.length ? params.groupBy : this.initialGroupBys; } if ('intervalMapping' in params) { this.chart.intervalMapping = params.intervalMapping; } if ('measure' in params) { this.chart.measure = params.measure; } if ('mode' in params) { this.chart.mode = params.mode; return $.when(); } return this._loadGraph(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Fetch and process graph data. It is basically a read_group with correct * fields. We have to do some light processing to separate date groups * in the field list, because they can be defined with an aggregation * function, such as my_date:week * * @returns {Deferred} */ _loadGraph: function () { var groupedBy = this.chart.groupedBy; var fields = _.map(groupedBy, function (groupBy) { return groupBy.split(':')[0]; }); if (this.chart.measure !== '__count__') { if (this.fields[this.chart.measure].type === 'many2one') { fields = fields.concat(this.chart.measure + ":count_distinct"); } else { fields = fields.concat(this.chart.measure); } } var context = _.extend({fill_temporal: true}, this.chart.context); var defs = []; defs.push(this._rpc({ model: this.modelName, method: 'read_group', context: context, domain: this.chart.domain.concat(this.chart.timeRange), fields: fields, groupBy: groupedBy, lazy: false, }).then(this._processData.bind(this, 'data'))); if (this.chart.compare) { defs.push(this._rpc({ model: this.modelName, method: 'read_group', context: context, domain: this.chart.domain.concat(this.chart.comparisonTimeRange), fields: fields, groupBy: groupedBy, lazy: false, }).then(this._processData.bind(this, 'comparisonData'))); } return $.when.apply($, defs); }, /** * Since read_group is insane and returns its result on different keys * depending of some input, we have to normalize the result. * The final chart data is added to this.chart object. * * @todo This is not good for race conditions. The processing should get * the object this.chart in argument, or an array or something. We want to * avoid writing on a this.chart object modified by a subsequent read_group * * @param {String} dataKey * @param {any} raw_data result from the read_group */ _processData: function (dataKey, raw_data) { var self = this; var is_count = this.chart.measure === '__count__'; var data_pt, labels; this.chart[dataKey] = []; for (var i = 0; i < raw_data.length; i++) { data_pt = raw_data[i]; labels = _.map(this.chart.groupedBy, function (field) { return self._sanitizeValue(data_pt[field], field); }); var count = data_pt.__count || data_pt[this.chart.groupedBy[0]+'_count'] || 0; var value = is_count ? count : data_pt[this.chart.measure]; 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. value = 1; } this.chart[dataKey].push({ count: count, value: value, labels: labels, }); } }, /** * Helper function (for _processData), turns various values in a usable * string form, that we can display in the interface. * * @param {any} value some value received by the read_group rpc * @param {string} field the name of the corresponding field * @returns {string} */ _sanitizeValue: function (value, field) { var fieldName = field.split(':')[0]; if (value === false && this.fields[fieldName].type !== 'boolean') { return undefined; } if (value instanceof Array) return value[1]; if (field && (this.fields[fieldName].type === 'selection')) { var selected = _.where(this.fields[fieldName].selection, {0: value})[0]; return selected ? selected[1] : value; } return value; }, }); });
odoo.define('web.CalendarModel', function (require) { "use strict"; var AbstractModel = require('web.AbstractModel'); var Context = require('web.Context'); var core = require('web.core'); var fieldUtils = require('web.field_utils'); var session = require('web.session'); var time = require('web.time'); var _t = core._t; var scales = [ 'day', 'week', 'month' ]; function dateToServer (date) { return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss'); } return AbstractModel.extend({ init: function () { this._super.apply(this, arguments); this.end_date = null; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Transform fullcalendar event object to OpenERP Data object */ calendarEventToRecord: function (event) { // Normalize event_end without changing fullcalendars event. var data = {'name': event.title}; var start = event.start.clone(); var end = event.end && event.end.clone(); // Detects allDay events (86400000 = 1 day in ms) if (event.allDay || (end && end.diff(start) % 86400000 === 0)) { event.allDay = true; } // Set end date if not existing if (!end || end.diff(start) < 0) { // undefined or invalid end date if (event.allDay) { end = start.clone(); } else { // in week mode or day mode, convert allday event to event end = start.clone().add(2, 'h'); } } else if (event.allDay) { // For an "allDay", FullCalendar gives the end day as the // next day at midnight (instead of 23h59). end.add(-1, 'days'); } // An "allDay" event without the "all_day" option is not considered // as a 24h day. It's just a part of the day (by default: 7h-19h). if (event.allDay) { if (!this.mapping.all_day) { if (event.r_start) { start.hours(event.r_start.hours()) .minutes(event.r_start.minutes()) .seconds(event.r_start.seconds()) .utc(); end.hours(event.r_end.hours()) .minutes(event.r_end.minutes()) .seconds(event.r_end.seconds()) .utc(); } else { // default hours in the user's timezone start.hours(7).add(-this.getSession().getTZOffset(start), 'minutes'); end.hours(19).add(-this.getSession().getTZOffset(end), 'minutes'); } } } else { start.add(-this.getSession().getTZOffset(start), 'minutes'); end.add(-this.getSession().getTZOffset(end), 'minutes'); } if (this.mapping.all_day) { if (event.record) { data[this.mapping.all_day] = (this.scale !== 'month' && event.allDay) || event.record[this.mapping.all_day] && end.diff(start) < 10 || false; } else { data[this.mapping.all_day] = event.allDay; } } data[this.mapping.date_start] = start; if (this.mapping.date_stop) { data[this.mapping.date_stop] = end; } if (this.mapping.date_delay) { data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; } return data; }, /** * @param {Object} filter * @returns {boolean} */ changeFilter: function (filter) { var Filter = this.data.filters[filter.fieldName]; if (filter.value === 'all') { Filter.all = filter.active; } var f = _.find(Filter.filters, function (f) { return f.value === filter.value; }); if (f) { if (f.active !== filter.active) { f.active = filter.active; } else { return false; } } else if (filter.active) { Filter.filters.push({ value: filter.value, active: true, }); } return true; }, /** * @param {OdooEvent} event */ createRecord: function (event) { var data = this.calendarEventToRecord(event.data.data); for (var k in data) { if (data[k] && data[k]._isAMomentObject) { data[k] = dateToServer(data[k]); } } return this._rpc({ model: this.modelName, method: 'create', args: [data], context: event.data.options.context, }); }, /** * @todo I think this is dead code * * @param {any} ids * @param {any} model * @returns */ deleteRecords: function (ids, model) { return this._rpc({ model: model, method: 'unlink', args: [ids], context: session.user_context, // todo: combine with view context }); }, /** * @override * @returns {Object} */ get: function () { return _.extend({}, this.data, { fields: this.fields }); }, /** * @override * @param {any} params * @returns {Deferred} */ load: function (params) { var self = this; this.modelName = params.modelName; this.fields = params.fields; this.fieldNames = params.fieldNames; this.fieldsInfo = params.fieldsInfo; this.mapping = params.mapping; this.mode = params.mode; // one of month, week or day this.scales = params.scales; // one of month, week or day // Check whether the date field is editable (i.e. if the events can be // dragged and dropped) this.editable = params.editable; this.creatable = params.creatable; // display more button when there are too much event on one day this.eventLimit = params.eventLimit; // fields to display color, e.g.: user_id.partner_id this.fieldColor = params.fieldColor; if (!this.preload_def) { this.preload_def = $.Deferred(); $.when( this._rpc({model: this.modelName, method: 'check_access_rights', args: ["write", false]}), this._rpc({model: this.modelName, method: 'check_access_rights', args: ["create", false]})) .then(function (write, create) { self.write_right = write; self.create_right = create; self.preload_def.resolve(); }); } this.data = { domain: params.domain, context: params.context, // get in arch the filter to display in the sidebar and the field to read filters: params.filters, }; // Use mode attribute in xml file to specify zoom timeline (day,week,month) // by default month. this.setDate(params.initialDate, true); this.setScale(params.mode); _.each(this.data.filters, function (filter) { if (filter.avatar_field && !filter.avatar_model) { filter.avatar_model = self.modelName; } }); return this.preload_def.then(this._loadCalendar.bind(this)); }, next: function () { this.setDate(this.data.target_date.clone().add(1, this.data.scale)); }, prev: function () { this.setDate(this.data.target_date.clone().add(-1, this.data.scale)); }, /** * @todo: this should not work. it ignores the domain/context * * @override * @param {any} _handle ignored * @param {any} _params ignored ? really ? * @returns {Deferred} */ reload: function (_handle, params) { if (params.domain) { this.data.domain = params.domain; } return this._loadCalendar(); }, /** * @param {Moment} start * @param {boolean} highlight */ setDate: function (start, highlight) { this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = start; this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); switch (this.data.scale) { case 'month': this.data.start_date = this.data.start_date.clone().startOf('month').startOf('week'); this.data.end_date = this.data.start_date.clone().add(5, 'week').endOf('week'); break; case 'week': this.data.start_date = this.data.start_date.clone().startOf('week'); this.data.end_date = this.data.end_date.clone().endOf('week'); break; default: this.data.start_date = this.data.start_date.clone().startOf('day'); this.data.end_date = this.data.end_date.clone().endOf('day'); } if (highlight) { this.data.highlight_date = this.data.target_date; } }, setScale: function (scale) { if (!_.contains(scales, scale)) { scale = "week"; } this.data.scale = scale; this.setDate(this.data.target_date); }, today: function () { this.setDate(moment(new Date())); }, toggleFullWidth: function () { var fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') !== 'true'; this.call('local_storage', 'setItem', 'calendar_fullWidth', fullWidth); }, /** * @param {Object} record * @param {integer} record.id * @returns {Deferred} */ updateRecord: function (record) { // Cannot modify actual name yet var data = _.omit(this.calendarEventToRecord(record), 'name'); for (var k in data) { if (data[k] && data[k]._isAMomentObject) { data[k] = dateToServer(data[k]); } } var context = new Context(this.data.context, {from_ui: true}); return this._rpc({ model: this.modelName, method: 'write', args: [[record.id], data], context: context }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Converts this.data.filters into a domain * * @private * @returns {Array} */ _getFilterDomain: function () { // List authorized values for every field // fields with an active 'all' filter are skipped var authorizedValues = {}; var avoidValues = {}; _.each(this.data.filters, function (filter) { // Skip 'all' filters because they do not affect the domain if (filter.all) return; // Loop over subfilters to complete authorizedValues _.each(filter.filters, function (f) { if (filter.write_model) { if (!authorizedValues[filter.fieldName]) authorizedValues[filter.fieldName] = []; if (f.active) { authorizedValues[filter.fieldName].push(f.value); } } else { if (!avoidValues[filter.fieldName]) avoidValues[filter.fieldName] = []; if (!f.active) { avoidValues[filter.fieldName].push(f.value); } } }); }); // Compute the domain var domain = []; for (var field in authorizedValues) { domain.push([field, 'in', authorizedValues[field]]); } for (var field in avoidValues) { domain.push([field, 'not in', avoidValues[field]]); } return domain; }, /** * @returns {Object} */ _getFullCalendarOptions: function () { return { defaultView: (this.mode === "month")? "month" : ((this.mode === "week")? "agendaWeek" : ((this.mode === "day")? "agendaDay" : "agendaWeek")), header: false, selectable: this.creatable && this.create_right, selectHelper: true, editable: this.editable, droppable: true, navLinks: false, eventLimit: this.eventLimit, // allow "more" link when too many events snapMinutes: 15, longPressDelay: 500, eventResizableFromStart: true, weekNumbers: true, weekNumberTitle: _t("W"), allDayText: _t("All day"), views: { week: { columnFormat: 'ddd ' + time.getLangDateFormat(), titleFormat: time.getLangTimeFormat(), } }, monthNames: moment.months(), monthNamesShort: moment.monthsShort(), dayNames: moment.weekdays(), dayNamesShort: moment.weekdaysShort(), firstDay: moment().startOf('week').isoWeekday(), }; }, /** * Return a domain from the date range * * @private * @returns {Array} */ _getRangeDomain: function () { // Build OpenERP Domain to filter object by this.mapping.date_start field // between given start, end dates. var domain = [[this.mapping.date_start, '<=', dateToServer(this.data.end_date)]]; if (this.mapping.date_stop) { domain.push([this.mapping.date_stop, '>=', dateToServer(this.data.start_date)]); } else if (!this.mapping.date_delay) { domain.push([this.mapping.date_start, '>=', dateToServer(this.data.start_date)]); } return domain; }, /** * @returns {Deferred} */ _loadCalendar: function () { var self = this; this.data.fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') === 'true'; this.data.fc_options = this._getFullCalendarOptions(); var defs = _.map(this.data.filters, this._loadFilter.bind(this)); return $.when.apply($, defs).then(function () { return self._rpc({ model: self.modelName, method: 'search_read', context: self.data.context, fields: self.fieldNames, domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()) }) .then(function (events) { self._parseServerData(events); self.data.data = _.map(events, self._recordToCalendarEvent.bind(self)); return $.when( self._loadColors(self.data, self.data.data), self._loadRecordsToFilters(self.data, self.data.data) ); }); }); }, /** * @param {any} element * @param {any} events * @returns {Deferred} */ _loadColors: function (element, events) { if (this.fieldColor) { var fieldName = this.fieldColor; _.each(events, function (event) { var value = event.record[fieldName]; event.color_index = _.isArray(value) ? value[0] : value; }); this.model_color = this.fields[fieldName].relation || element.model; } return $.Deferred().resolve(); }, /** * @param {any} filter * @returns {Deferred} */ _loadFilter: function (filter) { if (!filter.write_model) { return; } var field = this.fields[filter.fieldName]; return this._rpc({ model: filter.write_model, method: 'search_read', domain: [["user_id", "=", session.uid]], fields: [filter.write_field], }) .then(function (res) { var records = _.map(res, function (record) { var _value = record[filter.write_field]; var value = _.isArray(_value) ? _value[0] : _value; var f = _.find(filter.filters, function (f) {return f.value === value;}); var formater = fieldUtils.format[_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type]; return { 'id': record.id, 'value': value, 'label': formater(_value, field), 'active': !f || f.active, }; }); records.sort(function (f1,f2) { return _.string.naturalCmp(f2.label, f1.label); }); // add my profile if (field.relation === 'res.partner' || field.relation === 'res.users') { var value = field.relation === 'res.partner' ? session.partner_id : session.uid; var me = _.find(records, function (record) { return record.value === value; }); if (me) { records.splice(records.indexOf(me), 1); } else { var f = _.find(filter.filters, function (f) {return f.value === value;}); me = { 'value': value, 'label': session.name + _t(" [Me]"), 'active': !f || f.active, }; } records.unshift(me); } // add all selection records.push({ 'value': 'all', 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"), 'active': filter.all, }); filter.filters = records; }); }, /** * @param {any} element * @param {any} events * @returns {Deferred} */ _loadRecordsToFilters: function (element, events) { var self = this; var new_filters = {}; var to_read = {}; _.each(this.data.filters, function (filter, fieldName) { var field = self.fields[fieldName]; new_filters[fieldName] = filter; if (filter.write_model) { if (field.relation === self.model_color) { _.each(filter.filters, function (f) { f.color_index = f.value; }); } return; } _.each(filter.filters, function (filter) { filter.display = !filter.active; }); var fs = []; _.each(events, function (event) { var data = event.record[fieldName]; if (!_.contains(['many2many', 'one2many'], field.type)) { data = [data]; } else { to_read[field.relation] = (to_read[field.relation] || []).concat(data); } _.each(data, function (_value) { var value = _.isArray(_value) ? _value[0] : _value; fs.push({ 'color_index': self.model_color === (field.relation || element.model) ? value : false, 'value': value, 'label': fieldUtils.format[field.type](_value, field), 'avatar_model': field.relation || element.model, }); }); }); _.each(fs, function (f) { var f1 = _.findWhere(filter.filters, f); if (f1) { f1.display = true; } else { f.display = f.active = true; filter.filters.push(f); } }); }); var defs = []; _.each(to_read, function (ids, model) { defs.push(self._rpc({ model: model, method: 'name_get', args: [_.uniq(ids)], }) .then(function (res) { to_read[model] = _.object(res); })); }); return $.when.apply($, defs).then(function () { _.each(self.data.filters, function (filter) { if (filter.write_model) { return; } if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) { _.each(filter.filters, function (f) { f.label = to_read[f.avatar_model][f.value]; }); } }); }); }, /** * parse the server values to javascript framwork * * @param {Object} data the server data to parse */ _parseServerData: function (data) { var self = this; _.each(data, function(event) { _.each(self.fieldNames, function (fieldName) { event[fieldName] = self._parseServerValue(self.fields[fieldName], event[fieldName]); }); }); }, /** * Transform OpenERP event object to fullcalendar event object */ _recordToCalendarEvent: function (evt) { var date_start; var date_stop; var date_delay = evt[this.mapping.date_delay] || 1.0, all_day = this.mapping.all_day ? evt[this.mapping.all_day] : false, the_title = '', attendees = []; if (!all_day) { date_start = evt[this.mapping.date_start].clone(); date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone() : null; } else { date_start = evt[this.mapping.date_start].clone().startOf('day'); date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone().startOf('day') : null; } if (!date_stop && date_delay) { date_stop = date_start.clone().add(date_delay,'hours'); } if (!all_day) { date_start.add(this.getSession().getTZOffset(date_start), 'minutes'); date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes'); } if (this.mapping.all_day && evt[this.mapping.all_day]) { date_stop.add(1, 'days'); } var isAllDay = this.fields[this.mapping.date_start].type === 'date' || this.mapping.all_day && evt[this.mapping.all_day] || false; var r = { 'record': evt, 'start': date_start, 'end': date_stop, 'r_start': date_start, 'r_end': date_stop, 'title': the_title, 'allDay': isAllDay, 'id': evt.id, 'attendees':attendees, }; if (this.mapping.all_day && evt[this.mapping.all_day]) { // r.start = date_start.format('YYYY-MM-DD'); // r.end = date_stop.format('YYYY-MM-DD'); } else if (this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { // In month, FullCalendar gives the end day as the // next day at midnight (instead of 23h59). date_stop.add(1, 'days'); // allow to resize in month mode r.reset_allday = r.allDay; r.allDay = true; r.start = date_start.format('YYYY-MM-DD'); r.end = date_stop.startOf('day').format('YYYY-MM-DD'); } return r; }, }); });
odoo.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 dataComparisonUtils = require('web.dataComparisonUtils'); var core = require('web.core'); var session = require('web.session'); var utils = require('web.utils'); var computeVariation = dataComparisonUtils.computeVariation; 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 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 $.when.apply(null, defs).then(function () { var results = Array.prototype.slice.call(arguments); 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, }; 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); } }); }, /** * Export the current pivot view in a simple JS object. * * @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); function isBold (i, j) { return (i === 0) || ((self.data.main_col.width > 1) && (j >= rows[i].values.length - measureNbr)); } function makeValue (value, is_bold) { return { is_bold: is_bold, value: (value === undefined) ? "" : value, }; } 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(), '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, }; } 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)); } } 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, colGroupBys: this.data.main_col.groupbys, rowGroupBys: this.data.main_row.groupbys, measures: this.data.measures, }; } return { colGroupBys: this.data.main_col.groupbys, 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, 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 {string[]} params.timeRange * @param {string[]} params.comparisonTimeRange * @param {string[]} params.timeRangeDescription * @param {string[]} params.comparisonTimeRangeDescription * @param {string[]} params.compare * @param {Object} params.fields * @param {string} params.default_order * @returns {Deferred} */ load: function (params) { var self = this; this.initialDomain = params.domain; this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys; this.fields = params.fields; this.modelName = params.modelName; this.data = { domain: this.initialDomain, timeRange: params.timeRange || [], timeRangeDescription: params.timeRangeDescription || "", comparisonTimeRange: params.comparisonTimeRange || [], comparisonTimeRangeDescription: params.comparisonTimeRangeDescription || "", compare: params.compare || false, 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.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'); } }); }, /** * @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; var timeRangeMenuData = params.context.timeRangeMenuData; if (timeRangeMenuData) { this.data.timeRange = timeRangeMenuData.timeRange || []; this.data.timeRangeDescription = timeRangeMenuData.timeRangeDescription || ""; this.data.comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; this.data.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || ""; this.data.compare = this.data.comparisonTimeRange.length > 0; } else { this.data.timeRange = []; this.data.timeRangeDescription = ""; this.data.comparisonTimeRange = []; this.data.comparisonTimeRangeDescription = ""; this.data.compare = false; this.data.context = _.omit(this.data.context, 'timeRangeMenuData'); } } 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 * @param {'data'|'comparisonData'|'variation'} [dataType] */ 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]; } 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]; } }, /** * 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} dataPoint * @param {any} fields * @returns {string[]} */ _getValue: function (dataPoint, fields) { var result = []; var value; for (var i = 0; i < fields.length; i++) { value = this._sanitizeValue(dataPoint[fields[i]],fields[i]); result.push(value); } return result; }, /** * @returns {Deferred} */ _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]; }); }); 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]; }); })); } return this._loadDataDropPrevious.add($.when.apply(null, defs)).then(function () { var results = Array.prototype.slice.call(arguments); 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); 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].variation = computeVariation( dataPoints[groupIdentifier][measureName].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; 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++; } } 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; });