diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 12bc57970f4..ccfbd9b8499 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -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", 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 - */ - 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; }); 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); - /** - * @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 = $(''); - var $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 = $(''); - 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($('', 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 = $(''); - var paddingLeft = 5 + row.indent * self.paddingLeftHeaderTabWidth; - $tr.append($('', { - 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 = $('
', { - class: 'o_value', - html: formatter(measurement.value, measureField), - }); - } - cellParams.html = $value; - } else { - cellParams.class += ' o_empty' - } - $tr.append($('', 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; }); 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 = []; + 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; }); 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 @@
-
-
-
- - -
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 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + +
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: '' + + '' + + '', + 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(); + }); }); });