[REF] web: rewrite pivot view renderer with OWL

Because the webclient and other higher level components such as the
actionManager are not yet rewritten in OWL, we cannot make a complete
migration of the controller and the model to a more OWL-ish code.

We have rewritten the renderer completely in OWL, it uses more
extensively a template and manages its own state and its sub-components.

We did not rewrite the controller, however we have made it inherit from
`web.ControllerAdapter` which is a thin compatibility layer used when a
odoo controller uses a OWL renderer.

In the tests, the only changes are that all the events triggered by the
test and handled by OWL must be triggered on DOM nodes directly and not
on JQuery elements.

closes odoo/odoo#39491

Related: odoo/enterprise#7135
Signed-off-by: VincentSchippefilt <VincentSchippefilt@users.noreply.github.com>
Co-authored-by: VincentSchippefilt <vsc@odoo.com>
Co-authored-by: pro-odoo <pro@odoo.com>
This commit is contained in:
Pierre Rousseau
2019-12-09 12:53:24 +00:00
committed by Vincent Schippefilt
co-authored by VincentSchippefilt
parent eef10097ae
commit 46cf65d68e
10 changed files with 815 additions and 830 deletions
+1
View File
@@ -28,6 +28,7 @@ This module provides the core of the Odoo Web Client.
"static/src/xml/kanban.xml",
"static/src/xml/menu.xml",
"static/src/xml/notification.xml",
"static/src/xml/pivot.xml",
"static/src/xml/rainbow_man.xml",
"static/src/xml/report.xml",
"static/src/xml/web_calendar.xml",
+1 -1
View File
@@ -6,7 +6,7 @@ odoo.define('web.config', function () {
* This is often necessary to allow the rest of the web client to properly
* render itself.
*
* Note that many informations currently stored in session should be moved to
* Note that many information currently stored in session should be moved to
* this file someday.
*/
@@ -1,355 +1,311 @@
odoo.define('web.PivotController', function (require) {
"use strict";
/**
* Odoo Pivot Table Controller
*
* This class is the Controller for the pivot table view. It has to coordinate
* the actions coming from the search view (through the update method), from
* the renderer, from the model, and from the control panel.
*
* It can display action buttons in the control panel, to select a different
* measure, or to perform some other actions such as download/expand/flip the
* view.
*/
var AbstractController = require('web.AbstractController');
var core = require('web.core');
var framework = require('web.framework');
var session = require('web.session');
var _t = core._t;
var QWeb = core.qweb;
var PivotController = AbstractController.extend({
contentTemplate: 'PivotView',
events: {
'click .o_pivot_field_menu a': '_onGroupByMenuSelection',
'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
},
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
close_group: '_onCloseGroup',
open_view: '_onOpenView',
sort_rows: '_onSortRows',
}),
"use strict";
/**
* @override
* @param {Object} params
* @param {Object} params.groupableFields a map from field names to field
* props
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.measures = params.measures;
this.groupableFields = params.groupableFields;
this.title = params.title;
// views to use in the action triggered when a data cell is clicked
this.views = params.views;
this.groupSelected = null;
},
/**
* @override
*/
start: function () {
this.$groupBySelection = this.$('.o_field_selection');
core.bus.on('click', this, function () {
this.$groupBySelection.empty();
});
return this._super();
},
/**
* @override
*/
destroy: function () {
if (this.$buttons) {
// remove jquery's tooltip() handlers
this.$buttons.find('button').off();
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns the current measures and groupbys, so we can restore the view
* when we save the current state in the search view, or when we add it to
* the dashboard.
* Odoo Pivot Table Controller
*
* @override method from AbstractController
* @returns {Object}
* This class is the Controller for the pivot table view. It has to coordinate
* the actions coming from the search view (through the update method), from
* the renderer, from the model, and from the control panel.
*
* It can display action buttons in the control panel, to select a different
* measure, or to perform some other actions such as download/expand/flip the
* view.
*/
getOwnedQueryParams: function () {
var state = this.model.get({raw: true});
return {
context: {
pivot_measures: state.measures,
pivot_column_groupby: state.colGroupBys,
pivot_row_groupby: state.rowGroupBys,
const ControllerAdapter = require('web.ControllerAdapter');
const core = require('web.core');
const framework = require('web.framework');
const session = require('web.session');
const _t = core._t;
const QWeb = core.qweb;
const PivotController = ControllerAdapter.extend({
events: Object.assign({}, ControllerAdapter.prototype.events, {
closed_header_click: '_onClosedHeaderClicked',
open_view: '_onOpenView',
opened_header_click: '_onOpenedHeaderClicked',
sort_rows: '_onSortRows',
groupby_menu_selection: '_onGroupByMenuSelection',
}),
/**
* @override
* @param parent
* @param model
* @param renderer
* @param {Object} params
* @param {Object} params.groupableFields a map from field names to field
* props
*/
init: function (parent, model, renderer, params) {
this._super(...arguments);
this.measures = params.measures;
this.title = params.title;
// views to use in the action triggered when a data cell is clicked
this.views = params.views;
this.groupSelected = null;
},
/**
* @override
*/
destroy: function () {
if (this.$buttons) {
// remove jquery's tooltip() handlers
this.$buttons.find('button').off();
}
};
},
/**
* Render the buttons according to the PivotView.buttons template and
* add listeners on it.
* Set this.$buttons with the produced jQuery element
*
* @param {jQuery} [$node] a jQuery node where the rendered buttons should
* be inserted. $node may be undefined, in which case the PivotView
* does nothing
*/
renderButtons: function ($node) {
if ($node) {
var context = {
measures: _.sortBy(_.pairs(_.omit(this.measures, '__count')), function (x) {
return x[1].string.toLowerCase();
}),
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns the current measures and groupbys, so we can restore the view
* when we save the current state in the search view, or when we add it to
* the dashboard.
*
* @override method from AbstractController
* @returns {Object}
*/
getOwnedQueryParams: function () {
const state = this.model.get({ raw: true });
return {
context: {
pivot_measures: state.measures,
pivot_column_groupby: state.colGroupBys,
pivot_row_groupby: state.rowGroupBys,
}
};
this.$buttons = $(QWeb.render('PivotView.buttons', context));
this.$buttons.click(this._onButtonClick.bind(this));
this.$buttons.find('button').tooltip();
},
/**
* Render the buttons according to the PivotView.buttons template and
* add listeners on it.
* Set this.$buttons with the produced jQuery element
*
* @param {jQuery} [$node] a jQuery node where the rendered buttons should
* be inserted. $node may be undefined, in which case the PivotView
* does nothing
*/
renderButtons: function ($node) {
if ($node) {
const context = {
measures: Object.entries(this.measures)
.filter(x => x[0] !== '__count')
.sort((a, b) => a[1].string.toLowerCase() > b[1].string.toLowerCase() ? 1 : -1),
};
this.$buttons = $(QWeb.render('PivotView.buttons', context));
this.$buttons.click(this._onButtonClick.bind(this));
this.$buttons.find('button').tooltip();
this.$buttons.appendTo($node);
this.$buttons.appendTo($node);
this._updateButtons();
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* 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 () {
if (this.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;
}
const table = this.model.exportData();
table.title = this.title;
session.get_file({
url: '/web/pivot/export_xls',
data: { data: JSON.stringify(table) },
complete: framework.unblockUI,
error: (error) => this.call('crash_manager', 'rpc_error', error),
});
},
/**
* @override
* @private
*/
_update: function () {
this._updateButtons();
}
},
return this._super.apply(this, arguments);
},
/**
* @private
*/
_updateButtons: function () {
if (!this.$buttons) {
return;
}
const state = this.model.get({ raw: true });
Object.entries(this.measures).forEach(elt => {
const name = elt[0];
const isSelected = state.measures.includes(name);
this.$buttons.find('.dropdown-item[data-field="' + name + '"]')
.toggleClass('selected', isSelected);
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
});
const noDataDisplayed = !state.hasData || !state.measures.length;
this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed);
this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed);
this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed);
},
/**
* 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 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;
}
var table = self.model.exportData();
table.title = self.title;
session.get_file({
url: '/web/pivot/export_xls',
data: {data: JSON.stringify(table)},
complete: framework.unblockUI,
error: (error) => this.call('crash_manager', 'rpc_error', error),
});
},
/**
* Render the field selection menu, to select a groupable field. We disable
* already selected groupbys.
*
* @private
* @param {number} top top coordinate where we have to render the menu
* @param {number} left left coordinate for the menu
*/
_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];
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* This handler is called when the user clicked on a button in the control
* panel. We then have to react properly: it can either be a change in the
* current measures, or a request to flip/expand/download data.
*
* @private
* @param {MouseEvent} ev
*/
_onButtonClick: async function (ev) {
const $target = $(ev.target);
if ($target.hasClass('o_pivot_flip_button')) {
this.model.flip();
this.update({}, { reload: false });
}
if ($target.hasClass('o_pivot_expand_button')) {
await this.model.expandAll();
this.update({}, { reload: false });
}
if ($target.parents('.o_pivot_measures_list').length) {
ev.preventDefault();
ev.stopPropagation();
const field = $target.data('field');
await this.model.toggleMeasure(field);
this.update({}, { reload: false });
}
if ($target.hasClass('o_pivot_download')) {
this._downloadTable();
}
},
/**
*
* @private
* @param {OdooEvent} ev
*/
_onCloseGroup: function (ev) {
this.model.closeGroup(ev.data.groupId, ev.data.type);
this.update({}, { reload: false });
},
/**
* @param {CustomEvent} ev
* @private
* */
_onOpenedHeaderClicked: function (ev) {
this.model.closeGroup(ev.detail.cell.groupId, ev.detail.type);
this.update({}, { reload: false });
},
/**
* @param {CustomEvent} ev
* @private
* */
_onClosedHeaderClicked: async function (ev) {
const cell = ev.detail.cell;
const groupId = cell.groupId;
const type = ev.detail.type;
const group = {
rowValues: groupId[0],
colValues: groupId[1],
type: type
};
const state = this.model.get({ raw: true });
const groupValues = type === 'row' ? groupId[0] : groupId[1];
const groupBys = type === 'row' ?
state.rowGroupBys :
state.colGroupBys;
this.selectedGroup = group;
if (groupValues.length < groupBys.length) {
const groupBy = groupBys[groupValues.length];
await this.model.expandGroup(this.selectedGroup, groupBy);
this.update({}, { reload: false });
}
},
/**
* This handler is called when the user selects a groupby in the dropdown menu.
*
* @private
* @param {CustomEvent} ev
*/
_onGroupByMenuSelection: async function (ev) {
ev.stopPropagation();
let groupBy = ev.detail.field.name;
const interval = ev.detail.interval;
if (interval) {
groupBy = groupBy + ':' + interval;
}
this.model.addGroupBy(groupBy, this.selectedGroup.type);
await this.model.expandGroup(this.selectedGroup, groupBy);
this.update({}, { reload: false });
},
/**
* @private
* @param {CustomEvent} ev
*/
_onOpenView: function (ev) {
ev.stopPropagation();
const cell = ev.detail;
if (cell.value === undefined || this.initialState.disableLinking) {
return;
}
const context = Object.assign({}, this.model.data.context);
Object.keys(context).forEach(x => {
if (x === 'group_by' || x.startsWith('search_default_')) {
delete context[x];
}
});
var fields = _.chain(this.groupableFields)
.pairs()
.sortBy(function (f) {
return f[1].string;
})
.map(function (f) {
return [f[0], f[1], _.contains(groupedFields, f[0])];
})
.value();
const group = {
rowValues: cell.groupId[0],
colValues: cell.groupId[1],
originIndex: cell.originIndexes[0]
};
this.$groupBySelection.html(QWeb.render('PivotView.GroupBySelection', {
fields: fields
}));
const domain = this.model._getGroupDomain(group);
var cssProps = {top: top};
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
* @private
*/
_startRenderer: function () {
return this.renderer.appendTo(this.$('.o_pivot'));
},
/**
* @override
* @private
*/
_update: function () {
this._updateButtons();
return this._super.apply(this, arguments);
},
/**
* @private
*/
_updateButtons: function () {
if (!this.$buttons) {
return;
}
var self = this;
var state = this.model.get({raw: true});
_.each(this.measures, function (measure, name) {
var isSelected = _.contains(state.measures, name);
self.$buttons.find('.dropdown-item[data-field="' + name + '"]')
.toggleClass('selected', isSelected);
});
var noDataDisplayed = !state.hasData || !state.measures.length;
this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed);
this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed);
this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed);
},
this.do_action({
type: 'ir.actions.act_window',
name: this.title,
res_model: this.modelName,
views: this.views,
view_mode: 'list',
target: 'current',
context: context,
domain: domain,
});
},
/**
* @private
* @param {CustomEvent} ev
*/
_onSortRows: function (ev) {
this.model.sortRows({
groupId: ev.detail.groupId,
measure: ev.detail.measure,
order: (ev.detail.order || 'desc') === 'asc' ? 'desc' : 'asc',
originIndexes: ev.detail.originIndexes,
});
this.update({}, { reload: false });
},
});
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* This handler is called when the user clicked on a button in the control
* panel. We then have to react properly: it can either be a change in the
* current measures, or a request to flip/expand/download data.
*
* @private
* @param {MouseEvent} ev
*/
_onButtonClick: function (ev) {
var $target = $(ev.target);
if ($target.hasClass('o_pivot_flip_button')) {
this.model.flip();
this.update({}, {reload: false});
}
if ($target.hasClass('o_pivot_expand_button')) {
this.model.expandAll().then(this.update.bind(this, {}, {reload: false}));
}
if ($target.parents('.o_pivot_measures_list').length) {
ev.preventDefault();
ev.stopPropagation();
var field = $target.data('field');
this.model.toggleMeasure(field).then(this.update.bind(this, {}, {reload: false}));
}
if ($target.hasClass('o_pivot_download')) {
this._downloadTable();
}
},
/**
*
* @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
*/
_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 = ev.clientX;
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 groupBy = $target.data('field');
var interval = $target.data('interval');
if (interval) {
groupBy = groupBy + ':' + interval;
}
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) {
ev.stopPropagation();
var context = ev.data.context;
var group = ev.data.group;
var domain = this.model._getGroupDomain(group);
this.do_action({
type: 'ir.actions.act_window',
name: this.title,
res_model: this.modelName,
views: this.views,
view_mode: 'list',
target: 'current',
context: context,
domain: domain,
});
},
/**
* @private
* @param {OdooEvent} ev
*/
_onSortRows: function (ev) {
this.model.sortRows(ev.data.sortedColumn);
this.update({}, {reload: false});
},
});
return PivotController;
return PivotController;
});
@@ -361,7 +361,7 @@ var PivotModel = AbstractModel.extend({
* @param {string} groupBy
* @returns {Promise}
*/
expandGroup: function (group, groupBy) {
expandGroup: async function (group, groupBy) {
var leftDivisors;
var rightDivisors;
@@ -475,6 +475,7 @@ var PivotModel = AbstractModel.extend({
function twistKey(key) {
return JSON.stringify(JSON.parse(key).reverse());
}
function twist(object) {
var newObject = {};
Object.keys(object).forEach(function (key) {
@@ -483,6 +484,7 @@ var PivotModel = AbstractModel.extend({
});
return newObject;
}
this.measurements = twist(this.measurements);
this.counts = twist(this.counts);
this.groupDomains = twist(this.groupDomains);
@@ -507,6 +509,7 @@ var PivotModel = AbstractModel.extend({
measures: this.data.measures,
origins: this.data.origins,
rowGroupBys: groupBys.rowGroupBys,
selectionGroupBys: this._getSelectionGroupBy(groupBys),
};
if (!raw && state.hasData) {
state.table = this._getTable();
@@ -540,6 +543,7 @@ var PivotModel = AbstractModel.extend({
* @param {string} [params.default_order]
* @param {string} [params.timeRangeDescription=""]
* @param {string} params.modelName
* @param {Object[]} params.groupableFields
* @returns {Promise}
*/
load: function (params) {
@@ -549,6 +553,7 @@ var PivotModel = AbstractModel.extend({
this.fields = params.fields;
this.modelName = params.modelName;
this.groupableFields = params.groupableFields;
this.data = {
expandedRowGroupBys: [],
expandedColGroupBys: [],
@@ -661,6 +666,7 @@ var PivotModel = AbstractModel.extend({
* in-memory sort.
*
* @param {Object} sortedColumn
* @param {number[]} sortedColumn.groupId
*/
sortRows: function (sortedColumn) {
var self = this;
@@ -1041,7 +1047,7 @@ var PivotModel = AbstractModel.extend({
var groupId = column.groupId;
var measure = column.measure;
var isSorted = sortedColumn.measure === measure &&
_.isEqual(sortedColumn.groupId, groupId);
_.isEqual(sortedColumn.groupId, groupId);
var isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1];
var isSortedByVariation = isSorted && sortedColumn.originIndexes[1];
@@ -1092,6 +1098,31 @@ var PivotModel = AbstractModel.extend({
}
return origins;
},
/**
* Get the selection needed to display the group by dropdown
* @returns {Object[]}
* @private
*/
_getSelectionGroupBy: function (groupBys) {
let groupedFieldNames = groupBys.rowGroupBys
.concat(groupBys.colGroupBys)
.map(function (g) {
return g.split(':')[0];
});
var fields = Object.keys(this.groupableFields)
.map((fieldName, index) => {
return {
name: fieldName,
field: this.groupableFields[fieldName],
active: groupedFieldNames.includes(fieldName)
}
})
.sort((left, right) => left.field.string < right.field.string ? -1 : 1);
return fields;
},
/**
* Returns a description of the pivot table.
*
@@ -1132,6 +1163,7 @@ var PivotModel = AbstractModel.extend({
title: "",
width: 1,
});
// col groupby cells with group values
/**
* Recursive function that generates the header cells corresponding to
@@ -1139,7 +1171,7 @@ var PivotModel = AbstractModel.extend({
*
* @param {Object} tree
*/
function generateTreeHeaders(tree) {
function generateTreeHeaders(tree, fields) {
var group = tree.root;
var rowIndex = group.values.length;
var row = colGroupRows[rowIndex];
@@ -1150,6 +1182,7 @@ var PivotModel = AbstractModel.extend({
groupId: groupId,
height: isLeaf ? (colGroupBys.length + 1 - rowIndex) : 1,
isLeaf: isLeaf,
label: rowIndex === 0 ? undefined : fields[colGroupBys[rowIndex - 1].split(':')[0]].string,
title: group.labels[group.labels.length - 1] || _t('Total'),
width: leafCount * measureCount * (2 * originCount - 1),
};
@@ -1159,10 +1192,11 @@ var PivotModel = AbstractModel.extend({
}
_.values(tree.directSubTrees).forEach(function (subTree) {
generateTreeHeaders(subTree);
generateTreeHeaders(subTree, fields);
});
}
generateTreeHeaders(this.colGroupTree);
generateTreeHeaders(this.colGroupTree, this.fields);
// blank top right cell for 'Total' group (if there is more that one leaf)
if (leafCounts[JSON.stringify(this.colGroupTree.root.values)] > 1) {
var groupId = [[], []];
@@ -1205,6 +1239,7 @@ var PivotModel = AbstractModel.extend({
var title = group.labels[group.labels.length - 1] || _t('Total');
var indent = group.labels.length;
var isLeaf = _.isEmpty(tree.directSubTrees);
var rowGroupBys = this._getGroupBys().rowGroupBys;
var subGroupMeasurements = columns.map(function (column) {
var colGroupId = column.groupId;
@@ -1226,6 +1261,7 @@ var PivotModel = AbstractModel.extend({
rows.push({
title: title,
label: indent === 0 ? undefined : this.fields[rowGroupBys[indent - 1].split(':')[0]].string,
groupId: rowGroupId,
indent: indent,
isLeaf: isLeaf,
@@ -1276,17 +1312,17 @@ var PivotModel = AbstractModel.extend({
_loadData: function () {
var self = this;
this.rowGroupTree = {root: {labels: [], values: []}, directSubTrees: {}};
this.colGroupTree = {root: {labels: [], values: []}, directSubTrees: {}};
this.rowGroupTree = { root: { labels: [], values: [] }, directSubTrees: {} };
this.colGroupTree = { root: { labels: [], values: [] }, directSubTrees: {} };
this.measurements = {};
this.counts = {};
var key = JSON.stringify([[],[]]);
var key = JSON.stringify([[], []]);
this.groupDomains = {};
this.groupDomains[key] = this.data.domains.slice(0);
var group = {rowValues: [], colValues: []};
var group = { rowValues: [], colValues: [] };
var groupBys = this._getGroupBys();
var leftDivisors = sections(groupBys.rowGroupBys);
var rightDivisors = sections(groupBys.colGroupBys);
@@ -1305,7 +1341,6 @@ var PivotModel = AbstractModel.extend({
*
* @private
* @param {Object} group
TO DO
* @param {Object[]} groupSubdivisions
*/
_prepareData: function (group, groupSubdivisions) {
@@ -1447,7 +1482,7 @@ var PivotModel = AbstractModel.extend({
return this._getNumberedLabel(value, fieldName);
}
if (fieldName && this.fields[fieldName] && (this.fields[fieldName].type === 'selection')) {
var selected = _.where(this.fields[fieldName].selection, {0: value})[0];
var selected = _.where(this.fields[fieldName].selection, { 0: value })[0];
return selected ? selected[1] : value;
}
return value;
@@ -1487,7 +1522,11 @@ var PivotModel = AbstractModel.extend({
// if group is known to be empty for the given origin,
// we don't need to fetch data fot that origin.
if (!self.counts[key] || self.counts[key][originIndex] > 0) {
var subGroup = {rowValues: group.rowValues, colValues: group.colValues, originIndex: originIndex};
var subGroup = {
rowValues: group.rowValues,
colValues: group.colValues,
originIndex: originIndex
};
divisors.forEach(function (divisor) {
acc.push(self._getGroupSubdivision(subGroup, divisor[0], divisor[1]));
});
@@ -1,319 +1,200 @@
odoo.define('web.PivotRenderer', function (require) {
"use strict";
"use strict";
var AbstractRenderer = require('web.AbstractRenderer');
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, {
'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',
}),
const { useState, onMounted, onPatched } = owl.hooks;
const { useExternalListener } = require('web.custom_hooks');
const OwlAbstractRenderer = require('web.AbstractRendererOwl');
const field_utils = require('web.field_utils');
/**
* @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.
* Here is a basic example of the structure of the Pivot Table:
*
* ┌─────────────────────────┬─────────────────────────────────────────────┬─────────────────┐
* │ │ - web.PivotHeader │ │
* │ ├──────────────────────┬──────────────────────┤ │
* │ │ + web.PivotHeader │ + web.PivotHeader │ │
* ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
* │ │ web.PivotMeasure │ web.PivotMeasure │ │
* ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
* │ ─ web.PivotHeader │ │ │ │
* ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
* │ + web.PivotHeader │ │ │ │
* ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
* │ + web.PivotHeader │ │ │ │
* └─────────────────────────┴──────────────────────┴──────────────────────┴─────────────────┘
*
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
this.enableLinking = params.enableLinking;
this.fieldWidgets = params.widgets || {};
this.paddingLeftHeaderTabWidth = config.device.isMobile ? 5 : 30;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
class PivotRenderer extends OwlAbstractRenderer {
/**
* @override
* @param {boolean} props.disableLinking Disallow opening records by clicking on a cell
* @param {Object} props.widgets Widgets defined in the arch
*/
constructor(parent, props) {
super(...arguments);
/**
* @private
* @param {string[]} groupBy list of 'fieldName[:period]'
* @returns {string[]} list of fields label
*/
_getGroupByLabels: function (groupBy) {
var self = this;
return _.map(groupBy, function (gb) {
return self.state.fields[gb.split(':')[0]].string;
});
},
/**
* @override
* @private
* @returns {Promise}
*/
_render: function () {
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 " +
"one measure and no active filter in the search bar."),
}));
return this._super.apply(this, arguments);
}
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 {jQuery} $thead
*/
_renderHeaders: function ($thead) {
// groupbyLabels is the list of col groupby fields label
var groupbyLabels = this._getGroupByLabels(this.state.colGroupBys);
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) {
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 {
cellParams['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' + (cell.isLeaf ? '_closed' : '_opened');
}
cellParams.class = className;
$tr.append($('<th>', cellParams));
});
$thead.append($tr);
});
},
/**
* @private
* @param {jQuery} $tbody
*/
_renderRows: function ($tbody) {
var self = this;
// 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;
},
{}
);
// 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',
this.state = useState({
activeNodeHeader: {
groupId: false,
isXAxis: false,
click: false
},
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',
};
}
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);
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
onMounted(() => this._updateTooltip());
/**
* 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
*/
_onCellValueClicked: function (ev) {
ev.preventDefault();
ev.stopPropagation();
onPatched(() => this._updateTooltip());
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,
if (!this.env.device.isMobile) {
useExternalListener(window, 'click', this._resetState);
}
});
},
});
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* Get the formatted value of the cell
*
* @private
* @param {Object} cell
* @returns {string} Formatted value
*/
_getFormattedValue(cell) {
const type = this.props.widgets[cell.measure] ||
(this.props.fields[cell.measure].type === 'many2one' ? 'integer' : this.props.fields[cell.measure].type);
const formatter = field_utils.format[type];
return formatter(cell.value, this.props.fields[cell.measure]);
}
/**
* Get the formatted variation of a cell
*
* @private
* @param {Object} cell
* @returns {string} Formatted variation
*/
_getFormattedVariation(cell) {
const value = cell.value;
return isNaN(value) ? '-' : field_utils.format.percentage(value, this.props.fields[cell.measure]);
}
/**
* Retrieves the padding of a left header
*
* @private
* @param {Object} cell
* @returns {Number} Padding
*/
_getPadding(cell) {
return 5 + cell.indent * 30;
}
/**
* Compute if a cell is active (with its groupId)
*
* @private
* @param {Array} groupId GroupId of a cell
* @param {Boolean} isXAxis true if the cell is on the x axis
* @returns {Boolean} true if the cell is active
*/
_isClicked(groupId, isXAxis) {
return _.isEqual(groupId, this.state.activeNodeHeader.groupId) && this.state.activeNodeHeader.isXAxis === isXAxis;
}
/**
* Reset the state of the node.
*
* @private
*/
_resetState() {
// This is a pourrav solution
if (this.isHeaderClicked) {
this.isHeaderClicked = false;
return;
}
this.state.activeNodeHeader = {
groupId: false,
isXAxis: false,
click: false
};
}
/**
* Configure the tooltips on the headers.
*
* @private
*/
_updateTooltip() {
$(this.el).find('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip();
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* Handles a click on a menu item in the dropdown to select a groupby.
*
* @private
* @param {Object} field
* @param {string} interval
*/
_onClickMenuGroupBy(field, interval) {
this.trigger('groupby_menu_selection', { field, interval });
}
/**
* Handles a click on a header node
*
* @private
* @param {Object} cell
* @param {string} type col or row
*/
_onHeaderClick(cell, type) {
const groupValues = cell.groupId[type === 'col' ? 1 : 0];
const groupByLength = type === 'col' ? this.props.colGroupBys.length : this.props.rowGroupBys.length;
if (cell.isLeaf && groupValues.length >= groupByLength) {
this.isHeaderClicked = true;
this.state.activeNodeHeader = {
groupId: cell.groupId,
isXAxis: type === 'col',
click: 'leftClick'
};
}
this.trigger(cell.isLeaf ? 'closed_header_click' : 'opened_header_click', { cell, type });
}
/**
* Hover the column in which the mouse is.
*
* @private
* @param {MouseEvent} ev
*/
_onMouseEnter(ev) {
var index = [...ev.currentTarget.parentNode.children].indexOf(ev.currentTarget);
if (ev.currentTarget.tagName === 'TH') {
index += 1;
}
this.el.querySelectorAll('td:nth-child(' + (index + 1) + ')').forEach(elt => elt.classList.add('o_cell_hover'));
}
/**
* Remove the hover on the columns.
*
* @private
*/
_onMouseLeave() {
this.el.querySelectorAll('.o_cell_hover').forEach(elt => elt.classList.remove('o_cell_hover'));
}
}
PivotRenderer.template = 'web.PivotRenderer';
return PivotRenderer;
return PivotRenderer;
});
+137 -124
View File
@@ -1,137 +1,150 @@
odoo.define('web.PivotView', function (require) {
"use strict";
/**
* The Pivot View is a view that represents data in a 'pivot grid' form. It
* aggregates data on 2 dimensions and displays the result, allows the user to
* 'zoom in' data.
*/
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');
var PivotRenderer = require('web.PivotRenderer');
var _t = core._t;
var _lt = core._lt;
var controlPanelViewParameters = require('web.controlPanelViewParameters');
var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
var PivotView = AbstractView.extend({
display_name: _lt('Pivot'),
icon: 'fa-table',
config: _.extend({}, AbstractView.prototype.config,{
Model: PivotModel,
Controller: PivotController,
Renderer: PivotRenderer,
}),
viewType: 'pivot',
searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
"use strict";
/**
* @override
* @param {Object} params
* The Pivot View is a view that represents data in a 'pivot grid' form. It
* aggregates data on 2 dimensions and displays the result, allows the user to
* 'zoom in' data.
*/
init: function (viewInfo, params) {
var self = this;
this._super.apply(this, arguments);
var activeMeasures = [];
var colGroupBys = [];
var rowGroupBys = [];
const AbstractView = require('web.AbstractView');
const config = require('web.config');
const core = require('web.core');
const PivotModel = require('web.PivotModel');
const PivotController = require('web.PivotController');
const PivotRenderer = require('web.PivotRenderer');
const RendererWrapper = require('web.RendererWrapper');
var measures = {};
var groupableFields = {};
var widgets = {};
const _t = core._t;
const _lt = core._lt;
this.fields.__count = {string: _t("Count"), type: "integer"};
_.each(this.fields, function (field, name) {
if ((name !== 'id') && (field.store === true)) {
if (_.contains(['integer', 'float', 'monetary'], field.type) ||
_.contains(params.additionalMeasures, name)) {
const controlPanelViewParameters = require('web.controlPanelViewParameters');
const GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
const PivotView = AbstractView.extend({
display_name: _lt('Pivot'),
icon: 'fa-table',
config: Object.assign({}, AbstractView.prototype.config, {
Model: PivotModel,
Controller: PivotController,
Renderer: PivotRenderer,
}),
viewType: 'pivot',
searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
/**
* @override
* @param {Object} params
* @param {Array} params.additionalMeasures
*/
init: function (viewInfo, params) {
this._super.apply(this, arguments);
const activeMeasures = []; // Store the defined active measures
const colGroupBys = []; // Store the defined group_by used on cols
const rowGroupBys = []; // Store the defined group_by used on rows
const measures = {}; // All the available measures
const groupableFields = {}; // The fields which can be used to group data
const widgets = {}; // Wigdets defined in the arch
const additionalMeasures = params.additionalMeasures || [];
this.fields.__count = { string: _t("Count"), type: "integer" };
//Compute the measures and the groupableFields
Object.keys(this.fields).forEach(name => {
const field = this.fields[name];
if (name !== 'id' && field.store === true) {
if (['integer', 'float', 'monetary'].includes(field.type) || additionalMeasures.includes(name)) {
measures[name] = field;
}
if (GROUPABLE_TYPES.includes(field.type)) {
groupableFields[name] = field;
}
}
if (_.contains(GROUPABLE_TYPES, field.type)) {
groupableFields[name] = field;
}
}
});
measures.__count = {string: _t("Count"), type: "integer"};
this.arch.children.forEach(function (field) {
var name = field.attrs.name;
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;
}
// add active measures to the measure list. This is very rarely
// necessary, but it can be useful if one is working with a
// functional field non stored, but in a model with an overrided
// read_group method. In this case, the pivot view could work, and
// 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' && !(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];
}
if (field.attrs.type === 'col') {
colGroupBys.push(name);
}
if (field.attrs.type === 'row') {
rowGroupBys.push(name);
}
});
if ((!activeMeasures.length) || this.arch.attrs.display_quantity) {
activeMeasures = ['__count'].concat(activeMeasures);
}
this.loadParams.measures = activeMeasures;
this.loadParams.colGroupBys = colGroupBys;
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.rendererParams.widgets = widgets;
this.rendererParams.enableLinking = !this.arch.attrs.disable_linking;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
this.controllerParams.measures = measures;
this.controllerParams.groupableFields = groupableFields;
// retrieve form and list view ids from the action to open those views
// when a data cell of the pivot view is clicked
this.controllerParams.views = [
_findView(params.actionViews, 'list'),
_findView(params.actionViews, 'form'),
];
function _findView(views, viewType) {
var view = _.find(views, function (view) {
return view.type === viewType;
});
return [view ? view.viewID : false, viewType];
}
},
});
measures.__count = { string: _t("Count"), type: "integer" };
return PivotView;
this.arch.children.forEach(field => {
let name = field.attrs.name;
// Remove invisible fields from the measures
if (field.attrs.invisible && py.eval(field.attrs.invisible)) {
delete measures[name];
return;
}
if (field.attrs.interval) {
name += ':' + field.attrs.interval;
}
if (field.attrs.widget) {
widgets[name] = field.attrs.widget;
}
// add active measures to the measure list. This is very rarely
// necessary, but it can be useful if one is working with a
// functional field non stored, but in a model with an overrided
// read_group method. In this case, the pivot view could work, and
// 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' && !(name in measures)) {
measures[name] = this.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] = this.fields[name];
}
if (field.attrs.type === 'col') {
colGroupBys.push(name);
}
if (field.attrs.type === 'row') {
rowGroupBys.push(name);
}
});
if ((!activeMeasures.length) || this.arch.attrs.display_quantity) {
activeMeasures.splice(0, 0, '__count');
}
this.loadParams.measures = activeMeasures;
this.loadParams.colGroupBys = config.device.isMobile ? [] : colGroupBys;
this.loadParams.rowGroupBys = rowGroupBys;
this.loadParams.fields = this.fields;
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
this.loadParams.groupableFields = groupableFields;
this.rendererParams.widgets = widgets;
this.rendererParams.disableLinking = this.arch.attrs.disable_linking;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
this.controllerParams.measures = measures;
// retrieve form and list view ids from the action to open those views
// when a data cell of the pivot view is clicked
this.controllerParams.views = [
_findView(params.actionViews, 'list'),
_findView(params.actionViews, 'form'),
];
function _findView(views, viewType) {
const view = views.find(view => {
return view.type === viewType;
});
return [view ? view.viewID : false, viewType];
}
},
/**
*
* @override
*/
getRenderer(parent, state) {
state = Object.assign(state || {}, this.rendererParams);
return new RendererWrapper(null, state, this.config.Renderer);
},
});
return PivotView;
});
@@ -1,5 +1,4 @@
.o_pivot {
overflow: auto;
.o_pivot_cell_value {
font-size: 1em;
@@ -91,10 +90,6 @@
}
}
.o_field_selection {
position: absolute;
}
.o_pivot_field_selection::after {
@include o-caret-right;
position: absolute;
-26
View File
@@ -469,9 +469,6 @@
</table>
</div>
<div t-name="PivotView" t-attf-class="o_pivot #{widget.enableLinking ? 'o_enable_linking' : ''}">
<div class="o_field_selection"/>
</div>
<t t-name="PivotView.buttons">
<div class="btn-group" role="toolbar" aria-label="Main actions">
<button class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
@@ -491,29 +488,6 @@
<button class="btn btn-secondary fa fa-download o_pivot_download" title="Download xls" aria-label="Download xls"/>
</div>
</t>
<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')">
<div role="menuitem" aria-haspopup="true" class="o_inline_dropdown">
<a href="#" t-att-data-field="field[0]" t-attf-class="dropdown-item o_pivot_field_selection#{field[2] ? ' disabled' : ''}">
<t t-esc="field[1].string"/>
</a>
<div class="dropdown-menu" role="menu">
<a role="menuitem" href="#" class="dropdown-item" t-att-data-interval="'day'" t-att-data-field="field[0]">Day</a>
<a role="menuitem" href="#" class="dropdown-item" t-att-data-interval="'week'" t-att-data-field="field[0]">Week</a>
<a role="menuitem" href="#" class="dropdown-item" t-att-data-interval="'month'" t-att-data-field="field[0]">Month</a>
<a role="menuitem" href="#" class="dropdown-item" t-att-data-interval="'quarter'" t-att-data-field="field[0]">Quarter</a>
<a role="menuitem" href="#" class="dropdown-item" t-att-data-interval="'year'" t-att-data-field="field[0]">Year</a>
</div>
</div>
</t>
<t t-else="">
<a href="#" role="menuitem" t-att-data-field="field[0]" t-attf-class="dropdown-item#{field[2] ? ' disabled' : ''}"><t t-esc="field[1].string"/></a>
</t>
</t>
</div>
</t>
<t t-name="FormSelection">
<div class="btn-group o_selection" aria-atomic="true">
+99
View File
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="web.PivotRenderer" class="o_pivot" owl="1">
<t t-if="!props.hasData || !props.measures.length" t-call="web.NoContentHelper">
<t t-set="description" t-value="'Try to add some records, or make sure
that there is at least one measure and
no active filter in the search bar'"/>
</t>
<table t-else="1" class="table-hover table-sm table-bordered" t-att-class="{ o_enable_linking: !props.disableLinking }">
<thead>
<tr t-foreach="props.table.headers" t-as="row" t-key="'header_' + row_index">
<t t-foreach="row" t-as="cell" t-key="'header_row_' + cell_index">
<t t-if="cell.measure" t-call="web.PivotMeasure"/>
<t t-elif="cell.isLeaf !== undefined" t-call="web.PivotHeader">
<t t-set="isXAxis" t-value="true"/>
</t>
<th t-else="1" t-att-colspan="cell.width" t-att-rowspan="cell.height"/>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="props.table.rows" t-as="row" t-key="'row_' + row_index">
<t t-call="web.PivotHeader">
<t t-set="isXAxis" t-value="false"/>
<t t-set="cell" t-value="row"/>
</t>
<t t-foreach="row.subGroupMeasurements" t-as="cell" t-key="'row_cell_' + cell_index">
<td class="o_pivot_cell_value text-right" t-att-class="{
o_empty: cell.value === undefined,
'font-weight-bold': cell.isBold,
}" t-on-click="trigger('open_view', cell)"
t-on-mouseover="_onMouseEnter" t-on-mouseout="_onMouseLeave">
<t t-if="cell.value !== undefined">
<div t-if="cell.originIndexes.length > 1" class="o_variation" t-att-class="{
o_positive: cell.value &gt; 0,
o_negative: cell.value &lt; 0,
o_null: cell.value === 0,
}" t-esc="_getFormattedVariation(cell)"/>
<div t-else="1" class="o_value" t-esc="_getFormattedValue(cell)"/>
</t>
</td>
</t>
</tr>
</tbody>
</table>
</div>
<t t-name="web.PivotHeader" owl="1">
<th t-att-colspan="isXAxis ? cell.width : undefined" t-att-rowspan="isXAxis ? cell.height : undefined" t-att-class="{
o_pivot_header_cell_closed: cell.isLeaf,
o_pivot_header_cell_opened: !cell.isLeaf,
}" t-attf-style="{{ isXAxis ? undefined : 'padding-left: ' + _getPadding(cell) + 'px;' }}" t-att-title="cell.label" t-on-click.self.prevent="_onHeaderClick(cell, isXAxis ? 'col' : 'row')">
<span t-on-click.self.prevent="_onHeaderClick(cell, isXAxis ? 'col' : 'row')" t-esc="cell.title"/>
<t t-if="_isClicked(cell.groupId, isXAxis)" t-call="web.PivotGroupBySelection"/>
</th>
</t>
<t t-name="web.PivotMeasure" owl="1">
<th class="text-muted" t-att-colspan="cell.width" t-att-rowspan="cell.height" t-att-class="{
o_pivot_origin_row: cell.originIndexes,
o_pivot_measure_row: !cell.originIndexes,
o_pivot_sort_order_asc: cell.order === 'asc',
o_pivot_sort_order_desc: cell.order === 'desc',
}" t-on-click.prevent="trigger('sort_rows',cell)" t-on-mouseover="_onMouseEnter" t-on-mouseout="_onMouseLeave">
<t t-esc="cell.title"/>
</th>
</t>
<div t-name="web.PivotGroupBySelection" class="dropdown" owl="1">
<div class="dropdown-menu o_pivot_field_menu show" data-display="static" role="menu">
<t t-foreach="props.selectionGroupBys" t-as="field" t-key="field.field.name">
<t t-if="(field.field.type === 'date') || (field.field.type === 'datetime')">
<div role="menuitem" aria-haspopup="true" class="o_inline_dropdown">
<a href="#"
t-on-click.prevent="_onClickMenuGroupBy(field, 'month')"
class="dropdown-item o_pivot_field_selection"
t-att-class="{ disabled: field.active }"
t-att-data-field="field.name"
>
<t t-esc="field.field.string"/>
</a>
<div class="dropdown-menu" role="menu">
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'day')">Day</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'week')">Week</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'month')">Month</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'quarter')">Quarter</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'year')">Year</a>
</div>
</div>
</t>
<a t-else="" href="#" role="menuitem" class="dropdown-item" t-att-data-field="field.name" t-att-class="{ disabled: field.active }" t-on-click.prevent="_onClickMenuGroupBy(field, false)">
<t t-esc="field.field.string"/>
</a>
</t>
</div>
</div>
</templates>
+47 -20
View File
@@ -3,6 +3,7 @@ odoo.define('web.pivot_tests', function (require) {
var core = require('web.core');
var PivotView = require('web.PivotView');
var PivotRenderer = require('web.PivotRenderer');
var testUtils = require('web.test_utils');
var testUtilsDom = require('web.test_utils_dom');
@@ -10,7 +11,7 @@ var _t = core._t;
var createActionManager = testUtils.createActionManager;
var createView = testUtils.createView;
var patchDate = testUtils.mock.patchDate;
const oldEnv = PivotRenderer.env;
/**
* Helper function that returns, given a pivot instance, the values of the
* table, separated by ','.
@@ -108,7 +109,7 @@ QUnit.module('Views', {
}]
},
};
}
},
}, function () {
QUnit.module('PivotView');
@@ -595,7 +596,6 @@ QUnit.module('Views', {
return this._super.apply(this, arguments);
},
});
assert.containsN(pivot, 'tbody tr', 3,
"should have 3 rows: 1 for the opened header, and 2 for data");
@@ -608,9 +608,9 @@ QUnit.module('Views', {
await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
assert.containsN(pivot, '.o_pivot_field_menu .dropdown-item[data-field="date"]', 6,
"should have the date field as proposition (Date, Day, Week, Month, Quarter and Year)");
assert.containsOnce(pivot, '.o_field_selection .dropdown-item[data-field="product_id"]',
assert.containsOnce(pivot, '.o_pivot_field_menu .dropdown-item[data-field="product_id"]',
"should have the product_id field as proposition");
assert.containsNone(pivot, '.o_field_selection .dropdown-item[data-field="non_stored_m2o"]:first',
assert.containsNone(pivot, '.o_pivot_field_menu .dropdown-item[data-field="non_stored_m2o"]',
"should not have the non_stored_m2o field as proposition");
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:first'));
@@ -641,7 +641,7 @@ QUnit.module('Views', {
// click on date by day
pivot.$('.dropdown-menu.show .o_inline_dropdown .dropdown-menu').toggle(); // unfold inline dropdown
await testUtils.nextTick();
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"][data-interval="day"]'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:contains("Day")'));
// open dropdown to zoom into second row
await testUtils.dom.clickLast(pivot.$('tbody th.o_pivot_header_cell_closed'));
@@ -1016,7 +1016,6 @@ QUnit.module('Views', {
// expand on date:days, product
await testUtils.pivot.reload(pivot, {groupBy: ['date:days', 'product_id']});
assert.containsN(pivot, 'tbody tr', 8,
"should have 7 rows (total + 3 for December and 2 for October and April)");
@@ -1030,12 +1029,14 @@ QUnit.module('Views', {
// expand all
def = testUtils.makeTestPromise();
await testUtils.dom.click(pivot.$buttons.find('.o_pivot_expand_button'));
await testUtils.nextTick();
def.resolve();
// await testUtils.returnAfterNextAnimationFrame();
await testUtils.nextTick();
assert.containsN(pivot, 'tbody tr', 8,
"should have 8 rows again");
pivot.destroy();
pivot.destroy();
});
QUnit.test('can download a file', async function (assert) {
@@ -1312,8 +1313,8 @@ QUnit.module('Views', {
});
// Set a column groupby
pivot.$('thead .o_pivot_header_cell_closed').click();
await testUtils.dom.click(pivot.$('.o_field_selection .dropdown-item[data-field=customer]'));
await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]'));
// Set a domain
await pivot.update({domain: [['product_id', '=', 37]], groupBy: [], context: {}});
@@ -1327,8 +1328,8 @@ QUnit.module('Views', {
'Column groupby not lost after first reload');
// Set a column groupby
pivot.$('thead .o_pivot_header_cell_closed').click();
await testUtils.dom.click(pivot.$('.o_field_selection .dropdown-item[data-field=product_id]'));
await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]'));
// Set a domain
await pivot.update({domain: [['product_id', '=', 41]], groupBy: [], context: {}});
@@ -1419,8 +1420,8 @@ QUnit.module('Views', {
});
// Set a column groupby
pivot.$('thead .o_pivot_header_cell_closed').click();
pivot.$('.o_field_selection .dropdown-item[data-field=customer]').click();
await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]'));
// Set a domain for empty results
await pivot.update({domain: [['id', '=', false]]});
@@ -1701,7 +1702,7 @@ QUnit.module('Views', {
await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
// click on date by month
pivot.$('.dropdown-menu.show .o_inline_dropdown .dropdown-menu').toggle(); // unfold inline dropdown
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"][data-interval="month"]'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:contains("Month")'));
assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '2211',
'should have loaded the proper data');
@@ -1765,7 +1766,7 @@ QUnit.module('Views', {
// Set a column groupby
await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
await testUtils.dom.click(pivot.$('.o_field_selection .dropdown-item[data-field=customer]:first'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]:first'));
// Set a Row groupby
await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
@@ -1974,7 +1975,7 @@ QUnit.module('Views', {
// with data, with row groupby
await testUtils.dom.click(pivot.$('.o_pivot .o_pivot_header_cell_closed').eq(2));
await testUtils.dom.click(pivot.$('.o_pivot .o_field_selection a[data-field="product_id"]'));
await testUtils.dom.click(pivot.$('.o_pivot .o_pivot_field_menu a[data-field="product_id"]'));
values = [
"13", "0", "100%", "0", "19", "-100%", "13", "19", "-31.58%",
"12", "0", "100%", "12", "0" , "100%",
@@ -2517,7 +2518,7 @@ QUnit.module('Views', {
},
});
await testUtils.dom.click($('td').eq(1));
await testUtils.dom.click($('div .o_value')[1]);
unpatchDate();
pivot.destroy();
@@ -2569,7 +2570,7 @@ QUnit.module('Views', {
QUnit.test('group bys added via control panel and expand Header do not stack', async function (assert) {
assert.expect(8);
delete this.data.partner.fields['date']
delete this.data.partner.fields['date'];
var pivot = await createView({
View: PivotView,
@@ -2620,7 +2621,7 @@ QUnit.module('Views', {
].join(''),
"The row headers should be as expected"
);
// Set a Row groupby
await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed').eq(0));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first'));
@@ -2671,5 +2672,31 @@ QUnit.module('Views', {
pivot.destroy();
});
QUnit.test('display only one dropdown menu', async function (assert) {
assert.expect(1);
var pivot = await createView({
View: PivotView,
model: 'partner',
data: this.data,
arch: '<pivot>' +
'<field name="foo" type="measure"/>' +
'</pivot>',
viewOptions: {
additionalMeasures: ['product_id'],
},
});
await testUtils.dom.clickFirst(pivot.$('th.o_pivot_header_cell_closed'));
await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first'));
// Click on the two dropdown
await testUtils.dom.click(pivot.$('th.o_pivot_header_cell_closed')[0]);
await testUtils.dom.click(pivot.$('th.o_pivot_header_cell_closed')[1]);
assert.containsOnce(pivot, '.o_pivot_field_menu', 'Only one dropdown should be displayed at a time');
pivot.destroy();
});
});
});