summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorPierre Rousseau <pro@odoo.com>2019-12-03 09:58:52 +0000
committerVincent Schippefilt <vsc@odoo.com>2019-12-09 12:53:24 +0000
commit46cf65d68e9c13193dff4b563b9b0bbb2199dfc1 (patch)
tree4338abceab39a837283a4075587b9c12d616ba67 /addons/web/static
parenteef10097aefe04c3a89d5226727a45d540bf8f63 (diff)
[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>
Diffstat (limited to 'addons/web/static')
-rw-r--r--addons/web/static/src/js/services/config.js2
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js614
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js63
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js463
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js263
-rw-r--r--addons/web/static/src/scss/pivot_view.scss5
-rw-r--r--addons/web/static/src/xml/base.xml26
-rw-r--r--addons/web/static/src/xml/pivot.xml99
-rw-r--r--addons/web/static/tests/views/pivot_tests.js67
9 files changed, 793 insertions, 809 deletions
diff --git a/addons/web/static/src/js/services/config.js b/addons/web/static/src/js/services/config.js
index 91c3f1c764c..7ded43e291c 100644
--- a/addons/web/static/src/js/services/config.js
+++ b/addons/web/static/src/js/services/config.js
@@ -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.
*/
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index 71427baff1b..606367d4d3a 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -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
+ * 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.
*/
- 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);
- },
+ const ControllerAdapter = require('web.ControllerAdapter');
+ const core = require('web.core');
+ const framework = require('web.framework');
+ const session = require('web.session');
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
+ const _t = core._t;
+ const QWeb = core.qweb;
- /**
- * 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 () {
- var state = this.model.get({raw: true});
- return {
- context: {
- pivot_measures: state.measures,
- pivot_column_groupby: state.colGroupBys,
- pivot_row_groupby: state.rowGroupBys,
+ 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._updateButtons();
- }
- },
+ this.$buttons.appendTo($node);
+ this._updateButtons();
+ }
+ },
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
+ //--------------------------------------------------------------------------
+ // 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 () {
- 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];
+ /**
+ * 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);
- 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 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.$groupBySelection.html(QWeb.render('PivotView.GroupBySelection', {
- fields: fields
- }));
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
- 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 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;
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
+ const group = {
+ rowValues: groupId[0],
+ colValues: groupId[1],
+ type: type
+ };
- /**
- * 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();
+ 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();
- 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});
+ 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;
+ }
- var groupValues = type === 'row' ? groupId[0] : groupId[1];
- var groupBys = type === 'row' ?
- state.rowGroupBys :
- state.colGroupBys;
+ const context = Object.assign({}, this.model.data.context);
+ Object.keys(context).forEach(x => {
+ if (x === 'group_by' || x.startsWith('search_default_')) {
+ delete context[x];
+ }
+ });
- 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;
- }
+ const group = {
+ rowValues: cell.groupId[0],
+ colValues: cell.groupId[1],
+ originIndex: cell.originIndexes[0]
+ };
- 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);
+ const 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});
- },
-});
+ 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 });
+ },
+ });
-return PivotController;
+ return PivotController;
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index cb09f4a40f9..4669c5af7ec 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -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]));
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js
index 3affc975225..6345d6ac0cb 100644
--- a/addons/web/static/src/js/views/pivot/pivot_renderer.js
+++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js
@@ -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);
+
+ this.state = useState({
+ activeNodeHeader: {
+ groupId: false,
+ isXAxis: false,
+ click: false
+ },
+ });
- /**
- * @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);
- }
+ onMounted(() => this._updateTooltip());
- this.renderElement(); // in case we come from no content helper
- this.$el.toggleClass('o_enable_linking', this.enableLinking);
+ onPatched(() => this._updateTooltip());
- 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();
+ if (!this.env.device.isMobile) {
+ useExternalListener(window, 'click', this._resetState);
+ }
+ }
- 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);
+ //----------------------------------------------------------------------
+ // 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]);
+ }
- 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;
+ /**
+ * 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]);
+ }
- // 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;
- },
- {}
- );
+ /**
+ * Retrieves the padding of a left header
+ *
+ * @private
+ * @param {Object} cell
+ * @returns {Number} Padding
+ */
+ _getPadding(cell) {
+ return 5 + cell.indent * 30;
+ }
- // groupbyLabels is the list of row groupby fields label
- var groupbyLabels = this._getGroupByLabels(this.state.rowGroupBys);
+ /**
+ * 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;
+ }
- 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'),
- }));
+ /**
+ * 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
+ };
+ }
- 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);
- });
- },
+ /**
+ * Configure the tooltips on the headers.
+ *
+ * @private
+ */
+ _updateTooltip() {
+ $(this.el).find('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip();
+ }
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
- /**
- * 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();
- var $target = $(ev.currentTarget);
- if ($target.hasClass('o_empty') || !this.enableLinking) {
- return;
+ /**
+ * 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 });
}
- 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
+ /**
+ * 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 });
}
- 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');
+ /**
+ * 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'));
+ }
- 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();
+ /**
+ * Remove the hover on the columns.
+ *
+ * @private
+ */
+ _onMouseLeave() {
+ this.el.querySelectorAll('.o_cell_hover').forEach(elt => elt.classList.remove('o_cell_hover'));
+ }
+ }
- 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';
+ PivotRenderer.template = 'web.PivotRenderer';
- this.trigger_up('sort_rows', {
- sortedColumn: {
- groupId: groupId,
- measure: measure,
- order: order,
- originIndexes: originIndexes,
- }
- });
- },
-});
+ return PivotRenderer;
-return PivotRenderer;
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index 1b878cf3530..17cc478ba9b 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -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 = [];
-
- var measures = {};
- var groupableFields = {};
- var widgets = {};
-
- 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 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');
+
+ const _t = core._t;
+ const _lt = core._lt;
+
+ 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;
+ }
+ }
+ });
+ measures.__count = { string: _t("Count"), type: "integer" };
+
+
+ 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 (_.contains(GROUPABLE_TYPES, field.type)) {
- groupableFields[name] = field;
+ if (field.attrs.type === 'row') {
+ rowGroupBys.push(name);
}
+ });
+ if ((!activeMeasures.length) || this.arch.attrs.display_quantity) {
+ activeMeasures.splice(0, 0, '__count');
}
- });
- 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];
+ 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];
}
- 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];
- }
- },
-});
-
-return PivotView;
+ },
+
+ /**
+ *
+ * @override
+ */
+ getRenderer(parent, state) {
+ state = Object.assign(state || {}, this.rendererParams);
+ return new RendererWrapper(null, state, this.config.Renderer);
+ },
+ });
+
+ return PivotView;
});
diff --git a/addons/web/static/src/scss/pivot_view.scss b/addons/web/static/src/scss/pivot_view.scss
index d67607fe558..3dbfc809d77 100644
--- a/addons/web/static/src/scss/pivot_view.scss
+++ b/addons/web/static/src/scss/pivot_view.scss
@@ -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;
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 8854426367c..c3f57baba47 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -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">
diff --git a/addons/web/static/src/xml/pivot.xml b/addons/web/static/src/xml/pivot.xml
new file mode 100644
index 00000000000..6e85f1170de
--- /dev/null
+++ b/addons/web/static/src/xml/pivot.xml
@@ -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>
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index f6fd77349b6..d40fee98cef 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -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();
+ });
});
});