[REF] *: pivot view refactoring
The main aim of the present refactoring is twofold: - simplify the code that was largely complexified at the time of the introduction of comparisons in the pivot view (Time Ranges menu), - factorize the method expandHeader and _loadData that were too much complex. Along the way, the pivot model structure has been simplified and many tests have been added. Performances in mobile mode have been improved by avoiding unecessary operations due to useless column groupbys in that mode. It should now be easier to understand and modify the pivot view in future. Co-Authored-By: Aaron Bohy <aab@odoo.com> closes odoo/odoo#33615 Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
This commit is contained in:
co-authored by
Aaron Bohy
parent
1ef258f6cb
commit
49a97305b1
@@ -25,7 +25,7 @@ QUnit.module('Dashboard', {
|
||||
display_name: {string: "Displayed name", type: "char", searchable: true},
|
||||
foo: {string: "Foo", type: "char", default: "My little Foo Value", searchable: true},
|
||||
bar: {string: "Bar", type: "boolean"},
|
||||
int_field: {string: "Integer field", type: "integer"},
|
||||
int_field: {string: "Integer field", type: "integer", group_operator: 'sum'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
|
||||
@@ -19,55 +19,71 @@ class TableExporter(http.Controller):
|
||||
@http.route('/web/pivot/export_xls', type='http', auth="user")
|
||||
def export_xls(self, data, token):
|
||||
jdata = json.loads(data)
|
||||
nbr_measures = jdata['nbr_measures']
|
||||
workbook = xlwt.Workbook()
|
||||
worksheet = workbook.add_sheet(jdata['title'])
|
||||
header_bold = xlwt.easyxf("font: bold on; pattern: pattern solid, fore_colour gray25;")
|
||||
header_plain = xlwt.easyxf("pattern: pattern solid, fore_colour gray25;")
|
||||
bold = xlwt.easyxf("font: bold on;")
|
||||
|
||||
# Step 1: writing headers
|
||||
headers = jdata['headers']
|
||||
measure_count = jdata['measure_count']
|
||||
origin_count = jdata['origin_count']
|
||||
|
||||
# Step 1: writing col group headers
|
||||
col_group_headers = jdata['col_group_headers']
|
||||
|
||||
# x,y: current coordinates
|
||||
# carry: queue containing cell information when a cell has a >= 2 height
|
||||
# and the drawing code needs to add empty cells below
|
||||
x, y, carry = 1, 0, deque()
|
||||
for i, header_row in enumerate(headers):
|
||||
for i, header_row in enumerate(col_group_headers):
|
||||
worksheet.write(i, 0, '', header_plain)
|
||||
for header in header_row:
|
||||
while (carry and carry[0]['x'] == x):
|
||||
cell = carry.popleft()
|
||||
for i in range(nbr_measures):
|
||||
worksheet.write(y, x+i, '', header_plain)
|
||||
for j in range(measure_count * (2 * origin_count - 1)):
|
||||
worksheet.write(y, x+j, '', header_plain)
|
||||
if cell['height'] > 1:
|
||||
carry.append({'x': x, 'height': cell['height'] - 1})
|
||||
x = x + nbr_measures
|
||||
style = header_plain if 'expanded' in header else header_bold
|
||||
for i in range(header['width']):
|
||||
worksheet.write(y, x + i, header['title'] if i == 0 else '', style)
|
||||
x = x + measure_count * (2 * origin_count - 1)
|
||||
for j in range(header['width']):
|
||||
worksheet.write(y, x + j, header['title'] if j == 0 else '', header_plain)
|
||||
if header['height'] > 1:
|
||||
carry.append({'x': x, 'height': header['height'] - 1})
|
||||
x = x + header['width']
|
||||
while (carry and carry[0]['x'] == x):
|
||||
cell = carry.popleft()
|
||||
for i in range(nbr_measures):
|
||||
worksheet.write(y, x+i, '', header_plain)
|
||||
for j in range(measure_count * (2 * origin_count - 1)):
|
||||
worksheet.write(y, x+j, '', header_plain)
|
||||
if cell['height'] > 1:
|
||||
carry.append({'x': x, 'height': cell['height'] - 1})
|
||||
x = x + nbr_measures
|
||||
x = x + measure_count * (2 * origin_count - 1)
|
||||
x, y = 1, y + 1
|
||||
|
||||
# Step 2: measure row
|
||||
if nbr_measures > 1:
|
||||
# Step 2: writing measure headers
|
||||
measure_headers = jdata['measure_headers']
|
||||
|
||||
if measure_headers:
|
||||
worksheet.write(y, 0, '', header_plain)
|
||||
for measure in jdata['measure_row']:
|
||||
for measure in measure_headers:
|
||||
style = header_bold if measure['is_bold'] else header_plain
|
||||
worksheet.write(y, x, measure['measure'], style)
|
||||
worksheet.write(y, x, measure['title'], style)
|
||||
for i in range(1, 2 * origin_count - 1):
|
||||
worksheet.write(y, x+i, '', header_plain)
|
||||
x = x + (2 * origin_count - 1)
|
||||
x, y = 1, y + 1
|
||||
|
||||
# Step 3: writing origin headers
|
||||
origin_headers = jdata['origin_headers']
|
||||
|
||||
if origin_headers:
|
||||
worksheet.write(y, 0, '', header_plain)
|
||||
for origin in origin_headers:
|
||||
style = header_bold if origin['is_bold'] else header_plain
|
||||
worksheet.write(y, x, origin['title'], style)
|
||||
x = x + 1
|
||||
y = y + 1
|
||||
|
||||
# Step 3: writing data
|
||||
# Step 4: writing data
|
||||
x = 0
|
||||
for row in jdata['rows']:
|
||||
worksheet.write(y, x, row['indent'] * ' ' + ustr(row['title']), header_plain)
|
||||
|
||||
@@ -68,67 +68,60 @@ var DateClasses = Class.extend({
|
||||
/**
|
||||
* @param {Number} value
|
||||
* @param {Number} comparisonValue
|
||||
* @returns {Object}
|
||||
* @returns {Number}
|
||||
*/
|
||||
function computeVariation (value, comparisonValue) {
|
||||
var magnitude;
|
||||
var signClass;
|
||||
|
||||
if (!isNaN(value) && !isNaN(comparisonValue)) {
|
||||
if (comparisonValue === 0) {
|
||||
if (value === 0) {
|
||||
magnitude = 0;
|
||||
} else if (value > 0){
|
||||
magnitude = 1;
|
||||
} else {
|
||||
magnitude = -1;
|
||||
}
|
||||
} else {
|
||||
magnitude = (value - comparisonValue) / Math.abs(comparisonValue);
|
||||
}
|
||||
if (magnitude > 0) {
|
||||
signClass = ' o_positive';
|
||||
} else if (magnitude < 0) {
|
||||
signClass = ' o_negative';
|
||||
} else if (magnitude === 0) {
|
||||
signClass = ' o_null';
|
||||
}
|
||||
return {magnitude: magnitude, signClass: signClass};
|
||||
} else {
|
||||
return {magnitude: NaN};
|
||||
function computeVariation(value, comparisonValue) {
|
||||
if (isNaN(value) || isNaN(comparisonValue)) {
|
||||
return NaN;
|
||||
}
|
||||
if (comparisonValue === 0) {
|
||||
if (value === 0) {
|
||||
return 0;
|
||||
} else if (value > 0) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return (value - comparisonValue) / Math.abs(comparisonValue);
|
||||
}
|
||||
/**
|
||||
* @param {Object} variation
|
||||
* @param {Number} variation
|
||||
* @param {Object} field
|
||||
* @param {Object} options
|
||||
* @returns {Object}
|
||||
*/
|
||||
function renderVariation (variation, field, options) {
|
||||
var $variation;
|
||||
if (!isNaN(variation.magnitude)) {
|
||||
$variation = $('<div>', {class: 'o_variation' + variation.signClass}).html(
|
||||
fieldUtils.format.percentage(variation.magnitude, field, options
|
||||
));
|
||||
function renderVariation(variation, field, options) {
|
||||
var className = 'o_variation';
|
||||
var value;
|
||||
if (!isNaN(variation)) {
|
||||
if (variation > 0) {
|
||||
className += ' o_positive';
|
||||
} else if (variation < 0) {
|
||||
className += ' o_negative';
|
||||
} else {
|
||||
className += ' o_null';
|
||||
}
|
||||
value = fieldUtils.format.percentage(variation, field, options);
|
||||
} else {
|
||||
$variation = $('<div>', {class: 'o_variation'}).html('-');
|
||||
value = '-';
|
||||
}
|
||||
return $variation;
|
||||
return $('<div>', {class: className, html: value});
|
||||
}
|
||||
/**
|
||||
* @param {JQuery} $node
|
||||
* @param {Number} value
|
||||
* @param {Number} comparisonValue
|
||||
* @param {Object} variation (with key 'magnitude' and 'signClass')
|
||||
* @param {Number} variation
|
||||
* @param {function} formatter
|
||||
* @param {Object} field
|
||||
* @param {Object} options
|
||||
* @returns {Object}
|
||||
*/
|
||||
function renderComparison ($node, value, comparisonValue, variation, formatter, field, options) {
|
||||
function renderComparison($node, value, comparisonValue, variation, formatter, field, options) {
|
||||
var $variation = renderVariation(variation, field, options);
|
||||
$node.append($variation);
|
||||
if (!isNaN(variation.magnitude)) {
|
||||
if (!isNaN(variation)) {
|
||||
$node.append(
|
||||
$('<div>', {class: 'o_comparison'})
|
||||
.html(formatter(value, field, options) + ' <span>vs</span> ' + formatter(comparisonValue, field, options))
|
||||
@@ -139,7 +132,8 @@ function renderComparison ($node, value, comparisonValue, variation, formatter,
|
||||
return {
|
||||
computeVariation: computeVariation,
|
||||
DateClasses: DateClasses,
|
||||
renderComparison: renderComparison
|
||||
renderComparison: renderComparison,
|
||||
renderVariation: renderVariation,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ function _cartesian() {
|
||||
* Returns the product of any number n of arrays.
|
||||
* The internal structures of their elements is preserved.
|
||||
* For n = 1, no brackets are put around the unique parameter elements
|
||||
* For n = 0, the singleton [undefined] is returned since it is the unit
|
||||
* For n = 0, [undefined] is returned since it is the unit
|
||||
* of the cartesian product (up to isomorphism).
|
||||
*
|
||||
* @returns {Array}
|
||||
|
||||
@@ -13,6 +13,7 @@ odoo.define('web.PivotController', function (require) {
|
||||
*/
|
||||
|
||||
var AbstractController = require('web.AbstractController');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var framework = require('web.framework');
|
||||
var session = require('web.session');
|
||||
@@ -23,19 +24,19 @@ var QWeb = core.qweb;
|
||||
var PivotController = AbstractController.extend({
|
||||
contentTemplate: 'PivotView',
|
||||
events: {
|
||||
'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
|
||||
'click .o_pivot_field_menu a': '_onGroupByMenuSelection',
|
||||
'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
|
||||
'click .o_pivot_field_menu a': '_onFieldMenuSelection',
|
||||
'click td': '_onCellClick',
|
||||
'click .o_pivot_measure_row': '_onMeasureRowClick',
|
||||
},
|
||||
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
|
||||
close_group: '_onCloseGroup',
|
||||
open_view: '_onOpenView',
|
||||
sort_rows: '_onSortRows',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
* @param {Object} params.groupableFields a map from field name to field
|
||||
* @param {Object} params.groupableFields a map from field names to field
|
||||
* props
|
||||
* @param {boolean} params.enableLinking configure the pivot view to allow
|
||||
* opening a list view by clicking on a cell with some data.
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
@@ -43,18 +44,17 @@ var PivotController = AbstractController.extend({
|
||||
this.measures = params.measures;
|
||||
this.groupableFields = params.groupableFields;
|
||||
this.title = params.title;
|
||||
this.enableLinking = params.enableLinking;
|
||||
// views to use in the action triggered when a data cell is clicked
|
||||
this.views = params.views;
|
||||
this.lastHeaderSelected = null;
|
||||
this.groupSelected = null;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$fieldSelection = this.$('.o_field_selection');
|
||||
this.$groupBySelection = this.$('.o_field_selection');
|
||||
core.bus.on('click', this, function () {
|
||||
this.$fieldSelection.empty();
|
||||
this.$groupBySelection.empty();
|
||||
});
|
||||
return this._super();
|
||||
},
|
||||
@@ -82,7 +82,7 @@ var PivotController = AbstractController.extend({
|
||||
* @returns {Object}
|
||||
*/
|
||||
getOwnedQueryParams: function () {
|
||||
var state = this.model.get();
|
||||
var state = this.model.get({raw: true});
|
||||
return {
|
||||
context: {
|
||||
pivot_measures: state.measures,
|
||||
@@ -102,7 +102,11 @@ var PivotController = AbstractController.extend({
|
||||
*/
|
||||
renderButtons: function ($node) {
|
||||
if ($node) {
|
||||
var context = {measures: _.sortBy(_.pairs(_.omit(this.measures, '__count')), function (x) { return x[1].string.toLowerCase(); })};
|
||||
var context = {
|
||||
measures: _.sortBy(_.pairs(_.omit(this.measures, '__count')), function (x) {
|
||||
return x[1].string.toLowerCase();
|
||||
}),
|
||||
};
|
||||
this.$buttons = $(QWeb.render('PivotView.buttons', context));
|
||||
this.$buttons.click(this._onButtonClick.bind(this));
|
||||
this.$buttons.find('button').tooltip();
|
||||
@@ -111,6 +115,16 @@ var PivotController = AbstractController.extend({
|
||||
this._updateButtons();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
*/
|
||||
update: function (params) {
|
||||
if (config.device.isMobile && params.context) {
|
||||
params.context.pivot_column_groupby = [];
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
@@ -119,18 +133,19 @@ var PivotController = AbstractController.extend({
|
||||
/**
|
||||
* Export the current pivot table data in a xls file. For this, we have to
|
||||
* serialize the current state, then call the server /web/pivot/export_xls.
|
||||
* Force a reload before exporting to ensure to export up-to-date data.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_downloadTable: function () {
|
||||
var table = this.model.exportData();
|
||||
if(table.measure_row.length + 1 > 256) {
|
||||
var self = this;
|
||||
if (self.model.getTableWidth() > 256) {
|
||||
this.call('crash_manager', 'show_message', _t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures."));
|
||||
framework.unblockUI();
|
||||
return;
|
||||
}
|
||||
framework.blockUI();
|
||||
table.title = this.title;
|
||||
var table = self.model.exportData();
|
||||
table.title = self.title;
|
||||
session.get_file({
|
||||
url: '/web/pivot/export_xls',
|
||||
data: {data: JSON.stringify(table)},
|
||||
@@ -146,30 +161,32 @@ var PivotController = AbstractController.extend({
|
||||
* @param {number} top top coordinate where we have to render the menu
|
||||
* @param {number} left left coordinate for the menu
|
||||
*/
|
||||
_renderFieldSelection: function (top, left) {
|
||||
_renderGroupBySelection: function (top, left) {
|
||||
var state = this.model.get({raw: true});
|
||||
var groupedFields = state.rowGroupBys
|
||||
.concat(state.colGroupBys)
|
||||
.map(function (f) { return f.split(':')[0];});
|
||||
.map(function (f) {
|
||||
return f.split(':')[0];
|
||||
});
|
||||
|
||||
var fields = _.chain(this.groupableFields)
|
||||
.pairs()
|
||||
.sortBy(function (f) { return f[1].string; })
|
||||
.sortBy(function (f) {
|
||||
return f[1].string;
|
||||
})
|
||||
.map(function (f) {
|
||||
return [f[0], f[1], _.contains(groupedFields, f[0])];
|
||||
})
|
||||
.value();
|
||||
|
||||
this.$fieldSelection.html(QWeb.render('PivotView.FieldSelection', {
|
||||
this.$groupBySelection.html(QWeb.render('PivotView.GroupBySelection', {
|
||||
fields: fields
|
||||
}));
|
||||
|
||||
var cssProps = {top: top};
|
||||
cssProps[_t.database.parameters.direction === 'rtl' ? 'right' : 'left'] =
|
||||
_t.database.parameters.direction === 'rtl' ? this.$el.width() - left : left;
|
||||
this.$fieldSelection.find('.dropdown-menu').first()
|
||||
.css(cssProps)
|
||||
.addClass('show');
|
||||
var isRTL = _t.database.parameters.direction === 'rtl';
|
||||
cssProps[isRTL ? 'right' : 'left'] = isRTL ? this.$el.width() - left : left;
|
||||
this.$groupBySelection.find('.dropdown-menu').first().css(cssProps).addClass('show');
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -179,6 +196,7 @@ var PivotController = AbstractController.extend({
|
||||
return this.renderer.appendTo(this.$('.o_pivot'));
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_update: function () {
|
||||
@@ -199,8 +217,13 @@ var PivotController = AbstractController.extend({
|
||||
self.$buttons.find('.dropdown-item[data-field="' + name + '"]')
|
||||
.toggleClass('selected', isSelected);
|
||||
});
|
||||
var noDataDisplayed = !state.has_data || !state.measures.length;
|
||||
this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed);
|
||||
var noDataDisplayed = !state.hasData || !state.measures.length;
|
||||
var $flipButton = this.$buttons.find('.o_pivot_flip_button');
|
||||
if (config.device.isMobile) {
|
||||
$flipButton.toggleClass('o_hidden', true);
|
||||
} else {
|
||||
$flipButton.prop('disabled', noDataDisplayed);
|
||||
}
|
||||
this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed);
|
||||
this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed);
|
||||
},
|
||||
@@ -224,51 +247,102 @@ var PivotController = AbstractController.extend({
|
||||
this.update({}, {reload: false});
|
||||
}
|
||||
if ($target.hasClass('o_pivot_expand_button')) {
|
||||
this.model
|
||||
.expandAll()
|
||||
.then(this.update.bind(this, {}, {reload: false}));
|
||||
this.model.expandAll().then(this.update.bind(this, {}, {reload: false}));
|
||||
}
|
||||
if ($target.parents('.o_pivot_measures_list').length) {
|
||||
var field = $target.data('field');
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.model
|
||||
.toggleMeasure(field)
|
||||
.then(this.update.bind(this, {}, {reload: false}));
|
||||
var field = $target.data('field');
|
||||
this.model.toggleMeasure(field).then(this.update.bind(this, {}, {reload: false}));
|
||||
}
|
||||
if ($target.hasClass('o_pivot_download')) {
|
||||
this._downloadTable();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When the user clicks on a cell, and the view is configured to allow
|
||||
* 'linking' (with enableLinking), we want to open a list view with the
|
||||
* corresponding record.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onCloseGroup: function (ev) {
|
||||
this.model.closeGroup(ev.data.groupId, ev.data.type);
|
||||
this.update({}, {reload: false});
|
||||
},
|
||||
/**
|
||||
* When we click on a closed row (col) header, we either want to open the
|
||||
* dropdown menu to select a new field to add to rowGroupBys (resp. colGroupBys),
|
||||
* or we want to open the clicked header, if rowGroupBys (resp. colGroupBys)
|
||||
* has length strictly greater than header
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onCellClick: function (ev) {
|
||||
var $target = $(ev.currentTarget);
|
||||
if ($target.hasClass('o_pivot_header_cell_closed') ||
|
||||
$target.hasClass('o_pivot_header_cell_opened') ||
|
||||
$target.hasClass('o_empty') ||
|
||||
$target.data('type') === 'variation' ||
|
||||
!this.enableLinking) {
|
||||
_onClosedHeaderClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
var $target = $(ev.target);
|
||||
var groupId = $target.data('groupId');
|
||||
var type = $target.data('type');
|
||||
|
||||
var group = {
|
||||
rowValues: groupId[0],
|
||||
colValues: groupId[1],
|
||||
type: type
|
||||
};
|
||||
|
||||
var state = this.model.get({raw: true});
|
||||
|
||||
var groupValues = type === 'row' ? groupId[0] : groupId[1];
|
||||
var groupBys = type === 'row' ?
|
||||
state.rowGroupBys :
|
||||
state.colGroupBys;
|
||||
|
||||
this.selectedGroup = group;
|
||||
if (groupValues.length < groupBys.length) {
|
||||
var groupBy = groupBys[groupValues.length];
|
||||
this.model
|
||||
.expandGroup(this.selectedGroup, groupBy)
|
||||
.then(this.update.bind(this, {}, {reload: false}));
|
||||
} else {
|
||||
var position = $target.position();
|
||||
var top = position.top + $target.height();
|
||||
var left = position.left + ev.offsetX;
|
||||
this._renderGroupBySelection(top, left);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This handler is called when the user selects a groupby in the dropdown menu.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onGroupByMenuSelection: function (ev) {
|
||||
ev.preventDefault();
|
||||
var $target = $(ev.target);
|
||||
if ($target.hasClass('disabled')) {
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
var state = this.model.get(this.handle);
|
||||
var colDomain, rowDomain;
|
||||
if ($target.data('type') === 'comparisonData') {
|
||||
colDomain = this.model.getHeader($target.data('col_id')).comparisonDomain || [];
|
||||
rowDomain = this.model.getHeader($target.data('id')).comparisonDomain || [];
|
||||
} else {
|
||||
colDomain = this.model.getHeader($target.data('col_id')).domain || [];
|
||||
rowDomain = this.model.getHeader($target.data('id')).domain || [];
|
||||
|
||||
var groupBy = $target.data('field');
|
||||
var interval = $target.data('interval');
|
||||
if (interval) {
|
||||
groupBy = groupBy + ':' + interval;
|
||||
}
|
||||
var context = _.omit(state.context, function (val, key) {
|
||||
return key === 'group_by' || _.str.startsWith(key, 'search_default_');
|
||||
});
|
||||
this.model.addGroupBy(groupBy, this.selectedGroup.type);
|
||||
this.model
|
||||
.expandGroup(this.selectedGroup, groupBy)
|
||||
.then(this.update.bind(this, {}, {reload: false}));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onOpenView: function (ev) {
|
||||
var context = ev.data.context;
|
||||
var group = ev.data.group;
|
||||
var domain = this.model._getGroupDomain(group);
|
||||
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
@@ -278,86 +352,15 @@ var PivotController = AbstractController.extend({
|
||||
view_mode: 'list',
|
||||
target: 'current',
|
||||
context: context,
|
||||
domain: state.domain.concat(rowDomain, colDomain),
|
||||
domain: domain,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* When we click on a closed header cell, we either want to open the
|
||||
* dropdown menu to select a new groupby level, or we want to open the
|
||||
* clicked header, if a corresponging groupby has already been selected.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onClosedHeaderClick: function (ev) {
|
||||
var $target = $(ev.target);
|
||||
var id = $target.data('id');
|
||||
var header = this.model.getHeader(id);
|
||||
var groupbys = header.root.groupbys;
|
||||
|
||||
if (header.path.length - 1 < groupbys.length) {
|
||||
this.model
|
||||
.expandHeader(header, groupbys[header.path.length - 1])
|
||||
.then(this.update.bind(this, {}, {reload: false}));
|
||||
} else {
|
||||
this.lastHeaderSelected = id;
|
||||
var position = $target.position();
|
||||
var top = position.top + $target.height();
|
||||
var left = position.left + ev.offsetX;
|
||||
this._renderFieldSelection(top, left);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This handler is called when the user selects a field in the dropdown menu
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onFieldMenuSelection: function (ev) {
|
||||
ev.preventDefault();
|
||||
var $target = $(ev.target);
|
||||
if ($target.hasClass('disabled')) {
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
var field = $target.data('field');
|
||||
var interval = $target.data('interval');
|
||||
var header = this.model.getHeader(this.lastHeaderSelected);
|
||||
if (interval) {
|
||||
field = field + ':' + interval;
|
||||
}
|
||||
this.model
|
||||
.expandHeader(header, field)
|
||||
.then(this.update.bind(this, {}, {reload: false}));
|
||||
},
|
||||
/**
|
||||
* If the user clicks on a measure row, we can perform an in-memory sort
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMeasureRowClick: function (ev) {
|
||||
var $target = $(ev.target);
|
||||
var col_id = $target.data('id');
|
||||
var measure = $target.data('measure');
|
||||
var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc');
|
||||
var dataType = $target.data('data_type');
|
||||
this.model.sortRows(col_id, measure, isAscending, dataType);
|
||||
this.update({}, {reload: false});
|
||||
},
|
||||
/**
|
||||
* This method is called when someone clicks on an open header. When that
|
||||
* happens, we want to close the header, then redisplay the view.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onOpenHeaderClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
var headerID = $(ev.target).data('id');
|
||||
this.model.closeHeader(headerID);
|
||||
_onSortRows: function (ev) {
|
||||
this.model.sortRows(ev.data.sortedColumn);
|
||||
this.update({}, {reload: false});
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,72 +2,58 @@ odoo.define('web.PivotRenderer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractRenderer = require('web.AbstractRenderer');
|
||||
var core = require('web.core');
|
||||
var field_utils = require('web.field_utils');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var dataComparisonUtils = require('web.dataComparisonUtils');
|
||||
var field_utils = require('web.field_utils');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var PivotRenderer = AbstractRenderer.extend({
|
||||
tagName: 'table',
|
||||
className: 'table-hover table-sm table-bordered',
|
||||
events: _.extend({}, AbstractRenderer.prototype.events, {
|
||||
'hover td': '_onTdHover',
|
||||
'click td.o_pivot_cell_value': '_onCellValueClicked',
|
||||
'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
|
||||
'click .o_pivot_measure_row': '_onSpecialRowClick',
|
||||
'click .o_pivot_origin_row': '_onSpecialRowClick',
|
||||
'mouseenter thead tr:last th': '_onMouseenterCell',
|
||||
'mouseenter tbody td': '_onMouseenterCell',
|
||||
'mouseleave thead tr:last th': '_onMouseleaveCell',
|
||||
'mouseleave tbody td': '_onMouseleaveCell',
|
||||
}),
|
||||
|
||||
/**
|
||||
* @overide
|
||||
*
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
* @param {Object} state
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.enableLinking configure the pivot view to allow
|
||||
* opening a list view by clicking on a cell with some data.
|
||||
*/
|
||||
init: function (parent, state, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.compare = state.compare;
|
||||
this.enableLinking = params.enableLinking;
|
||||
this.fieldWidgets = params.widgets || {};
|
||||
this.timeRangeDescription = params.timeRangeDescription;
|
||||
this.comparisonTimeRangeDescription = params.comparisonTimeRangeDescription;
|
||||
this.paddingLeftHeaderTabWidth = config.device.isMobile ? 5 : 30;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} state
|
||||
* @param {Object} params
|
||||
*/
|
||||
updateState: function (state, params) {
|
||||
if (params.context !== undefined) {
|
||||
var timeRangeMenuData = params.context.timeRangeMenuData;
|
||||
if (timeRangeMenuData) {
|
||||
this.timeRangeDescription = timeRangeMenuData.timeRangeDescription;
|
||||
this.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription;
|
||||
} else {
|
||||
this.timeRangeDescription = undefined;
|
||||
this.comparisonTimeRangeDescription = undefined;
|
||||
}
|
||||
}
|
||||
this.compare = state.compare;
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Used to determine whether or not to display the no content helper.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
* @param {string[]} groupBy list of 'fieldName[:period]'
|
||||
* @returns {string[]} list of fields label
|
||||
*/
|
||||
_hasContent: function () {
|
||||
return this.state.has_data && this.state.measures.length;
|
||||
_getGroupByLabels: function (groupBy) {
|
||||
var self = this;
|
||||
return _.map(groupBy, function (gb) {
|
||||
return self.state.fields[gb.split(':')[0]].string;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -75,7 +61,8 @@ var PivotRenderer = AbstractRenderer.extend({
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_render: function () {
|
||||
if (!this._hasContent()) {
|
||||
var hasContent = this.state.hasData && this.state.measures.length;
|
||||
if (!hasContent) {
|
||||
// display the nocontent helper
|
||||
this._replaceElement(QWeb.render('View.NoContentHelper', {
|
||||
description: _t("Try to add some records, or make sure that there is at least " +
|
||||
@@ -84,223 +71,145 @@ var PivotRenderer = AbstractRenderer.extend({
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
|
||||
if (!this.$el.is('table')) {
|
||||
// coming from the no content helper, so the root element has to be
|
||||
// re-rendered before rendering and appending its content
|
||||
this.renderElement();
|
||||
}
|
||||
var $fragment = $(document.createDocumentFragment());
|
||||
var $table = $('<table>').appendTo($fragment);
|
||||
var $thead = $('<thead>').appendTo($table);
|
||||
var $tbody = $('<tbody>').appendTo($table);
|
||||
var nbr_measures = this.state.measures.length;
|
||||
var nbrCols = (this.state.mainColWidth === 1) ?
|
||||
nbr_measures :
|
||||
(this.state.mainColWidth + 1) * nbr_measures;
|
||||
var i;
|
||||
if (this.compare) {
|
||||
for (i=0; i < 3 * nbrCols + 1; i++) {
|
||||
$table.prepend($('<col>'));
|
||||
}
|
||||
} else {
|
||||
for (i=0; i < nbrCols + 1; i++) {
|
||||
$table.prepend($('<col>'));
|
||||
}
|
||||
}
|
||||
this._renderHeaders($thead, this.state.headers, nbrCols);
|
||||
this._renderRows($tbody, this.state.rows);
|
||||
// todo: make sure the next line does something
|
||||
$table.find('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip();
|
||||
this.$el.html($table.contents());
|
||||
this.renderElement(); // in case we come from no content helper
|
||||
this.$el.toggleClass('o_enable_linking', this.enableLinking);
|
||||
|
||||
var $thead = $('<thead>');
|
||||
var $tbody = $('<tbody>');
|
||||
this._renderHeaders($thead);
|
||||
this._renderRows($tbody);
|
||||
this.$el.empty().append($thead).append($tbody);
|
||||
this.$('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip();
|
||||
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryElement} $thead
|
||||
* @param {jQueryElement} headers
|
||||
* @param {jQuery} $thead
|
||||
*/
|
||||
_renderHeaders: function ($thead, headers, nbrCols) {
|
||||
var self = this;
|
||||
var i, j, cell, $row, $cell;
|
||||
var measure, dataType;
|
||||
var id, measureCellIds = [];
|
||||
_renderHeaders: function ($thead) {
|
||||
// groupbyLabels is the list of col groupby fields label
|
||||
var groupbyLabels = this._getGroupByLabels(this.state.colGroupBys);
|
||||
|
||||
var groupbyLabels = _.map(this.state.colGroupBys, function (gb) {
|
||||
return self.state.fields[gb.split(':')[0]].string;
|
||||
});
|
||||
|
||||
for (i = 0; i < headers.length; i++) {
|
||||
$row = $('<tr>');
|
||||
for (j = 0; j < headers[i].length; j++) {
|
||||
cell = headers[i][j];
|
||||
if (i == 0 && j == 0) {
|
||||
$cell = $('<th>')
|
||||
.text(cell.title)
|
||||
.attr('rowspan', cell.height + 1)
|
||||
.attr('colspan', cell.width);
|
||||
} else {
|
||||
$cell = $('<th>')
|
||||
.text(cell.title)
|
||||
.attr('rowspan', cell.height)
|
||||
.attr('colspan', (cell.width && self.compare) ? 3 * cell.width : cell.width || (self.compare && cell.measure && 3));
|
||||
}
|
||||
if (i > 0) {
|
||||
$cell.attr('title', groupbyLabels[i-1]);
|
||||
}
|
||||
if (cell.expanded !== undefined) {
|
||||
$cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
|
||||
$cell.data('id', cell.id);
|
||||
this.state.table.headers.forEach(function (row, rowIndex) {
|
||||
var $tr = $('<tr>');
|
||||
row.forEach(function (cell) {
|
||||
var cellParams = {
|
||||
text: cell.title,
|
||||
colspan: cell.width,
|
||||
rowspan: cell.height,
|
||||
data: {
|
||||
groupId: cell.groupId,
|
||||
type: 'col',
|
||||
}
|
||||
}
|
||||
var className;
|
||||
if (cell.measure) {
|
||||
measure = cell.measure;
|
||||
measureCellIds.push(cell.id);
|
||||
$cell.addClass('o_pivot_measure_row text-muted')
|
||||
.text(this.state.fields[measure].string);
|
||||
$cell.data('id', cell.id).data('measure', measure);
|
||||
if (cell.id === this.state.sortedColumn.id &&
|
||||
measure === this.state.sortedColumn.measure) {
|
||||
$cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order);
|
||||
if (this.state.sortedColumn.order == 'asc') {
|
||||
$cell.attr('aria-sorted', 'ascending');
|
||||
if (cell.originIndexes) {
|
||||
cellParams.data.originIndexes = cell.originIndexes
|
||||
className = 'o_pivot_origin_row';
|
||||
} else {
|
||||
className = 'o_pivot_measure_row';
|
||||
}
|
||||
className += ' text-muted';
|
||||
if (cell.order) {
|
||||
className += ' o_pivot_sort_order_' + cell.order;
|
||||
if (cell.order === 'asc') {
|
||||
cellParams['aria-sorted'] = 'ascending'
|
||||
} else {
|
||||
$cell.attr('aria-sorted', 'descending');
|
||||
cellParams['aria-sorted'] = 'descending'
|
||||
}
|
||||
}
|
||||
}
|
||||
$row.append($cell);
|
||||
|
||||
$cell.toggleClass('d-none d-md-table-cell', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.state.measures.length));
|
||||
if (cell.height > 1) {
|
||||
$cell.css('padding', 0);
|
||||
}
|
||||
}
|
||||
$thead.append($row);
|
||||
}
|
||||
if (this.compare) {
|
||||
var colLabels = [this.timeRangeDescription, this.comparisonTimeRangeDescription, _t('Variation')];
|
||||
var dataTypes = ['data', 'comparisonData', 'variation'];
|
||||
$row = $('<tr>');
|
||||
for (i = 0; i < 3 * nbrCols; i++) {
|
||||
id = measureCellIds[~~(i / 3)];
|
||||
measure = this.state.measures[(~~(i / 3)) % this.state.measures.length];
|
||||
dataType = dataTypes[i % 3];
|
||||
$cell = $('<th>')
|
||||
.addClass('o_pivot_measure_row text-muted')
|
||||
.data('data_type', dataType)
|
||||
.data('id', id)
|
||||
.data('measure', measure)
|
||||
.text(colLabels[i % 3])
|
||||
.attr('rowspan', 1)
|
||||
.attr('colspan', 1);
|
||||
if (dataType === this.state.sortedColumn.dataType &&
|
||||
id === this.state.sortedColumn.id &&
|
||||
measure === this.state.sortedColumn.measure) {
|
||||
$cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order);
|
||||
if (this.state.sortedColumn.order === 'asc') {
|
||||
$cell.attr('aria-sorted', 'ascending');
|
||||
} else {
|
||||
$cell.attr('aria-sorted', 'descending');
|
||||
cellParams.data.measure = cell.measure;
|
||||
} else if ('isLeaf' in cell) {
|
||||
if (rowIndex > 0) {
|
||||
cellParams.title = groupbyLabels[rowIndex - 1];
|
||||
}
|
||||
className = 'o_pivot_header_cell';
|
||||
if (!config.device.isMobile) {
|
||||
className += cell.isLeaf ? '_closed' : '_opened';
|
||||
}
|
||||
}
|
||||
$row.append($cell);
|
||||
}
|
||||
$thead.append($row);
|
||||
}
|
||||
cellParams.class = className;
|
||||
|
||||
$tr.append($('<th>', cellParams));
|
||||
});
|
||||
$thead.append($tr);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryElement} $tbody
|
||||
* @param {jQueryElement} rows
|
||||
* @param {jQuery} $tbody
|
||||
*/
|
||||
_renderRows: function ($tbody, rows) {
|
||||
_renderRows: function ($tbody) {
|
||||
var self = this;
|
||||
var i, j, value, measure, name, formatter, $row, $cell, $header;
|
||||
var nbrMeasures = this.state.measures.length;
|
||||
var length = rows[0].values.length;
|
||||
var shouldDisplayTotal = this.state.mainColWidth > 1;
|
||||
|
||||
var groupbyLabels = _.map(this.state.rowGroupBys, function (gb) {
|
||||
return self.state.fields[gb.split(':')[0]].string;
|
||||
});
|
||||
var measureTypes = this.state.measures.map(function (name) {
|
||||
var type = self.state.fields[name].type;
|
||||
return type === 'many2one' ? 'integer' : type;
|
||||
});
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
$row = $('<tr>');
|
||||
$header = $('<td>')
|
||||
.text(rows[i].title)
|
||||
.data('id', rows[i].id)
|
||||
.css('padding-left', (5 + rows[i].indent * self.paddingLeftHeaderTabWidth) + 'px')
|
||||
.addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed');
|
||||
if (rows[i].indent > 0) $header.attr('title', groupbyLabels[rows[i].indent - 1]);
|
||||
$header.appendTo($row);
|
||||
for (j = 0; j < length; j++) {
|
||||
value = rows[i].values[j];
|
||||
name = this.state.measures[j % nbrMeasures];
|
||||
formatter = field_utils.format[this.fieldWidgets[name] || measureTypes[j % nbrMeasures]];
|
||||
measure = this.state.fields[name];
|
||||
if (this.compare) {
|
||||
if (value instanceof Object) {
|
||||
for (var origin in value) {
|
||||
$cell = $('<td>')
|
||||
.data('id', rows[i].id)
|
||||
.data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)])
|
||||
.data('type' , origin)
|
||||
.toggleClass('o_empty', false)
|
||||
.addClass('o_pivot_cell_value text-right');
|
||||
if (origin === 'data') {
|
||||
$cell.append($('<div>', {class: 'o_value'}).html(formatter(
|
||||
value[origin],
|
||||
measure
|
||||
)));
|
||||
} else if (origin === 'comparisonData') {
|
||||
$cell.append($('<div>', {class: 'o_comparison_value'}).html(formatter(
|
||||
value[origin],
|
||||
measure
|
||||
)));
|
||||
} else {
|
||||
$cell.append($('<div>', {class: 'o_variation' + value[origin].signClass}).html(
|
||||
field_utils.format.percentage(
|
||||
value[origin].magnitude,
|
||||
measure
|
||||
)
|
||||
));
|
||||
}
|
||||
if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){
|
||||
$cell.css('font-weight', 'bold');
|
||||
}
|
||||
$cell.toggleClass('d-none d-md-table-cell', j < length - nbrMeasures);
|
||||
$row.append($cell);
|
||||
}
|
||||
} else {
|
||||
for (var l=0; l < 3; l++) {
|
||||
$cell = $('<td>')
|
||||
.data('id', rows[i].id)
|
||||
.toggleClass('o_empty', true)
|
||||
.addClass('o_pivot_cell_value text-right');
|
||||
$row.append($cell);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$cell = $('<td>')
|
||||
.data('id', rows[i].id)
|
||||
.data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)])
|
||||
.toggleClass('o_empty', _.isUndefined(value))
|
||||
.addClass('o_pivot_cell_value text-right');
|
||||
if (value !== undefined) {
|
||||
$cell.append($('<div>', {class: 'o_value'}).html(formatter(value, measure)));
|
||||
}
|
||||
if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){
|
||||
$cell.css('font-weight', 'bold');
|
||||
}
|
||||
$row.append($cell);
|
||||
// measureTypes is a mapping from measure fields to their field type,
|
||||
// with a special case for many2one fields which are mapped to the
|
||||
// 'integer' type (as their group_operator is 'count_distinct')
|
||||
var measureTypes = this.state.measures.reduce(
|
||||
function (acc, measureName) {
|
||||
var type = self.state.fields[measureName].type;
|
||||
acc[measureName] = type === 'many2one' ? 'integer' : type;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
$cell.toggleClass('d-none d-md-table-cell', j < length - nbrMeasures);
|
||||
// groupbyLabels is the list of row groupby fields label
|
||||
var groupbyLabels = this._getGroupByLabels(this.state.rowGroupBys);
|
||||
|
||||
this.state.table.rows.forEach(function (row) {
|
||||
var $tr = $('<tr>');
|
||||
var paddingLeft = 5 + row.indent * self.paddingLeftHeaderTabWidth;
|
||||
$tr.append($('<th>', {
|
||||
text: row.title,
|
||||
title: row.indent > 0 ? groupbyLabels[row.indent - 1] : null,
|
||||
data: {
|
||||
groupId: row.groupId,
|
||||
type: 'row',
|
||||
},
|
||||
css: {
|
||||
'padding-left': paddingLeft + 'px',
|
||||
},
|
||||
class: 'o_pivot_header_cell_' + (row.isLeaf ? 'closed' : 'opened'),
|
||||
}));
|
||||
|
||||
row.subGroupMeasurements.forEach(function (measurement) {
|
||||
var cellParams = {
|
||||
data: {
|
||||
groupId: measurement.groupId,
|
||||
originIndexes: measurement.originIndexes,
|
||||
},
|
||||
class: 'o_pivot_cell_value text-right',
|
||||
};
|
||||
if (measurement.isBold) {
|
||||
cellParams.css = {
|
||||
'font-weight': 'bold',
|
||||
};
|
||||
}
|
||||
}
|
||||
$tbody.append($row);
|
||||
}
|
||||
if (measurement.value !== undefined) {
|
||||
var measure = measurement.measure;
|
||||
var measureField = self.state.fields[measure];
|
||||
var $value;
|
||||
if (measurement.originIndexes.length > 1) {
|
||||
$value = dataComparisonUtils.renderVariation(measurement.value, measureField);
|
||||
} else {
|
||||
var formatType = self.fieldWidgets[measure] || measureTypes[measure];
|
||||
var formatter = field_utils.format[formatType];
|
||||
$value = $('<div>', {
|
||||
class: 'o_value',
|
||||
html: formatter(measurement.value, measureField),
|
||||
});
|
||||
}
|
||||
cellParams.html = $value;
|
||||
} else {
|
||||
cellParams.class += ' o_empty'
|
||||
}
|
||||
$tr.append($('<td>', cellParams));
|
||||
});
|
||||
$tbody.append($tr);
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -308,12 +217,104 @@ var PivotRenderer = AbstractRenderer.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When the user clicks on a non empty cell, and the view is configured to
|
||||
* allow 'linking' (with enableLinking), we want to open a list view with
|
||||
* the corresponding record.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onTdHover: function (ev) {
|
||||
var $td = $(event.target);
|
||||
$td.closest('table').find('col:eq(' + $td.index()+')').toggleClass('hover');
|
||||
_onCellValueClicked: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
var $target = $(ev.currentTarget);
|
||||
if ($target.hasClass('o_empty') || !this.enableLinking) {
|
||||
return;
|
||||
}
|
||||
|
||||
var context = _.omit(this.state.context, function (val, key) {
|
||||
return key === 'group_by' || _.str.startsWith(key, 'search_default_');
|
||||
});
|
||||
|
||||
var groupId = $target.data('groupId');
|
||||
var originIndexes = $target.data('originIndexes');
|
||||
|
||||
var group = {
|
||||
rowValues: groupId[0],
|
||||
colValues: groupId[1],
|
||||
originIndex: originIndexes[0]
|
||||
};
|
||||
|
||||
this.trigger_up('open_view', {
|
||||
group: group,
|
||||
context: context,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Highlight the column when hovering a cell.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMouseenterCell: function (ev) {
|
||||
var index = $(ev.currentTarget).index();
|
||||
if ($(ev.currentTarget).is('th')) { // header cell
|
||||
index += 1; // increment by 1 to compensate the top left empty cell
|
||||
}
|
||||
this.$("td").filter(":nth-child(" + (index + 1) + ")").addClass("o_cell_hover");
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onMouseleaveCell: function () {
|
||||
this.$('.o_cell_hover').removeClass('o_cell_hover');
|
||||
},
|
||||
/**
|
||||
* This method is called when someone clicks on an open header. When that
|
||||
* happens, we want to close the header, then redisplay the view.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onOpenHeaderClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
|
||||
var $target = $(ev.target);
|
||||
var groupId = $target.data('groupId');
|
||||
var type = $target.data('type');
|
||||
|
||||
this.trigger_up('close_group', {
|
||||
groupId: groupId,
|
||||
type: type,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* If the user clicks on a measure or origin row, we perform an in-memory sort.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onSpecialRowClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
|
||||
var $target = $(ev.target);
|
||||
var groupId = $target.data('groupId');
|
||||
var measure = $target.data('measure');
|
||||
var originIndexes = $target.data('originIndexes');
|
||||
var isAscending = $target.hasClass('o_pivot_sort_order_asc');
|
||||
var order = isAscending ? 'desc' : 'asc';
|
||||
|
||||
this.trigger_up('sort_rows', {
|
||||
sortedColumn: {
|
||||
groupId: groupId,
|
||||
measure: measure,
|
||||
order: order,
|
||||
originIndexes: originIndexes,
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ odoo.define('web.PivotView', function (require) {
|
||||
*/
|
||||
|
||||
var AbstractView = require('web.AbstractView');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var PivotModel = require('web.PivotModel');
|
||||
var PivotController = require('web.PivotController');
|
||||
@@ -65,12 +66,9 @@ var PivotView = AbstractView.extend({
|
||||
if (field.attrs.interval) {
|
||||
name += ':' + field.attrs.interval;
|
||||
}
|
||||
|
||||
if (field.attrs.widget) {
|
||||
widgets[name] = field.attrs.widget;
|
||||
}
|
||||
|
||||
|
||||
if (field.attrs.invisible && py.eval(field.attrs.invisible)) {
|
||||
delete measures[name];
|
||||
return;
|
||||
@@ -82,15 +80,12 @@ var PivotView = AbstractView.extend({
|
||||
// the measure should be allowed. However, be careful if you define
|
||||
// a measure in your pivot view: non stored functional fields will
|
||||
// probably not work (their aggregate will always be 0).
|
||||
|
||||
if (field.attrs.type === 'measure' && !(field.attrs.name in measures)) {
|
||||
measures[field.attrs.name] = self.fields[field.attrs.name];
|
||||
if (field.attrs.type === 'measure' && !(name in measures)) {
|
||||
measures[name] = self.fields[name];
|
||||
}
|
||||
|
||||
if (field.attrs.string) {
|
||||
measures[name].string = field.attrs.string;
|
||||
}
|
||||
|
||||
if (field.attrs.type === 'measure' || 'operator' in field.attrs) {
|
||||
activeMeasures.push(name);
|
||||
measures[name] = self.fields[name];
|
||||
@@ -111,11 +106,16 @@ var PivotView = AbstractView.extend({
|
||||
this.loadParams.rowGroupBys = rowGroupBys;
|
||||
this.loadParams.fields = this.fields;
|
||||
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
|
||||
if (config.device.isMobile) {
|
||||
this.loadParams.colGroupBys = [];
|
||||
this.loadParams.context.pivot_column_groupby = [];
|
||||
}
|
||||
|
||||
|
||||
this.rendererParams.widgets = widgets;
|
||||
this.rendererParams.enableLinking = !this.arch.attrs.disable_linking;
|
||||
|
||||
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
|
||||
this.controllerParams.enableLinking = !this.arch.attrs.disable_linking;
|
||||
this.controllerParams.measures = measures;
|
||||
this.controllerParams.groupableFields = groupableFields;
|
||||
// retrieve form and list view ids from the action to open those views
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
.o_pivot {
|
||||
overflow: auto;
|
||||
|
||||
// Inform the user that he may click on a cell to be redirected to a list view of the
|
||||
// items corresponding to the clicked cell
|
||||
&.o_enable_linking {
|
||||
.o_pivot_cell_value:hover {
|
||||
color: $o-brand-primary;
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
}
|
||||
.o_pivot_cell_value {
|
||||
font-size: 1em;
|
||||
.o_comparison {
|
||||
@@ -29,7 +20,17 @@
|
||||
background-color: $o-view-background-color;
|
||||
width: auto; // bootstrap override
|
||||
|
||||
thead th:not(.o_pivot_header_cell_closed):not(.o_pivot_header_cell_opened) {
|
||||
// Inform the user that he may click on a cell to be redirected to a list view of the
|
||||
// items corresponding to the clicked cell
|
||||
&.o_enable_linking {
|
||||
.o_pivot_cell_value:not(.o_empty):hover {
|
||||
color: $o-brand-primary;
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
thead th:not(.o_pivot_header_cell_closed):not(.o_pivot_header_cell_opened):not(.o_pivot_header_cell) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -52,6 +53,11 @@
|
||||
@include o-pivot-header-cell;
|
||||
}
|
||||
|
||||
.o_pivot_header_cell {
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.o_pivot_header_cell_closed {
|
||||
@include o-pivot-header-cell;
|
||||
&::before {
|
||||
@@ -70,14 +76,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_pivot_measure_row_sorted_asc::after {
|
||||
.o_pivot_sort_order_asc::after {
|
||||
@include o-caret-up;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.o_pivot_sort_order_desc::after {
|
||||
@include o-caret-down;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.o_pivot_measure_row_sorted_desc::after {
|
||||
@include o-caret-up;
|
||||
margin-left: 5px;
|
||||
.o_pivot_cell_value.o_cell_hover {
|
||||
background-color: $table-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
<button class="btn btn-secondary fa fa-download o_pivot_download" title="Download xls" aria-label="Download xls"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="PivotView.FieldSelection">
|
||||
<t t-name="PivotView.GroupBySelection">
|
||||
<div class="dropdown-menu o_pivot_field_menu" role="menu">
|
||||
<t t-foreach="fields" t-as="field">
|
||||
<t t-if="(field[1].type === 'date') || (field[1].type === 'datetime')">
|
||||
|
||||
@@ -53,4 +53,4 @@ QUnit.module('mathUtils', function () {
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -974,7 +974,7 @@ var MockServer = Class.extend({
|
||||
res[groupByField] = val;
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
if (field.type === 'date' && val) {
|
||||
var aggregateFunction = groupByField.split(':')[1];
|
||||
var startDate, endDate;
|
||||
if (aggregateFunction === 'day') {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ QUnit.module('Views', {
|
||||
fields: {
|
||||
foo: {string: "Foo", type: 'char'},
|
||||
bar: {string: "Bar", type: 'boolean'},
|
||||
int_field: {string: "Int Field", type: 'integer'},
|
||||
int_field: {string: "Int Field", type: 'integer', group_operator: 'sum'},
|
||||
company_id: {string: "company", type: 'many2one', relation: 'company'},
|
||||
category_id: { string: "category", type: 'many2one', relation: 'category' },
|
||||
state: { string: "State", type: 'selection', selection: [['abc', "ABC"], ['def', "DEF"], ['ghi', "GHI"]]},
|
||||
|
||||
@@ -21,7 +21,7 @@ QUnit.module('Search View', {
|
||||
birthday: {string: "Birthday", type: "date", store: true, sortable: true},
|
||||
foo: {string: "Foo", type: "char", store: true, sortable: true},
|
||||
bar: {string: "Bar", type: "many2one", relation: 'partner'},
|
||||
float_field: {string: "Float", type: "float"},
|
||||
float_field: {string: "Float", type: "float", group_operator: 'sum'},
|
||||
},
|
||||
records: [
|
||||
{id: 1, display_name: "First record", foo: "yop", bar: 2, date_field: "2017-01-25", birthday: "1983-07-15", float_field: 1},
|
||||
@@ -1254,14 +1254,15 @@ QUnit.module('Search View', {
|
||||
});
|
||||
|
||||
QUnit.test('a default time range only in context is taken into account', async function (assert) {
|
||||
assert.expect(2);
|
||||
assert.expect(1);
|
||||
|
||||
var actionManager = await createActionManager({
|
||||
actions: this.actions,
|
||||
archs: this.archs,
|
||||
data: this.data,
|
||||
mockRPC: function (route, args) {
|
||||
// there are two read_group calls (for the groupby lists [] and ["date_field:day"])
|
||||
// there are only one read_group call (for the groupby lists [])
|
||||
// the read group for the list ["date_field:day"] is not done since no data is available for today)
|
||||
if (route === '/web/dataset/call_kw/partner/read_group') {
|
||||
var timeRangeMenuData = args.kwargs.context.timeRangeMenuData;
|
||||
assert.ok(timeRangeMenuData.timeRange.length > 0, "time range should be non empty");
|
||||
|
||||
Reference in New Issue
Block a user