From d1e227b07b24b8e121fe9f56b10a6e6689d4294e Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Tue, 7 Sep 2021 06:23:00 +0000 Subject: [PATCH] [IMP] web,*: new PivotView component Part-of: odoo/odoo#73311 Co-authored-by: Aaron Bohy Co-authored-by: Bruno Boi Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Julien Mougenot Co-authored-by: Lucas Perais Co-authored-by: Jorge Pinna Puissant --- addons/board/static/tests/dashboard_tests.js | 26 +- addons/event/views/event_views.xml | 2 +- .../hr_expense/static/src/js/expense_views.js | 2 - .../report/hr_leave_employee_type_report.xml | 2 +- .../im_livechat_report_channel_views.xml | 2 +- .../im_livechat_report_operator_views.xml | 2 +- .../views/mailing_contact_views.xml | 2 +- addons/project/__manifest__.py | 4 +- .../project/report/project_report_views.xml | 2 +- addons/project/static/src/js/project_pivot.js | 13 - .../static/src/js/project_pivot_view.js | 12 + .../src/js/project_rating_pivot_view.js | 34 + .../static/src/js/project_rating_reporting.js | 34 - .../purchase/report/purchase_report_views.xml | 2 +- addons/purchase/views/purchase_views.xml | 2 +- addons/rating/views/rating_rating_views.xml | 2 +- ...ct_profitability_report_analysis_views.xml | 2 +- addons/web/__manifest__.py | 1 - addons/web/static/src/core/utils/numbers.js | 22 + .../legacy/js/views/pivot/pivot_renderer.js | 6 +- addons/web/static/src/legacy/xml/pivot.xml | 20 +- .../src/views/pivot/pivot_arch_parser.js | 76 + .../src/views/pivot/pivot_group_by_menu.js | 59 + .../web/static/src/views/pivot/pivot_model.js | 1636 ++++++ .../static/src/views/pivot/pivot_renderer.js | 140 + .../static/src/views/pivot/pivot_renderer.xml | 81 + .../web/static/src/views/pivot/pivot_view.js | 186 + .../scss => views/pivot}/pivot_view.scss | 16 +- .../web/static/src/views/pivot/pivot_view.xml | 46 + .../tests/core/debug/debug_manager_tests.js | 2 + .../web/static/tests/helpers/mock_server.js | 11 +- .../web/static/tests/helpers/mock_services.js | 11 + .../legacy/control_panel/search_bar_tests.js | 4 + .../static/tests/legacy/views/pivot_tests.js | 68 +- .../tests/legacy/views/search_panel_tests.js | 10 +- addons/web/static/tests/views/helpers.js | 2 +- .../static/tests/views/pivot_view_tests.js | 4931 +++++++++++++++++ .../webclient/actions/window_action_tests.js | 3 + 38 files changed, 7322 insertions(+), 154 deletions(-) delete mode 100644 addons/project/static/src/js/project_pivot.js create mode 100644 addons/project/static/src/js/project_pivot_view.js create mode 100644 addons/project/static/src/js/project_rating_pivot_view.js delete mode 100644 addons/project/static/src/js/project_rating_reporting.js create mode 100644 addons/web/static/src/core/utils/numbers.js create mode 100644 addons/web/static/src/views/pivot/pivot_arch_parser.js create mode 100644 addons/web/static/src/views/pivot/pivot_group_by_menu.js create mode 100644 addons/web/static/src/views/pivot/pivot_model.js create mode 100644 addons/web/static/src/views/pivot/pivot_renderer.js create mode 100644 addons/web/static/src/views/pivot/pivot_renderer.xml create mode 100644 addons/web/static/src/views/pivot/pivot_view.js rename addons/web/static/src/{legacy/scss => views/pivot}/pivot_view.scss (88%) create mode 100644 addons/web/static/src/views/pivot/pivot_view.xml create mode 100644 addons/web/static/tests/views/pivot_view_tests.js diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 1473823946d..6643f1accb6 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -7,6 +7,15 @@ var ListController = require('web.ListController'); var testUtils = require('web.test_utils'); var ListRenderer = require('web.ListRenderer'); var pyUtils = require('web.py_utils'); +const registry = require("@web/core/registry"); +const { makeFakeUserService } = require("@web/../tests/helpers/mock_services"); +const { + toggleFilterMenu, + toggleMenuItem, + toggleMenuItemOption, + toggleComparisonMenu, + toggleFavoriteMenu, +} = require("@web/../tests/search/helpers"); const cpHelpers = testUtils.controlPanel; const { createWebClient, doAction } = require("@web/../tests/webclient/helpers"); @@ -1053,7 +1062,8 @@ QUnit.test('click on a cell of pivot view inside dashboard', async function (ass form.destroy(); }); -QUnit.test( +// TODO: The button "Add to my dashboard" is not yet developped on the new control panel search view +QUnit.skip( "correctly save the time ranges of a reporting view in comparison mode", async function (assert) { assert.expect(1); @@ -1090,6 +1100,8 @@ QUnit.test( } }; + registry.category("services").add("user", makeFakeUserService()); + const webClient = await createWebClient({ serverData, mockRPC }); await doAction(webClient, { @@ -1100,16 +1112,16 @@ QUnit.test( }); // filter on July 2020 - await cpHelpers.toggleFilterMenu(webClient); - await cpHelpers.toggleMenuItem(webClient, "Date"); - await cpHelpers.toggleMenuItemOption(webClient, "Date", "July"); + await toggleFilterMenu(webClient); + await toggleMenuItem(webClient, "Date"); + await toggleMenuItemOption(webClient, "Date", "July"); // compare July 2020 to June 2020 - await cpHelpers.toggleComparisonMenu(webClient); - await cpHelpers.toggleMenuItem(webClient, 0); + await toggleComparisonMenu(webClient); + await toggleMenuItem(webClient, 0); // add the view to the dashboard - await cpHelpers.toggleFavoriteMenu(webClient); + await toggleFavoriteMenu(webClient); await testUtils.dom.click($(".o_add_to_board > button")); await testUtils.fields.editInput($(".o_add_to_board input"), "a name"); diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index cbdfad72cea..863f27e684f 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -648,7 +648,7 @@ event.registration.pivot event.registration - + diff --git a/addons/hr_expense/static/src/js/expense_views.js b/addons/hr_expense/static/src/js/expense_views.js index a031f647de1..f192111dcc4 100644 --- a/addons/hr_expense/static/src/js/expense_views.js +++ b/addons/hr_expense/static/src/js/expense_views.js @@ -3,14 +3,12 @@ odoo.define('hr_expense.expenses.tree', function (require) { var DocumentUploadMixin = require('hr_expense.documents.upload.mixin'); var KanbanController = require('web.KanbanController'); var KanbanView = require('web.KanbanView'); - var PivotView = require('web.PivotView'); var ListController = require('web.ListController'); var ListView = require('web.ListView'); var viewRegistry = require('web.view_registry'); var core = require('web.core'); var ListRenderer = require('web.ListRenderer'); var KanbanRenderer = require('web.KanbanRenderer'); - var PivotRenderer = require('web.PivotRenderer'); var session = require('web.session'); const config = require('web.config'); diff --git a/addons/hr_holidays/report/hr_leave_employee_type_report.xml b/addons/hr_holidays/report/hr_leave_employee_type_report.xml index 15ddf1b329e..5d4b07de1ca 100644 --- a/addons/hr_holidays/report/hr_leave_employee_type_report.xml +++ b/addons/hr_holidays/report/hr_leave_employee_type_report.xml @@ -19,7 +19,7 @@ hr.leave.employee.type.report.view.pivot hr.leave.employee.type.report - + diff --git a/addons/im_livechat/report/im_livechat_report_channel_views.xml b/addons/im_livechat/report/im_livechat_report_channel_views.xml index 1de3ca5c327..6678bb3bcfd 100644 --- a/addons/im_livechat/report/im_livechat_report_channel_views.xml +++ b/addons/im_livechat/report/im_livechat_report_channel_views.xml @@ -6,7 +6,7 @@ im_livechat.report.channel.pivot im_livechat.report.channel - + diff --git a/addons/im_livechat/report/im_livechat_report_operator_views.xml b/addons/im_livechat/report/im_livechat_report_operator_views.xml index e2059fed6bc..9104952d152 100644 --- a/addons/im_livechat/report/im_livechat_report_operator_views.xml +++ b/addons/im_livechat/report/im_livechat_report_operator_views.xml @@ -6,7 +6,7 @@ im_livechat.report.operator.pivot im_livechat.report.operator - + diff --git a/addons/mass_mailing/views/mailing_contact_views.xml b/addons/mass_mailing/views/mailing_contact_views.xml index 0de5fa81382..51574af0f0d 100644 --- a/addons/mass_mailing/views/mailing_contact_views.xml +++ b/addons/mass_mailing/views/mailing_contact_views.xml @@ -208,7 +208,7 @@ mailing.contact 10 - + diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 2b659091cee..b717a2fc762 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -68,9 +68,9 @@ 'project/static/src/js/project_graph_view.js', 'project/static/src/js/project_kanban.js', 'project/static/src/js/project_list.js', - 'project/static/src/js/project_pivot.js', + 'project/static/src/js/project_pivot_view.js', 'project/static/src/js/project_rating_graph_view.js', - 'project/static/src/js/project_rating_reporting.js', + 'project/static/src/js/project_rating_pivot_view.js', 'project/static/src/js/project_task_kanban_examples.js', 'project/static/src/js/tours/project.js', 'project/static/src/js/project_calendar.js', diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index 9c696950104..bb8808c6cf9 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -5,7 +5,7 @@ report.project.task.user.pivot report.project.task.user - + diff --git a/addons/project/static/src/js/project_pivot.js b/addons/project/static/src/js/project_pivot.js deleted file mode 100644 index 355eae34c15..00000000000 --- a/addons/project/static/src/js/project_pivot.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @odoo-module **/ - -import PivotView from 'web.PivotView'; -import { ProjectControlPanel } from '@project/js/project_control_panel'; -import viewRegistry from 'web.view_registry'; - -export const ProjectPivotView = PivotView.extend({ - config: Object.assign({}, PivotView.prototype.config, { - ControlPanel: ProjectControlPanel, - }), -}); - -viewRegistry.add('project_pivot', ProjectPivotView); diff --git a/addons/project/static/src/js/project_pivot_view.js b/addons/project/static/src/js/project_pivot_view.js new file mode 100644 index 00000000000..c5f71c95222 --- /dev/null +++ b/addons/project/static/src/js/project_pivot_view.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { PivotView } from "@web/views/pivot/pivot_view"; +import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +class ProjectPivotView extends PivotView {} +ProjectPivotView.components = { ...PivotView, ControlPanel: ProjectControlPanel }; + +viewRegistry.add("project_pivot", ProjectPivotView); diff --git a/addons/project/static/src/js/project_rating_pivot_view.js b/addons/project/static/src/js/project_rating_pivot_view.js new file mode 100644 index 00000000000..5d54afceb19 --- /dev/null +++ b/addons/project/static/src/js/project_rating_pivot_view.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { PivotView } from "@web/views/pivot/pivot_view"; +import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +const MEASURE_STRINGS = { + parent_res_id: _lt("Project"), + rating: _lt("Rating Value (/5)"), + res_id: _lt("Task"), +}; + +class ProjectRatingArchParser extends PivotArchParser { + parse() { + const archInfo = super.parse(...arguments); + for (const [key, val] of Object.entries(MEASURE_STRINGS)) { + archInfo.fieldAttrs[key] = { + ...archInfo.fieldAttrs[key], + string: val.toString(), + }; + } + return archInfo; + } +} + +// Would it be not better achiedved by using a proper arch directly? + +class ProjectRatingPivotView extends PivotView {} +ProjectRatingPivotView.archParser = ProjectRatingArchParser; + +viewRegistry.add("project_rating_pivot", ProjectRatingPivotView); diff --git a/addons/project/static/src/js/project_rating_reporting.js b/addons/project/static/src/js/project_rating_reporting.js deleted file mode 100644 index 164e751b5ad..00000000000 --- a/addons/project/static/src/js/project_rating_reporting.js +++ /dev/null @@ -1,34 +0,0 @@ -/** @odoo-module **/ - -import { _t } from 'web.core'; -import PivotController from 'web.PivotController'; -import PivotView from 'web.PivotView'; -import viewRegistry from 'web.view_registry'; - -const ProjectPivotController = PivotController.extend({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - const measures = JSON.parse(JSON.stringify(this.measures)); - if ('res_id' in measures) { - measures.res_id.string = _t('Task'); - } - if ('parent_res_id' in measures) { - measures.parent_res_id.string = _t('Project'); - } - if ('rating' in measures) { - measures.rating.string = _t('Rating Value (/5)'); - } - this.measures = measures; - }, -}); - -const ProjectPivotView = PivotView.extend({ - config: Object.assign({}, PivotView.prototype.config, { - Controller: ProjectPivotController, - }), -}); - -viewRegistry.add('project_rating_pivot', ProjectPivotView); diff --git a/addons/purchase/report/purchase_report_views.xml b/addons/purchase/report/purchase_report_views.xml index 3ab59218394..0b87d3a0801 100644 --- a/addons/purchase/report/purchase_report_views.xml +++ b/addons/purchase/report/purchase_report_views.xml @@ -4,7 +4,7 @@ product.month.pivot purchase.report - + diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 579271a88fe..9c643b28c82 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -111,7 +111,7 @@ purchase.order.pivot purchase.order - + diff --git a/addons/rating/views/rating_rating_views.xml b/addons/rating/views/rating_rating_views.xml index 4bc1f3065e0..1df89804d3b 100644 --- a/addons/rating/views/rating_rating_views.xml +++ b/addons/rating/views/rating_rating_views.xml @@ -107,7 +107,7 @@ rating.rating.pivot rating.rating - + diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml index 660986b2ef9..8061f942299 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml +++ b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml @@ -5,7 +5,7 @@ project.profitability.report.pivot project.profitability.report - + diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 29e0e0485bd..84add579bda 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -114,7 +114,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/scss/fields.scss', 'web/static/src/legacy/scss/file_upload.scss', 'web/static/src/legacy/scss/views.scss', - 'web/static/src/legacy/scss/pivot_view.scss', 'web/static/src/legacy/scss/graph_view.scss', 'web/static/src/legacy/scss/form_view.scss', 'web/static/src/legacy/scss/list_view.scss', diff --git a/addons/web/static/src/core/utils/numbers.js b/addons/web/static/src/core/utils/numbers.js new file mode 100644 index 00000000000..01947258343 --- /dev/null +++ b/addons/web/static/src/core/utils/numbers.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ + +/** + * @param {Number} value + * @param {Number} comparisonValue + * @returns {Number} + */ +export function computeVariation(value, comparisonValue) { + if (isNaN(value) || isNaN(comparisonValue)) { + return NaN; + } + if (comparisonValue === 0) { + if (value === 0) { + return 0; + } else if (value > 0) { + return 1; + } else { + return -1; + } + } + return (value - comparisonValue) / Math.abs(comparisonValue); +} diff --git a/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js b/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js index 09e2e44085c..d10a6b62350 100644 --- a/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js @@ -49,7 +49,7 @@ } } - PivotCustomGroupByItem.template = "web.PivotCustomGroupByItem"; + PivotCustomGroupByItem.template = "web.legacy.PivotCustomGroupByItem"; PivotCustomGroupByItem.props = { fields: Array }; export class PivotGroupByMenu extends DropdownMenu { @@ -98,7 +98,7 @@ } } - PivotGroupByMenu.template = "web.PivotGroupByMenu"; + PivotGroupByMenu.template = "web.legacy.PivotGroupByMenu"; PivotGroupByMenu.components = { PivotCustomGroupByItem }; PivotGroupByMenu.props = { ...DropdownMenu.props, @@ -303,7 +303,7 @@ } } - PivotRenderer.template = 'web.PivotRenderer'; + PivotRenderer.template = 'web.legacy.PivotRenderer'; PivotRenderer.components = { PivotGroupByMenu }; export default PivotRenderer; diff --git a/addons/web/static/src/legacy/xml/pivot.xml b/addons/web/static/src/legacy/xml/pivot.xml index 650ebb3b587..7984c9f215c 100644 --- a/addons/web/static/src/legacy/xml/pivot.xml +++ b/addons/web/static/src/legacy/xml/pivot.xml @@ -1,7 +1,7 @@ -
+
@@ -13,8 +13,8 @@ - - + + @@ -23,7 +23,7 @@ - + @@ -54,17 +54,17 @@
- + - + - + + - + - + _onToggleCustomGroupbyItem diff --git a/addons/web/static/src/views/pivot/pivot_arch_parser.js b/addons/web/static/src/views/pivot/pivot_arch_parser.js new file mode 100644 index 00000000000..63ee6cdd292 --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_arch_parser.js @@ -0,0 +1,76 @@ +/** @odoo-module **/ + +import { evaluateExpr } from "@web/core/py_js/py"; +import { XMLParser } from "@web/core/utils/xml"; +import { archParseBoolean } from "@web/views/helpers/utils"; + +export class PivotArchParser extends XMLParser { + parse(arch) { + const archInfo = { + activeMeasures: [], // store the defined active measures + colGroupBys: [], // store the defined group_by used on cols + defaultOrder: null, + fieldAttrs: {}, + rowGroupBys: [], // store the defined group_by used on rows + widgets: {}, // wigdets defined in the arch + }; + + this.visitXML(arch, (node) => { + switch (node.tagName) { + case "pivot": { + if (node.hasAttribute("disable_linking")) { + archInfo.disableLinking = archParseBoolean( + node.getAttribute("disable_linking") + ); + } + if (node.hasAttribute("default_order")) { + archInfo.defaultOrder = node.getAttribute("default_order"); + } + if (node.hasAttribute("string")) { + archInfo.title = node.getAttribute("string"); + } + if (node.hasAttribute("display_quantity")) { + archInfo.displayQuantity = archParseBoolean( + node.getAttribute("display_quantity") + ); + } + break; + } + case "field": { + let fieldName = node.getAttribute("name"); // exists (rng validation) + + archInfo.fieldAttrs[fieldName] = {}; + if (node.hasAttribute("string")) { + archInfo.fieldAttrs[fieldName].string = node.getAttribute("string"); + } + if (node.hasAttribute("invisible")) { + const isInvisible = Boolean(evaluateExpr(node.getAttribute("invisible"))); + if (isInvisible) { + archInfo.fieldAttrs[fieldName].isInvisible = true; + break; + } + } + + if (node.hasAttribute("interval")) { + fieldName += ":" + node.getAttribute("interval"); + } + if (node.hasAttribute("widget")) { + archInfo.widgets[fieldName] = node.getAttribute("widget"); + } + if (node.getAttribute("type") === "measure" || node.hasAttribute("operator")) { + archInfo.activeMeasures.push(fieldName); + } + if (node.getAttribute("type") === "col") { + archInfo.colGroupBys.push(fieldName); + } + if (node.getAttribute("type") === "row") { + archInfo.rowGroupBys.push(fieldName); + } + break; + } + } + }); + + return archInfo; + } +} diff --git a/addons/web/static/src/views/pivot/pivot_group_by_menu.js b/addons/web/static/src/views/pivot/pivot_group_by_menu.js new file mode 100644 index 00000000000..f012225aecd --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_group_by_menu.js @@ -0,0 +1,59 @@ +/** @odoo-module */ + +import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu"; +import { getIntervalOptions } from "@web/search/utils/dates"; + +export class PivotGroupByMenu extends GroupByMenu { + /** + * @override + * @returns {Object[]} + */ + get items() { + let items = super.items.filter((i) => !i.custom); + if (items.length === 0) { + items = this.fields; + } + + // Add custom groupbys + let groupNumber = 1 + Math.max(0, ...items.map(({ groupNumber: n }) => n)); + for (const [fieldName, customGroupBy] of this.props.customGroupBys.entries()) { + items.push({ ...customGroupBy, name: fieldName, groupNumber: groupNumber++ }); + } + + return items.map((item) => ({ + ...item, + id: item.id || item.name, + fieldName: item.fieldName || item.name, + description: item.description || item.string, + isActive: false, + options: + item.options || ["date", "datetime"].includes(item.type) + ? getIntervalOptions() + : undefined, + })); + } + /** + * @override + * @param {CustomEvent} ev + */ + onAddCustomGroup(ev) { + this.trigger("add-custom-group-by", { + fieldName: ev.detail.fieldName, + groupId: this.props.cell.groupId, + }); + } + /** + * @override + * @param {CustomEvent} ev + */ + onGroupBySelected(ev) { + const { itemId, optionId } = ev.detail.payload; + const item = this.items.find(({ id }) => id === itemId); + // Augment payload data + Object.assign(ev.detail.payload, { + fieldName: item.fieldName, + interval: optionId, + groupId: this.props.cell.groupId, + }); + } +} diff --git a/addons/web/static/src/views/pivot/pivot_model.js b/addons/web/static/src/views/pivot/pivot_model.js new file mode 100644 index 00000000000..3055dfffb60 --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_model.js @@ -0,0 +1,1636 @@ +/** @odoo-module **/ + +import { Domain } from "@web/core/domain"; +import { cartesian, sections, sortBy, symmetricalDifference } from "@web/core/utils/arrays"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { computeVariation } from "@web/core/utils/numbers"; +import { DEFAULT_INTERVAL } from "@web/search/utils/dates"; +import { Model } from "@web/views/helpers/model"; +import { buildSampleORM } from "@web/views/helpers/sample_server"; +import { computeReportMeasures, processMeasure } from "@web/views/helpers/utils"; + +/** + * Pivot Model + * + * The pivot model keeps an in-memory representation of the pivot table that is + * displayed on the screen. The exact layout of this representation is not so + * simple, because a pivot table is at its core a 2-dimensional object, but + * with a 'tree' component: some rows/cols can be expanded so we zoom into the + * structure. + * + * However, we need to be able to manipulate the data in a somewhat efficient + * way, and to transform it into a list of lines to be displayed by the renderer. + * + * Basicaly the pivot table presents aggregated values for various groups of records + * in one domain. If a comparison is asked for, two domains are considered. + * + * Let us consider a simple example and let us fix the vocabulary (let us suppose we are in June 2020): + * ___________________________________________________________________________________________________________________________________________ + * | | Total | + * | |_____________________________________________________________________________________________________________________| + * | | Sale Team 1 | Sale Team 2 | | + * | |_______________________________________|______________________________________|______________________________________| + * | | Sales total | Sales total | Sales total | + * | |_______________________________________|______________________________________|______________________________________| + * | | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * | Total | 85 | 110 | 29.4% | 40 | 30 | -25% | 125 | 140 | 12% | + * | Europe | 25 | 35 | 40% | 40 | 30 | -25% | 65 | 65 | 0% | + * | Brussels | 0 | 15 | 100% | 30 | 30 | 0% | 30 | 45 | 50% | + * | Paris | 25 | 20 | -20% | 10 | 0 | -100% | 35 | 20 | -42.8% | + * | North America | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * | Washington | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * + * + * META DATA: + * + * In the above pivot table, the records have been grouped using the fields + * + * continent_id, city_id + * + * for rows and + * + * sale_team_id + * + * for columns. + * + * The measure is the field 'sales_total'. + * + * Two domains are considered: 'May 2020' and 'June 2020'. + * + * In the model, + * + * - rowGroupBys is the list [continent_id, city_id] + * - colGroupBys is the list [sale_team_id] + * - measures is the list [sales_total] + * - domains is the list [d1, d2] with d1 and d2 domain expressions + * for say sale_date in May 2020 and June 2020, for instance + * d1 = [['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31]] + * - origins is the list ['May 2020', 'June 2020'] + * + * DATA: + * + * Recall that a group is constituted by records (in a given domain) + * that have the same (raw) values for a list of fields. + * Thus the group itself is identified by this list and the domain. + * In comparison mode, the same group (forgetting the domain part or 'originIndex') + * can be eventually found in the two domains. + * This defines the way in which the groups are identified or not. + * + * In the above table, (forgetting the domain) the following groups are found: + * + * the 'row groups' + * - Total + * - Europe + * - America + * - Europe, Brussels + * - Europe, Paris + * - America, Washington + * + * the 'col groups' + * + * - Total + * - Sale Team 1 + * - Sale Team 2 + * + * and all non trivial combinations of row groups and col groups + * + * - Europe, Sale Team 1 + * - Europe, Brussels, Sale Team 2 + * - America, Washington, Sale Team 1 + * - ... + * + * The list of fields is created from the concatenation of two lists of fields, the first in + * + * [], [f1], [f1, f2], ... [f1, f2, ..., fn] for [f1, f2, ..., fn] the full list of groupbys + * (called rowGroupBys) used to create row groups + * + * In the example: [], [continent_id], [continent_id, city_id]. + * + * and the second in + * [], [g1], [g1, g2], ... [g1, g2, ..., gm] for [g1, g2, ..., gm] the full list of groupbys + * (called colGroupBys) used to create col groups. + * + * In the example: [], [sale_team_id]. + * + * Thus there are (n+1)*(m+1) lists of fields possible. + * + * In the example: 6 lists possible, namely [], + * [continent_id], [sale_team_id], + * [continent_id, sale_team_id], [continent_id, city_id], + * [continent_id, city_id, sale_team_id] + * + * A given list is thus of the form [f1,..., fi, g1,..., gj] or better [[f1,...,fi], [g1,...,gj]] + * + * For each list of fields possible and each domain considered, one read_group is done + * and gives results of the form (an exception for list []) + * + * g = { + * f1: v1, ..., fi: vi, + * g1: w1, ..., gj: wj, + * m1: x1, ..., mk: xk, + * __count: c, + * __domain: d + * } + * + * where v1,...,vi,w1,...,Wj are 'values' for the corresponding fields and + * m1,...,mk are the fields selected as measures. + * + * For example, g = { + * continent_id: [1, 'Europe'] + * sale_team_id: [1, 'Sale Team 1'] + * sales_count: 25, + * __count: 4 + * __domain: [ + * ['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31], + * ['continent_id', '=', 1], + * ['sale_team_id', '=', 1] + * ] + * } + * + * Thus the above group g is fully determined by [[v1,...,vi], [w1,...,wj]] and the base domain + * or the corresponding 'originIndex'. + * + * When j=0, g corresponds to a row group (or also row header) and is of the form [[v1,...,vi], []] or more simply [v1,...vi] + * (not forgetting the list [v1,...vi] comes from left). + * When i=0, g corresponds to a col group (or col header) and is of the form [[], [w1,...,wj]] or more simply [w1,...,wj]. + * + * A generic group g as above [[v1,...,vi], [w1,...,wj]] corresponds to the two headers [[v1,...,vi], []] + * and [[], [w1,...,wj]]. + * + * Here is a description of the data structure manipulated by the pivot model. + * + * Five objects contain all the data from the read_groups + * + * - rowGroupTree: contains information on row headers + * the nodes correspond to the groups of the form [[v1,...,vi], []] + * The root is [[], []]. + * A node [[v1,...,vl], []] has as direct children the nodes of the form [[v1,...,vl,v], []], + * this means that a direct child is obtained by grouping records using the single field fi+1 + * + * The structure at each level is of the form + * + * { + * root: { + * values: [v1,...,vl], + * labels: [la1,...,lal] + * }, + * directSubTrees: { + * v => { + * root: { + * values: [v1,...,vl,v] + * labels: [label1,...,labell,label] + * }, + * directSubTrees: {...} + * }, + * v' => {...}, + * ... + * } + * } + * + * (directSubTrees is a Map instance) + * + * In the example, the rowGroupTree is: + * + * { + * root: { + * values: [], + * labels: [] + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1], + * labels: ['Europe'], + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1, 1], + * labels: ['Europe', 'Brussels'], + * }, + * directSubTrees: new Map(), + * }, + * 2 => { + * root: { + * values: [1, 2], + * labels: ['Europe', 'Paris'], + * }, + * directSubTrees: new Map(), + * }, + * }, + * }, + * 2 => { + * root: { + * values: [2], + * labels: ['America'], + * }, + * directSubTrees: { + * 3 => { + * root: { + * values: [2, 3], + * labels: ['America', 'Washington'], + * } + * directSubTrees: new Map(), + * }, + * }, + * }, + * }, + * } + * + * - colGroupTree: contains information on col headers + * The same as above with right instead of left + * + * - measurements: contains information on measure values for all the groups + * + * the object keys are of the form JSON.stringify([[v1,...,vi], [w1,...,wj]]) + * and values are arrays of length equal to number of origins containing objects of the form + * {m1: x1,...,mk: xk} + * The structure looks like + * + * { + * JSON.stringify([[], []]): [{m1: x1,...,mk: xk}, {m1: x1',...,mk: xk'},...] + * .... + * JSON.stringify([[v1,...,vi], [w1,...,wj]]): [{m1: y1',...,mk: yk'}, {m1: y1',...,mk: yk'},...], + * .... + * JSON.stringify([[v1,...,vn], [w1,...,wm]]): [{m1: z1',...,mk: zk'}, {m1: z1',...,mk: zk'},...], + * } + * Thus the structure contains all information for all groups and all origins on measure values. + * + * + * this.measurments["[[], []]"][0]['foo'] gives the value of the measure 'foo' for the group 'Total' and the + * first domain (origin). + * + * In the example: + * { + * "[[], []]": [{'sales_total': 125}, {'sales_total': 140}] (total/total) + * ... + * "[[1, 2], [2]]": [{'sales_total': 10}, {'sales_total': 0}] (Europe/Paris/Sale Team 2) + * ... + * } + * + * - counts: contains information on the number of records in each groups + * The structure is similar to the above but the arrays contains numbers (counts) + * - groupDomains: + * The structure is similar to the above but the arrays contains domains + * + * With this light data structures, all manipulation done by the model are eased and redundancies are limited. + * Each time a rendering or an export of the data has to be done, the pivot table is generated by the getTable function. + */ + +/** + * @typedef Meta + * @property {Object} meta + * @property {string[]} activeMeasures + * @property {string[]} colGroupBys + * @property {boolean} disableLinking + * @property {Object} fields + * @property {Object} measures + * @property {string} resModel + * @property {string[]} rowGroupBys + * @property {string} title + * @property {boolean} useSampleModel + * @property {Object} widgets + * @property {Map} customGroupBys + * @property {string[]} expandedRowGroupBys + * @property {string[]} expandedColGroupBys + * @property {Object} sortedColumn + */ + +/** + * @typedef Data + * @property {Object} colGroupTree + * @property {Object} rowGroupTree + * @property {Object} groupDomains + * @property {Object} measurements + * @property {Object} counts + * @property {Object} numbering + */ + +/** + * @typedef SearchParams + * @property {Array[]} domain + * @property {Object} context + * @property {string[]} groupBy + * @property {Array[]} domains + * @property {string[]} origins + */ + +/** + * @typedef Config + * @property {Meta} meta + * @property {Data} data + * @property {SearchParams} searchParams + */ + +export class PivotModel extends Model { + /** + * @override + * @param {Object} params.meta + * @param {string[]} params.meta.activeMeasures + * @param {string[]} params.meta.colGroupBys + * @param {Object} params.meta.fields + * @param {Object[]} params.meta.measures + * @param {string} params.meta.resModel + * @param {string[]} params.meta.rowGroupBys + * @param {string|null} params.meta.defaultOrder + * @param {boolean} params.meta.disableLinking + * @param {boolean} params.meta.useSampleModel + * @param {Map} [params.meta.customGroupBys={}] + * @param {string[]} [params.meta.expandedColGroupBys=[]] + * @param {string[]} [params.meta.expandedRowGroupBys=[]] + * @param {Object|null} [params.meta.sortedColumn=null] + * @param {Object} [params.data] previously exported data + */ + setup(params, { orm, user }) { + this.realORM = orm; + this.orm2Use = this.realORM; + this.user = user; + this.keepLast = new KeepLast(); + + let sortedColumn = params.meta.sortedColumn || null; + if (!sortedColumn && params.meta.defaultOrder) { + const defaultOrder = params.meta.defaultOrder.split(" "); + sortedColumn = { + groupId: [[], []], + measure: defaultOrder[0], + order: defaultOrder[1] ? defaultOrder[1] : "asc", + }; + } + + const meta = Object.assign({}, params.meta, { + customGroupBys: params.meta.customGroupBys || new Map(), + expandedRowGroupBys: params.meta.expandedRowGroupBys || [], + expandedColGroupBys: params.meta.expandedColGroupBys || [], + sortedColumn, + }); + this.meta = this._buildMeta(meta); + + this.data = params.data || { + colGroupTree: null, + rowGroupTree: null, + groupDomains: {}, + measurements: {}, + counts: {}, + numbering: {}, + }; + + this.searchParams = { + context: {}, + domain: [], + groupBy: [], + + domains: null, // not the same as searchParams.domains from cp + origins: null, // generated from searchParams.domains from cp + }; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a groupBy to rowGroupBys or colGroupBys according to provided type. + * + * @param {Object} params + * @param {Array[]} params.groupId + * @param {string} params.fieldName + * @param {'row'|'col'} params.type + * @param {boolean} [params.custom=false] + * @param {string} [params.interval] + */ + async addGroupBy(params) { + const { groupId, fieldName, type, custom } = params; + let { interval } = params; + const meta = this._buildMeta(); + if (custom && !meta.customGroupBys.has(fieldName)) { + const field = meta.fields[fieldName]; + if (!interval && ["date", "datetime"].includes(field.type)) { + interval = DEFAULT_INTERVAL; + } + meta.customGroupBys.set(fieldName, { + ...field, + id: fieldName, + }); + } + + let groupBy = fieldName; + if (interval) { + groupBy = `${groupBy}:${interval}`; + } + if (type === "row") { + meta.expandedRowGroupBys.push(groupBy); + } else { + meta.expandedColGroupBys.push(groupBy); + } + const config = { meta, data: this.data, searchParams: this.searchParams }; + await this._expandGroup(groupId, type, config); + this.meta = meta; + this.notify(); + } + /** + * Close the group with id given by groupId. A type must be specified + * in case groupId is [[], []] (the id of the group 'Total') because this + * group is present in both colGroupTree and rowGroupTree. + * + * @param {Array[]} groupId + * @param {'row'|'col'} type + */ + closeGroup(groupId, type) { + this._cancelPreviousOperation(); + let groupBys; + let expandedGroupBys; + let keyPart; + let group; + let tree; + if (type === "row") { + groupBys = this.meta.rowGroupBys; + expandedGroupBys = this.meta.expandedRowGroupBys; + tree = this.data.rowGroupTree; + group = this._findGroup(this.data.rowGroupTree, groupId[0]); + keyPart = 0; + } else { + groupBys = this.meta.colGroupBys; + expandedGroupBys = this.meta.expandedColGroupBys; + tree = this.data.colGroupTree; + group = this._findGroup(this.data.colGroupTree, groupId[1]); + keyPart = 1; + } + + const groupIdPart = groupId[keyPart]; + const range = groupIdPart.map((_, index) => index); + function keep(key) { + const idPart = JSON.parse(key)[keyPart]; + return ( + range.some((index) => groupIdPart[index] !== idPart[index]) || + idPart.length === groupIdPart.length + ); + } + function omitKeys(object) { + const newObject = {}; + for (const key in object) { + if (keep(key)) { + newObject[key] = object[key]; + } + } + return newObject; + } + this.data.measurements = omitKeys(this.data.measurements); + this.data.counts = omitKeys(this.data.counts); + this.data.groupDomains = omitKeys(this.data.groupDomains); + + group.directSubTrees.clear(); + delete group.sortedKeys; + var newGroupBysLength = this._getTreeHeight(tree) - 1; + if (newGroupBysLength <= groupBys.length) { + expandedGroupBys.splice(0); + groupBys.splice(newGroupBysLength); + } else { + expandedGroupBys.splice(newGroupBysLength - groupBys.length); + } + this.notify(); + } + /** + * Reload the view with the current rowGroupBys and colGroupBys + * This is the easiest way to expand all the groups that are not expanded + */ + async expandAll() { + const config = { meta: this.meta, data: this.data, searchParams: this.searchParams }; + this.data = await this._loadData(config); + this.notify(); + } + /** + * Expand a group by using groupBy to split it and trigger a re-rendering. + * + * @param {Object} group + * @param {'row'|'col'} type + */ + async expandGroup(groupId, type) { + const config = { meta: this.meta, data: this.data, searchParams: this.searchParams }; + await this._expandGroup(groupId, type, config); + this.notify(); + } + /** + * Export model data in a form suitable for an easy encoding of the pivot + * table in excell. + * + * @returns {Object} + */ + exportData() { + const measureCount = this.meta.activeMeasures.length; + const originCount = this.searchParams.origins.length; + + const table = this.getTable(); + + // process headers + const headers = table.headers; + let colGroupHeaderRows; + let measureRow = []; + let originRow = []; + + function processHeader(header) { + const inTotalColumn = header.groupId[1].length === 0; + return { + title: header.title, + width: header.width, + height: header.height, + is_bold: !!header.measure && inTotalColumn, + }; + } + + if (originCount > 1) { + colGroupHeaderRows = headers.slice(0, headers.length - 2); + measureRow = headers[headers.length - 2].map(processHeader); + originRow = headers[headers.length - 1].map(processHeader); + } else { + colGroupHeaderRows = headers.slice(0, headers.length - 1); + measureRow = headers[headers.length - 1].map(processHeader); + } + + // remove the empty headers on left side + colGroupHeaderRows[0].splice(0, 1); + + colGroupHeaderRows = colGroupHeaderRows.map((headerRow) => { + return headerRow.map(processHeader); + }); + + // process rows + const tableRows = table.rows.map((row) => { + return { + title: row.title, + indent: row.indent, + values: row.subGroupMeasurements.map((measurement) => { + let value = measurement.value; + if (value === undefined) { + value = ""; + } else if (measurement.originIndexes.length > 1) { + // in that case the value is a variation and a + // number between 0 and 1 + value = value * 100; + } + return { + is_bold: measurement.isBold, + value: value, + }; + }), + }; + }); + + return { + model: this.meta.resModel, + title: this.meta.title, + col_group_headers: colGroupHeaderRows, + measure_headers: measureRow, + origin_headers: originRow, + rows: tableRows, + measure_count: measureCount, + origin_count: originCount, + }; + } + /** + * Swap the pivot columns and the rows. It is a synchronous operation. + */ + flip() { + this._cancelPreviousOperation(); + + // swap the data: the main column and the main row + let temp = this.data.rowGroupTree; + this.data.rowGroupTree = this.data.colGroupTree; + this.data.colGroupTree = temp; + + // we need to update the record metadata: (expanded) row and col groupBys + temp = this.meta.rowGroupBys; + this.meta.rowGroupBys = this.meta.colGroupBys; + this.meta.colGroupBys = temp; + temp = this.meta.expandedColGroupBys; + this.meta.expandedColGroupBys = this.meta.expandedRowGroupBys; + this.meta.expandedRowGroupBys = temp; + + function twistKey(key) { + return JSON.stringify(JSON.parse(key).reverse()); + } + + function twist(object) { + const newObject = {}; + Object.keys(object).forEach((key) => { + const value = object[key]; + newObject[twistKey(key)] = value; + }); + return newObject; + } + + this.data.measurements = twist(this.data.measurements); + this.data.counts = twist(this.data.counts); + this.data.groupDomains = twist(this.data.groupDomains); + + this.notify(); + } + /** + * Returns a domain representation of a group + * + * @param {Object} group + * @param {Array} group.colValues + * @param {Array} group.rowValues + * @param {number} group.originIndex + * @returns {Array[]} + */ + getGroupDomain(group) { + const config = { meta: this.meta, data: this.data, searchParams: this.searchParams }; + return this._getGroupDomain(group, config); + } + /** + * Returns a description of the pivot table. + * + * @returns {Object} + */ + getTable() { + const headers = this._getTableHeaders(); + return { + headers: headers, + rows: this._getTableRows(this.data.rowGroupTree, headers[headers.length - 1]), + }; + } + /** + * Returns the total number of columns of the pivot table. + * + * @returns {integer} + */ + getTableWidth() { + var leafCounts = this._getLeafCounts(this.data.colGroupTree); + return leafCounts[JSON.stringify(this.data.colGroupTree.root.values)] + 2; + } + /** + * @returns {boolean} true iff there's no data in the table + */ + hasData() { + const config = { meta: this.meta, data: this.data, searchParams: this.searchParams }; + return this._hasData(config); + } + /** + * @override + * + * @param {Object} searchParams + * @param {Object} params.context + * @param {Array[]} params.domain + * @param {string[]} params.groupBy + * @param {string[]} params.measures + * @param {Object} [params.domains] + */ + async load(searchParams) { + this.orm2Use = this.realORM; + searchParams = JSON.parse(JSON.stringify(searchParams)); //This to prevent errors in the Dashboard + + const activeMeasures = + processMeasure(searchParams.context.pivot_measures) || this.meta.activeMeasures; + const meta = this._buildMeta({ activeMeasures }); + if (!this.reload) { + meta.rowGroupBys = + searchParams.context.pivot_row_groupby || + (searchParams.groupBy.length ? searchParams.groupBy : meta.rowGroupBys); + this.reload = true; + } else { + meta.rowGroupBys = searchParams.groupBy.length + ? searchParams.groupBy + : searchParams.context.pivot_row_groupby || meta.rowGroupBys; + } + meta.colGroupBys = searchParams.context.pivot_column_groupby || this.meta.colGroupBys; + + const { domains, origins } = this._computeDerivedParams(searchParams); + searchParams.domains = domains; + searchParams.origins = origins; + + if (JSON.stringify(meta.rowGroupBys) !== JSON.stringify(this.meta.rowGroupBys)) { + meta.expandedRowGroupBys = []; + } + if (JSON.stringify(meta.colGroupBys) !== JSON.stringify(this.meta.colGroupBys)) { + meta.expandedColGroupBys = []; + } + + meta.measures = computeReportMeasures( + meta.fields, + meta.fieldAttrs, + meta.activeMeasures, + meta.additionalMeasures + ); + const config = { meta, data: this.data, searchParams }; + const data = await this._loadData(config); + const oldConfig = { meta: this.meta, data: this.data, searchParams: this.searchParams }; + if (this._hasData(oldConfig)) { + if (symmetricalDifference(meta.rowGroupBys, this.meta.rowGroupBys).length === 0) { + this._pruneTree(data.rowGroupTree, this.data.rowGroupTree); + } + if (symmetricalDifference(meta.colGroupBys, this.meta.colGroupBys).length === 0) { + this._pruneTree(data.colGroupTree, this.data.colGroupTree); + } + } + this.data = data; + this.meta = meta; + this.searchParams = searchParams; + } + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * @param {Object} sortedColumn + * @param {number[]} sortedColumn.groupId + */ + sortRows(sortedColumn) { + this._cancelPreviousOperation(); + + const config = { meta: this.meta, data: this.data, searchParams: this.searchParams }; + this._sortRows(sortedColumn, config); + + this.notify(); + } + /** + * Toggle the active state for a given measure, then reload the data + * if this turns out to be necessary. + * + * @param {string} fieldName + * @returns {Promise} + */ + async toggleMeasure(fieldName) { + const meta = this._buildMeta(); + const index = meta.activeMeasures.indexOf(fieldName); + if (index !== -1) { + this._cancelPreviousOperation(); + // in this case, we already have all data in memory, no need to + // actually reload a lesser amount of information + meta.activeMeasures.splice(index, 1); + } else { + meta.activeMeasures.push(fieldName); + const config = { meta, data: this.data, searchParams: this.searchParams }; + this.data = await this._loadData(config); + } + this.meta = meta; + this.notify(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add labels/values in the provided groupTree. A new leaf is created in + * the groupTree with a root object corresponding to the group with given + * labels/values. + * + * @private + * @param {Object} groupTree, either this.data.rowGroupTree or this.data.colGroupTree + * @param {string[]} labels + * @param {Array} values + */ + _addGroup(groupTree, labels, values) { + let tree = groupTree; + // we assume here that the group with value value.slice(value.length - 2) has already been added. + values.slice(0, values.length - 1).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + tree.directSubTrees.set(values[values.length - 1], { + root: { + labels: labels, + values: values, + }, + directSubTrees: new Map(), + }); + } + /** + * Return a copy of this.meta, extended with optional params. This is useful + * for async methods that need to modify this.meta, but it can't be done in + * place directly for the model to be concurrency proof (so they work on a + * copy and commit it at the end). + * + * @private + * @param {Object} params + * @returns {Object} + */ + _buildMeta(params) { + const meta = Object.assign({}, this.meta, params); + meta.activeMeasures = [...meta.activeMeasures]; + meta.colGroupBys = [...meta.colGroupBys]; + meta.rowGroupBys = [...meta.rowGroupBys]; + meta.expandedColGroupBys = [...meta.expandedColGroupBys]; + meta.expandedRowGroupBys = [...meta.expandedRowGroupBys]; + meta.customGroupBys = new Map([...meta.customGroupBys]); + // shallow copy sortedColumn because we never modify groupId in place + meta.sortedColumn = meta.sortedColumn ? { ...meta.sortedColumn } : null; + Object.defineProperty(meta, "fullColGroupBys", { + get() { + return meta.colGroupBys.concat(meta.expandedColGroupBys); + }, + }); + Object.defineProperty(meta, "fullRowGroupBys", { + get() { + return meta.rowGroupBys.concat(meta.expandedRowGroupBys); + }, + }); + return meta; + } + /** + * Used in synchronous operations that need to prevent a possible previous + * ongoing asynchronous operation that could commit new information leading + * to a non valid state model. + */ + _cancelPreviousOperation() { + this.keepLast.add(Promise.resolve()); + } + /** + * Expand a group by using groupBy to split it. + * + * @private + * @param {Object} group + * @param {'row'|'col'} type + * @param {Config} config + */ + async _expandGroup(groupId, type, config) { + const { meta } = config; + const group = { + rowValues: groupId[0], + colValues: groupId[1], + type: type, + }; + const groupValues = type === "row" ? groupId[0] : groupId[1]; + const groupBys = type === "row" ? meta.fullRowGroupBys : meta.fullColGroupBys; + if (groupValues.length >= groupBys.length) { + throw new Error("Cannot expand group"); + } + const groupBy = groupBys[groupValues.length]; + let leftDivisors; + let rightDivisors; + if (group.type === "row") { + leftDivisors = [[groupBy]]; + rightDivisors = sections(meta.fullColGroupBys); + } else { + leftDivisors = sections(meta.fullRowGroupBys); + rightDivisors = [[groupBy]]; + } + const divisors = cartesian(leftDivisors, rightDivisors); + delete group.type; + await this._subdivideGroup(group, divisors, config); + } + /** + * Find a group with given values in the provided groupTree, either + * this.rowGrouptree or this.data.colGroupTree. + * + * @private + * @param {Object} groupTree + * @param {Array} values + * @returns {Object} + */ + _findGroup(groupTree, values) { + let tree = groupTree; + values.slice(0, values.length).forEach((value) => { + tree = tree.directSubTrees.get(value); + }); + return tree; + } + /** + * In case originIndex is an array of length 1, thus a single origin + * index, returns the given measure for a group determined by the id + * groupId and the origin index. + * If originIndexes is an array of length 2, we compute the variation + * ot the measure values for the groups determined by groupId and the + * different origin indexes. + * + * @private + * @param {Array[]} groupId + * @param {string} measure + * @param {number[]} originIndexes + * @param {Config} config + * @returns {number} + */ + _getCellValue(groupId, measure, originIndexes, config) { + var key = JSON.stringify(groupId); + if (!config.data.measurements[key]) { + return; + } + var values = originIndexes.map((originIndex) => { + return config.data.measurements[key][originIndex][measure]; + }); + if (originIndexes.length > 1) { + return computeVariation(values[1], values[0]); + } else { + return values[0]; + } + } + /** + * Returns a domain representation of a group + * + * @private + * @param {Object} group + * @param {Array} group.colValues + * @param {Array} group.rowValues + * @param {number} group.originIndex + * @param {Config} config + * @returns {Array[]} + */ + _getGroupDomain(group, config) { + const { data } = config; + var key = JSON.stringify([group.rowValues, group.colValues]); + return data.groupDomains[key][group.originIndex]; + } + /** + * Returns the group sanitized labels. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @param {Config} config + * @returns {string[]} + */ + _getGroupLabels(group, groupBys, config) { + return groupBys.map((groupBy) => { + return this._sanitizeLabel(group[groupBy], groupBy, config); + }); + } + /** + * Returns a promise that returns the annotated read_group results + * corresponding to a partition of the given group obtained using the given + * rowGroupBy and colGroupBy. + * + * @private + * @param {Object} group + * @param {string[]} rowGroupBy + * @param {string[]} colGroupBy + * @param {Config} config + */ + async _getGroupSubdivision(group, rowGroupBy, colGroupBy, config) { + const groupDomain = this._getGroupDomain(group, config); + const measureSpecs = this._getMeasureSpecs(config); + const groupBy = rowGroupBy.concat(colGroupBy); + const options = { lazy: false }; + const subGroups = await this.orm2Use.readGroup( + config.meta.resModel, + groupDomain, + measureSpecs, + groupBy, + options, + config.searchParams.context + ); + return { + group: group, + subGroups: subGroups, + rowGroupBy: rowGroupBy, + colGroupBy: colGroupBy, + }; + } + /** + * Returns the group sanitized values. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {Array} + */ + _getGroupValues(group, groupBys) { + return groupBys.map((groupBy) => { + return this._sanitizeValue(group[groupBy]); + }); + } + /** + * Returns the leaf counts of each group inside the given tree. + * + * @private + * @param {Object} tree + * @returns {Object} keys are group ids + */ + _getLeafCounts(tree) { + const leafCounts = {}; + let leafCount; + if (!tree.directSubTrees.size) { + leafCount = 1; + } else { + leafCount = [...tree.directSubTrees.values()].reduce((acc, subTree) => { + const subLeafCounts = this._getLeafCounts(subTree); + Object.assign(leafCounts, subLeafCounts); + return acc + leafCounts[JSON.stringify(subTree.root.values)]; + }, 0); + } + + leafCounts[JSON.stringify(tree.root.values)] = leafCount; + return leafCounts; + } + /** + * Returns the group sanitized measure values for the measures in + * this.meta.activeMeasures (that migth contain '__count', not really a fieldName). + * + * @private + * @param {Object} group + * @param {Config} config + * @returns {Array} + */ + _getMeasurements(group, config) { + const { meta, searchParams } = config; + return meta.activeMeasures.reduce((measurements, measureName) => { + var measurement = group[measureName]; + if (measurement instanceof Array) { + // case field is many2one and used as measure and groupBy simultaneously + measurement = 1; + } + if (meta.measures[measureName].type === "boolean" && measurement instanceof Boolean) { + measurement = measurement ? 1 : 0; + } + if (searchParams.origins.length > 1 && !measurement) { + measurement = 0; + } + measurements[measureName] = measurement; + return measurements; + }, {}); + } + /** + * Returns a description of the measures row of the pivot table + * + * @private + * @param {Object[]} columns for which measure cells must be generated + * @returns {Object[]} + */ + _getMeasuresRow(columns) { + const sortedColumn = this.meta.sortedColumn || {}; + const measureRow = []; + + columns.forEach((column) => { + this.meta.activeMeasures.forEach((measureName) => { + const measureCell = { + groupId: column.groupId, + height: 1, + measure: measureName, + title: this.meta.measures[measureName].string, + width: 2 * this.searchParams.origins.length - 1, + }; + if ( + sortedColumn.measure === measureName && + JSON.stringify(sortedColumn.groupId) === JSON.stringify(column.groupId) // FIXME + ) { + measureCell.order = sortedColumn.order; + } + measureRow.push(measureCell); + }); + }); + + return measureRow; + } + /** + * Returns the list of measure specs associated with meta.activeMeasures, i.e. + * a measure 'fieldName' becomes 'fieldName:groupOperator' where + * groupOperator is the value specified on the field 'fieldName' for + * the key group_operator. + * + * @private + * @param {Config} config + * @return {string[]} + */ + _getMeasureSpecs(config) { + const { meta } = config; + return meta.activeMeasures.reduce((acc, measure) => { + if (measure === "__count") { + acc.push(measure); + return acc; + } + const field = this.meta.fields[measure]; + if (field.type === "many2one") { + field.group_operator = "count_distinct"; + } + if (field.group_operator === undefined) { + throw new Error( + "No aggregate function has been provided for the measure '" + measure + "'" + ); + } + acc.push(measure + ":" + field.group_operator); + return acc; + }, []); + } + /** + * Make sure that the labels of different many2one values are distinguished + * by numbering them if necessary. + * + * @private + * @param {Array} label + * @param {string} fieldName + * @param {Config} config + * @returns {string} + */ + _getNumberedLabel(label, fieldName, config) { + const { data } = config; + const id = label[0]; + const name = label[1]; + data.numbering[fieldName] = data.numbering[fieldName] || {}; + data.numbering[fieldName][name] = data.numbering[fieldName][name] || {}; + const numbers = data.numbering[fieldName][name]; + numbers[id] = numbers[id] || Object.keys(numbers).length + 1; + return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); + } + /** + * Returns a description of the origins row of the pivot table + * + * @private + * @param {Object[]} columns for which origin cells must be generated + * @returns {Object[]} + */ + _getOriginsRow(columns) { + const sortedColumn = this.meta.sortedColumn || {}; + const originRow = []; + + columns.forEach((column) => { + const groupId = column.groupId; + const measure = column.measure; + const isSorted = + sortedColumn.measure === measure && + JSON.stringify(sortedColumn.groupId) === JSON.stringify(groupId); // FIXME + const isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1]; + const isSortedByVariation = isSorted && sortedColumn.originIndexes[1]; + + this.searchParams.origins.forEach((origin, originIndex) => { + const originCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex], + title: origin, + width: 1, + }; + if (isSortedByOrigin && sortedColumn.originIndexes[0] === originIndex) { + originCell.order = sortedColumn.order; + } + originRow.push(originCell); + + if (originIndex > 0) { + const variationCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex - 1, originIndex], + title: this.env._t("Variation"), + width: 1, + }; + if (isSortedByVariation && sortedColumn.originIndexes[1] === originIndex) { + variationCell.order = sortedColumn.order; + } + originRow.push(variationCell); + } + }); + }); + + return originRow; + } + /** + * Returns the list of header rows of the pivot table: the col group rows + * (depending on the col groupbys), the measures row and optionnaly the + * origins row (if there are more than one origins). + * + * @private + * @returns {Object[]} + */ + _getTableHeaders() { + const _t = this.env._t; + const colGroupBys = this.meta.fullColGroupBys; + const height = colGroupBys.length + 1; + const measureCount = this.meta.activeMeasures.length; + const originCount = this.searchParams.origins.length; + const leafCounts = this._getLeafCounts(this.data.colGroupTree); + let headers = []; + const measureColumns = []; // used to generate the measure cells + + // 1) generate col group rows (total row + one row for each col groupby) + const colGroupRows = new Array(height).fill(0).map(() => []); + // blank top left cell + colGroupRows[0].push({ + height: height + 1 + (originCount > 1 ? 1 : 0), // + measures rows [+ origins row] + title: "", + width: 1, + }); + + // col groupby cells with group values + /** + * Recursive function that generates the header cells corresponding to + * the groups of a given tree. + * + * @param {Object} tree + */ + function generateTreeHeaders(tree, fields) { + const group = tree.root; + const rowIndex = group.values.length; + const row = colGroupRows[rowIndex]; + const groupId = [[], group.values]; + const isLeaf = !tree.directSubTrees.size; + const leafCount = leafCounts[JSON.stringify(tree.root.values)]; + const cell = { + groupId: groupId, + height: isLeaf ? colGroupBys.length + 1 - rowIndex : 1, + isLeaf: isLeaf, + isFolded: isLeaf && colGroupBys.length > group.values.length, + 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), + }; + row.push(cell); + if (isLeaf) { + measureColumns.push(cell); + } + + [...tree.directSubTrees.values()].forEach((subTree) => { + generateTreeHeaders(subTree, fields); + }); + } + + generateTreeHeaders(this.data.colGroupTree, this.meta.fields); + // blank top right cell for 'Total' group (if there is more that one leaf) + if (leafCounts[JSON.stringify(this.data.colGroupTree.root.values)] > 1) { + var groupId = [[], []]; + var totalTopRightCell = { + groupId: groupId, + height: height, + title: "", + width: measureCount * (2 * originCount - 1), + }; + colGroupRows[0].push(totalTopRightCell); + measureColumns.push(totalTopRightCell); + } + headers = headers.concat(colGroupRows); + + // 2) generate measures row + var measuresRow = this._getMeasuresRow(measureColumns); + headers.push(measuresRow); + + // 3) generate origins row if more than one origin + if (originCount > 1) { + headers.push(this._getOriginsRow(measuresRow)); + } + + return headers; + } + /** + * Returns the list of body rows of the pivot table for a given tree. + * + * @private + * @param {Object} tree + * @param {Object[]} columns + * @returns {Object[]} + */ + _getTableRows(tree, columns) { + let rows = []; + const group = tree.root; + const rowGroupId = [group.values, []]; + const title = group.labels[group.labels.length - 1] || this.env._t("Total"); + const indent = group.labels.length; + const isLeaf = !tree.directSubTrees.size; + const rowGroupBys = this.meta.fullRowGroupBys; + + const subGroupMeasurements = columns.map((column) => { + const colGroupId = column.groupId; + const groupIntersectionId = [rowGroupId[0], colGroupId[1]]; + const measure = column.measure; + const originIndexes = column.originIndexes || [0]; + + const value = this._getCellValue(groupIntersectionId, measure, originIndexes, { + data: this.data, + }); + + const measurement = { + groupId: groupIntersectionId, + originIndexes: originIndexes, + measure: measure, + value: value, + isBold: !groupIntersectionId[0].length || !groupIntersectionId[1].length, + }; + return measurement; + }); + + rows.push({ + title: title, + label: + indent === 0 + ? undefined + : this.meta.fields[rowGroupBys[indent - 1].split(":")[0]].string, + groupId: rowGroupId, + indent: indent, + isLeaf: isLeaf, + isFolded: isLeaf && rowGroupBys.length > group.values.length, + subGroupMeasurements: subGroupMeasurements, + }); + + const subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; + subTreeKeys.forEach((subTreeKey) => { + const subTree = tree.directSubTrees.get(subTreeKey); + rows = rows.concat(this._getTableRows(subTree, columns)); + }); + + return rows; + } + /** + * returns the height of a given groupTree + * + * @private + * @param {Object} tree, a groupTree + * @returns {number} + */ + _getTreeHeight(tree) { + const subTreeHeights = [...tree.directSubTrees.values()].map( + this._getTreeHeight.bind(this) + ); + return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1; + } + /** + * @private + * @param {Config} config + * @returns {boolean} true iff there's no data in the table + */ + _hasData(config) { + const { data } = config; + return (data.counts[JSON.stringify([[], []])] || []).some((count) => { + return count > 0; + }); + } + /** + * Initialize/Reinitialize data.rowGroupTree, colGroupTree, measurements, + * counts and subdivide the group 'Total' as many times it is necessary. + * A first subdivision with no groupBy (divisors.slice(0, 1)) is made in + * order to see if there is data in the intersection of the group 'Total' + * and the various origins. In case there is none, non supplementary rpc + * will be done (see the code of subdivideGroup). + * + * @private + * @param {Config} config + */ + async _loadData(config) { + config.data = {}; // data will be completely recomputed + const { data, meta, searchParams } = config; + data.rowGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + data.colGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + data.measurements = {}; + data.counts = {}; + data.groupDomains = {}; + data.numbering = {}; + const key = JSON.stringify([[], []]); + data.groupDomains[key] = searchParams.domains.slice(0); + + const group = { rowValues: [], colValues: [] }; + const leftDivisors = sections(meta.fullRowGroupBys); + const rightDivisors = sections(meta.fullColGroupBys); + const divisors = cartesian(leftDivisors, rightDivisors); + + await this._subdivideGroup(group, divisors.slice(0, 1), config); + await this._subdivideGroup(group, divisors.slice(1), config); + + if (meta.useSampleModel && !this._hasData(config)) { + const fakeORM = buildSampleORM(meta.resModel, meta.fields, this.user); + this.orm2Use = fakeORM; + return this._loadData(config); + } else if (this.orm2Use.rpc.name !== "fakeRPC") { + meta.useSampleModel = false; + } + return config.data; + } + /** + * Extract the information in the read_group results (groupSubdivisions) + * and develop this.data.rowGroupTree, colGroupTree, measurements, counts, and + * groupDomains. + * If a column needs to be sorted, the rowGroupTree corresponding to the + * group is sorted. + * + * @private + * @param {Object} group + * @param {Object[]} groupSubdivisions + * @param {Config} config + */ + _prepareData(group, groupSubdivisions, config) { + const { data, meta, searchParams } = config; + const groupRowValues = group.rowValues; + let groupRowLabels = []; + let rowSubTree = data.rowGroupTree; + let root; + if (groupRowValues.length) { + // we should have labels information on hand! regretful! + rowSubTree = this._findGroup(data.rowGroupTree, groupRowValues); + root = rowSubTree.root; + groupRowLabels = root.labels; + } + + const groupColValues = group.colValues; + let groupColLabels = []; + if (groupColValues.length) { + root = this._findGroup(data.colGroupTree, groupColValues).root; + groupColLabels = root.labels; + } + + groupSubdivisions.forEach((groupSubdivision) => { + groupSubdivision.subGroups.forEach((subGroup) => { + const rowValues = groupRowValues.concat( + this._getGroupValues(subGroup, groupSubdivision.rowGroupBy) + ); + const rowLabels = groupRowLabels.concat( + this._getGroupLabels(subGroup, groupSubdivision.rowGroupBy, config) + ); + + const colValues = groupColValues.concat( + this._getGroupValues(subGroup, groupSubdivision.colGroupBy) + ); + const colLabels = groupColLabels.concat( + this._getGroupLabels(subGroup, groupSubdivision.colGroupBy, config) + ); + + if (!colValues.length && rowValues.length) { + this._addGroup(data.rowGroupTree, rowLabels, rowValues); + } + if (colValues.length && !rowValues.length) { + this._addGroup(data.colGroupTree, colLabels, colValues); + } + + const key = JSON.stringify([rowValues, colValues]); + const originIndex = groupSubdivision.group.originIndex; + + if (!(key in data.measurements)) { + data.measurements[key] = searchParams.origins.map(() => { + return this._getMeasurements({}, config); + }); + } + data.measurements[key][originIndex] = this._getMeasurements(subGroup, config); + + if (!(key in data.counts)) { + data.counts[key] = searchParams.origins.map(function () { + return 0; + }); + } + data.counts[key][originIndex] = subGroup.__count; + + if (!(key in data.groupDomains)) { + data.groupDomains[key] = searchParams.origins.map(function () { + return Domain.FALSE.toList(); + }); + } + // if __domain is not defined this means that we are in the + // case where + // groupSubdivision.rowGroupBy = groupSubdivision.rowGroupBy = [] + if (subGroup.__domain) { + data.groupDomains[key][originIndex] = subGroup.__domain; + } + }); + }); + + if (meta.sortedColumn) { + this._sortRows(meta.sortedColumn, config); + } + } + /** + * Determine this.searchParams.domains and this.searchParams.origins from + * this.searchParams.domains. + * + * @private + */ + _computeDerivedParams(params) { + const domains = params.domains.reverse(); + return { + domains: domains.map((d) => d.arrayRepr), + origins: domains.map((d) => d.description || ""), + }; + } + /** + * Make any group in tree a leaf if it was a leaf in oldTree. + * + * @private + * @param {Object} tree + * @param {Object} oldTree + */ + _pruneTree(tree, oldTree) { + if (!oldTree.directSubTrees.size) { + tree.directSubTrees.clear(); + delete tree.sortedKeys; + return; + } + [...tree.directSubTrees.keys()].forEach((subTreeKey) => { + const subTree = tree.directSubTrees.get(subTreeKey); + if (!oldTree.directSubTrees.has(subTreeKey)) { + subTree.directSubTrees.clear(); + delete subTreeKey.sortedKeys; + } else { + const oldSubTree = oldTree.directSubTrees.get(subTreeKey); + this._pruneTree(subTree, oldSubTree); + } + }); + } + /** + * Extract from a groupBy value a label. + * + * @private + * @param {any} value + * @param {string} groupBy + * @param {Config} config + * @returns {string} + */ + _sanitizeLabel(value, groupBy, config) { + const { meta } = config; + const fieldName = groupBy.split(":")[0]; + if (value === false) { + return this.env._t("Undefined"); + } + if (value instanceof Array) { + return this._getNumberedLabel(value, fieldName, config); + } + if (fieldName && meta.fields[fieldName] && meta.fields[fieldName].type === "selection") { + const selected = meta.fields[fieldName].selection.find((o) => o[0] === value); + return selected ? selected[1] : value; // selected should be truthy normally ?! + } + return value; + } + /** + * Extract from a groupBy value the raw value of that groupBy (discarding + * a label if any) + * + * @private + * @param {any} value + * @returns {any} + */ + _sanitizeValue(value) { + if (value instanceof Array) { + return value[0]; + } + return value; + } + /** + * Get all partitions of a given group using the provided list of divisors + * and enrich the objects of this.data.rowGroupTree, colGroupTree, + * measurements, counts. + * + * @private + * @param {Object} group + * @param {Array[]} divisors + * @param {Config} config + */ + async _subdivideGroup(group, divisors, config) { + const { data, searchParams } = config; + const key = JSON.stringify([group.rowValues, group.colValues]); + + const proms = searchParams.origins.reduce((acc, origin, originIndex) => { + // if no information on group content is available, we fetch data. + // if group is known to be empty for the given origin, + // we don't need to fetch data for that origin. + if (!data.counts[key] || data.counts[key][originIndex] > 0) { + const subGroup = { + rowValues: group.rowValues, + colValues: group.colValues, + originIndex: originIndex, + }; + divisors.forEach((divisor) => { + acc.push(this._getGroupSubdivision(subGroup, divisor[0], divisor[1], config)); + }); + } + return acc; + }, []); + const groupSubdivisions = await this.keepLast.add(Promise.all(proms)); + if (groupSubdivisions.length) { + this._prepareData(group, groupSubdivisions, config); + } + } + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * @private + * @param {Object} sortedColumn + * @param {number[]} sortedColumn.groupId + * @param {Config} config + */ + _sortRows(sortedColumn, config) { + const meta = config.meta || this.meta; + const data = config.data || this.data; + const colGroupValues = sortedColumn.groupId[1]; + sortedColumn.originIndexes = sortedColumn.originIndexes || [0]; + meta.sortedColumn = sortedColumn; + + const sortFunction = (tree) => { + return (subTreeKey) => { + const subTree = tree.directSubTrees.get(subTreeKey); + const groupIntersectionId = [subTree.root.values, colGroupValues]; + const value = + this._getCellValue( + groupIntersectionId, + sortedColumn.measure, + sortedColumn.originIndexes, + { data } + ) || 0; + return sortedColumn.order === "asc" ? value : -value; + }; + }; + + this._sortTree(sortFunction, data.rowGroupTree); + } + /** + * Sort recursively the subTrees of tree using sortFunction. + * In the end each node of the tree has its direct children sorted + * according to the criterion reprensented by sortFunction. + * + * @private + * @param {Function} sortFunction + * @param {Object} tree + */ + _sortTree(sortFunction, tree) { + tree.sortedKeys = sortBy([...tree.directSubTrees.keys()], sortFunction(tree)); + [...tree.directSubTrees.values()].forEach((subTree) => { + this._sortTree(sortFunction, subTree); + }); + } +} +PivotModel.services = ["orm", "user"]; diff --git a/addons/web/static/src/views/pivot/pivot_renderer.js b/addons/web/static/src/views/pivot/pivot_renderer.js new file mode 100644 index 00000000000..393026bf07e --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_renderer.js @@ -0,0 +1,140 @@ +/** @odoo-module **/ + +import { PivotGroupByMenu } from "@web/views/pivot/pivot_group_by_menu"; +import { registry } from "@web/core/registry"; +import fieldUtils from "web.field_utils"; + +const { Component } = owl; +const formatterRegistry = registry.category("formatters"); + +export class PivotRenderer extends Component { + setup() { + this.model = this.props.model; + this.table = this.model.getTable(); + } + willUpdateProps() { + this.table = this.model.getTable(); + } + /** + * Get the formatted value of the cell. + * + * @private + * @param {Object} cell + * @returns {string} Formatted value + */ + getFormattedValue(cell) { + const field = this.model.meta.measures[cell.measure]; + let formatType = this.model.meta.widgets[cell.measure]; + if (!formatType) { + const fieldType = field.type; + formatType = fieldType === "many2one" ? "integer" : fieldType; + } + //If the formatter is not found on the registry, search on the legacy fieldUtils.format. + //This must be removed when all the formatters will be on the registry + const formatter = formatterRegistry.get(formatType, null) || fieldUtils.format[formatType]; + if (!formatter) { + throw new Error(`${formatType} is not a defined formatter!`); + } + return formatter(cell.value, field); + } + /** + * Get the formatted variation of a cell. + * + * @private + * @param {Object} cell + * @returns {string} Formatted variation + */ + getFormattedVariation(cell) { + if (isNaN(cell.value)) { + return "-"; + } + const formatPercentage = formatterRegistry.get("percentage"); + return formatPercentage(cell.value, this.model.meta.fields[cell.measure]); + } + /** + * Retrieve the padding of a left header. + * + * @param {Object} cell + * @returns {Number} Padding + */ + getPadding(cell) { + return 5 + cell.indent * 30; + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + onCellClicked(cell) { + this.props.onCellClicked(cell); + } + /** + * Handle the adding of a custom groupby (inside the view, not the searchview). + * + * @param {"col"|"row"} type + * @param {CustomEvent} ev + */ + onAddCustomGroupBy(type, ev) { + this.model.addGroupBy({ ...ev.detail, custom: true, type }); + } + + /** + * Handle the selection of a groupby dropdown item. + * + * @param {"col"|"row"} type + * @param {CustomEvent} ev + */ + onDropdownItemSelected(type, ev) { + this.model.addGroupBy({ ...ev.detail.payload, type }); + } + /** + * Handle a click on a header cell. + * + * @param {Object} cell + * @param {string} type col or row + */ + onHeaderClick(cell, type) { + if (cell.isLeaf && cell.isFolded) { + this.model.expandGroup(cell.groupId, type); + } else if (!cell.isLeaf) { + this.model.closeGroup(cell.groupId, type); + } + } + /** + * Handle a click on a measure cell. + * + * @param {Object} cell + */ + onMeasureClick(cell) { + this.model.sortRows({ + groupId: cell.groupId, + measure: cell.measure, + order: (cell.order || "desc") === "asc" ? "desc" : "asc", + originIndexes: cell.originIndexes, + }); + } + /** + * Hover the column in which the mouse is. + * + * @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. + */ + onMouseLeave() { + this.el + .querySelectorAll(".o_cell_hover") + .forEach((elt) => elt.classList.remove("o_cell_hover")); + } +} +PivotRenderer.template = "web.PivotRenderer"; +PivotRenderer.components = { PivotGroupByMenu }; diff --git a/addons/web/static/src/views/pivot/pivot_renderer.xml b/addons/web/static/src/views/pivot/pivot_renderer.xml new file mode 100644 index 00000000000..194803a59d7 --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_renderer.xml @@ -0,0 +1,81 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + +
diff --git a/addons/web/static/src/views/pivot/pivot_view.js b/addons/web/static/src/views/pivot/pivot_view.js new file mode 100644 index 00000000000..bfe1c912124 --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_view.js @@ -0,0 +1,186 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { download } from "@web/core/network/download"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { ControlPanel } from "@web/search/control_panel/control_panel"; +import { SearchPanel } from "@web/search/search_panel/search_panel"; +import { useModel } from "@web/views/helpers/model"; +import { standardViewProps } from "@web/views/helpers/standard_view_props"; +import { useSetupView } from "@web/views/helpers/view_hook"; +import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser"; +import { PivotModel } from "@web/views/pivot/pivot_model"; +import { PivotRenderer } from "@web/views/pivot/pivot_renderer"; + +const viewRegistry = registry.category("views"); + +const { Component } = owl; + +export class PivotView extends Component { + setup() { + this.actionService = useService("action"); + + let modelParams = {}; + if (this.props.state) { + modelParams.data = this.props.state.data; + modelParams.meta = this.props.state.meta; + } else { + const { arch } = this.props; + + // parse arch + const archInfo = new this.constructor.archParser().parse(arch); + + if (!archInfo.activeMeasures.length || archInfo.displayQuantity) { + archInfo.activeMeasures.unshift("__count"); + } + + modelParams.meta = { + activeMeasures: archInfo.activeMeasures, + additionalMeasures: this.props.additionalMeasures, + colGroupBys: archInfo.colGroupBys, + defaultOrder: archInfo.defaultOrder, + disableLinking: Boolean(archInfo.disableLinking), + fields: this.props.fields, + fieldAttrs: archInfo.fieldAttrs, + resModel: this.props.resModel, + rowGroupBys: archInfo.rowGroupBys, + title: this.props.title || archInfo.title || this.env._t("Untitled"), + useSampleModel: Boolean(this.props.useSampleModel), + widgets: archInfo.widgets, + }; + } + + this.model = useModel(this.constructor.Model, modelParams); + + useSetupView({ + exportLocalState: () => { + const { data, meta } = this.model; + return { data, meta }; + }, + saveParams: () => this.saveParams(), // FIXME: rename this + }); + } + /** + * @returns {Object} + */ + saveParams() { + return { + context: { + pivot_measures: this.model.meta.activeMeasures, + pivot_column_groupby: this.model.meta.fullColGroupBys, + pivot_row_groupby: this.model.meta.fullRowGroupBys, + }, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Exports 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_xlsx. + * Force a reload before exporting to ensure to export up-to-date data. + */ + onDownloadButtonClicked() { + if (this.model.getTableWidth() > 16384) { + throw new Error( + this.env._t( + "For Excel compatibility, data cannot be exported if there are more than 16384 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures." + ) + ); + } + const table = this.model.exportData(); + download({ + url: "/web/pivot/export_xlsx", + data: { data: JSON.stringify(table) }, + }); + } + /** + * Expands all groups + */ + onExpandButtonClicked() { + this.model.expandAll(); + } + /** + * Flips axis + */ + onFlipButtonClicked() { + this.model.flip(); + } + /** + * Toggles the given measure + * + * @param {CustomEvent} ev + */ + onMeasureSelected(ev) { + this.model.toggleMeasure(ev.detail.payload.measure); + } + /** + * @param {CustomEvent} ev + */ + onOpenView(cell) { + if (cell.value === undefined || this.model.meta.disableLinking) { + return; + } + + const context = Object.assign({}, this.model.searchParams.context); + Object.keys(context).forEach((x) => { + if (x === "group_by" || x.startsWith("search_default_")) { + delete context[x]; + } + }); + + // retrieve form and list view ids from the action + this.views = ["list", "form"].map((viewType) => { + const view = this.props.info.views.find((view) => view[1] === viewType); + return [view ? view[0] : false, viewType]; + }); + + const group = { + rowValues: cell.groupId[0], + colValues: cell.groupId[1], + originIndex: cell.originIndexes[0], + }; + const domain = this.model.getGroupDomain(group); + + this.actionService.doAction({ + type: "ir.actions.act_window", + name: this.model.meta.title, + res_model: this.props.resModel, + views: this.views, + view_mode: "list", + target: "current", + context, + domain, + }); + } +} + +PivotView.template = "web.PivotView"; +PivotView.buttonTemplate = "web.PivotView.Buttons"; +PivotView.components = { ControlPanel, SearchPanel, Renderer: PivotRenderer }; + +PivotView.props = { + ...standardViewProps, + additionalMeasures: { type: Array, elements: String, optional: 1 }, + display: { type: Object, optional: 1 }, + title: { type: String, optional: 1 }, +}; +PivotView.defaultProps = { + additionalMeasures: [], + display: {}, +}; + +PivotView.Model = PivotModel; + +PivotView.archParser = PivotArchParser; + +PivotView.type = "pivot"; +PivotView.display_name = _lt("Pivot"); +PivotView.icon = "fa-table"; +PivotView.multiRecord = true; +PivotView.searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"]; + +viewRegistry.add("pivot", PivotView); diff --git a/addons/web/static/src/legacy/scss/pivot_view.scss b/addons/web/static/src/views/pivot/pivot_view.scss similarity index 88% rename from addons/web/static/src/legacy/scss/pivot_view.scss rename to addons/web/static/src/views/pivot/pivot_view.scss index 060f5bf656a..0a37c1caff0 100644 --- a/addons/web/static/src/legacy/scss/pivot_view.scss +++ b/addons/web/static/src/views/pivot/pivot_view.scss @@ -1,5 +1,4 @@ .o_pivot { - .o_pivot_cell_value { font-size: 1em; .o_comparison { @@ -16,6 +15,13 @@ } table { + th > .o_dropdown > .o_dropdown_toggler { + background: none!important; + border: none!important; + padding: 0!important; + // width: 100%!important; + // text-align: left!important; + } background-color: $o-view-background-color; width: auto; // bootstrap override @@ -108,8 +114,8 @@ top: 10px; } - .o_pivot_field_menu .dropdown-item.disabled { - color: $text-muted; - cursor: default; - } + // .o_pivot_field_menu .dropdown-item.disabled { + // color: $text-muted; + // cursor: default; + // } } diff --git a/addons/web/static/src/views/pivot/pivot_view.xml b/addons/web/static/src/views/pivot/pivot_view.xml new file mode 100644 index 00000000000..3545594ab80 --- /dev/null +++ b/addons/web/static/src/views/pivot/pivot_view.xml @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + + +
+
+
+ +
diff --git a/addons/web/static/tests/core/debug/debug_manager_tests.js b/addons/web/static/tests/core/debug/debug_manager_tests.js index 3d725c4112e..70b7948f8d9 100644 --- a/addons/web/static/tests/core/debug/debug_manager_tests.js +++ b/addons/web/static/tests/core/debug/debug_manager_tests.js @@ -15,6 +15,7 @@ import { fakeCommandService, makeFakeDialogService, makeFakeLocalizationService, + makeFakeUserService, } from "../../helpers/mock_services"; import { click, getFixture, legacyExtraNextTick, patchWithCleanup } from "../../helpers/utils"; import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers"; @@ -357,6 +358,7 @@ QUnit.module("DebugMenu", (hooks) => { debug: true, }); + registry.category("services").add("user", makeFakeUserService()); registry.category("debug").category("view").add("editViewItem", editView); const serverData = getActionManagerServerData(); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index a90d6f047fb..b90a9b40b30 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -330,6 +330,9 @@ export class MockServer { } // generate a field_view_get result const fields = Object.assign({}, this.models[modelName].fields); + for (const fieldName in fields) { + fields[fieldName].name = fieldName; + } // var viewOptions = params.viewOptions || {}; const fvg = _fieldsViewGet({ arch, @@ -726,7 +729,7 @@ export class MockServer { let aggregatedFields = []; // if no fields have been given, the server picks all stored fields if (kwargs.fields.length === 0) { - aggregatedFields = Object.keys(this.models[modelName].fields).filter( + aggregatedFields = Object.keys(fields).filter( (fieldName) => !groupByFieldNames.includes(fieldName) ); } else { @@ -1499,7 +1502,11 @@ export class MockServer { fieldNames = [...new Set(fieldNames.concat(["id"]))]; const { context } = params; const active_test = context && "active_test" in context ? context.active_test : true; - let records = this.getRecords(params.model, params.domain || [], { active_test }); + let records = this.getRecords( + params.model, + params.domain || [], + Object.assign({}, params.context, { active_test }) + ); if (params.sort) { // warning: only consider first level of sort params.sort = params.sort.split(",")[0]; diff --git a/addons/web/static/tests/helpers/mock_services.js b/addons/web/static/tests/helpers/mock_services.js index c48279f4f2e..ad0e5f4cc45 100644 --- a/addons/web/static/tests/helpers/mock_services.js +++ b/addons/web/static/tests/helpers/mock_services.js @@ -246,6 +246,17 @@ export function makeFakeDialogService(addDialog) { }; } +export function makeFakeUserService(hasGroup = () => false) { + return { + ...userService, + start() { + const fakeUserService = userService.start(...arguments); + fakeUserService.hasGroup = hasGroup; + return fakeUserService; + }, + }; +} + export const mocks = { company: () => companyService, command: () => fakeCommandService, diff --git a/addons/web/static/tests/legacy/control_panel/search_bar_tests.js b/addons/web/static/tests/legacy/control_panel/search_bar_tests.js index 79cebce327f..c3e44f624e4 100644 --- a/addons/web/static/tests/legacy/control_panel/search_bar_tests.js +++ b/addons/web/static/tests/legacy/control_panel/search_bar_tests.js @@ -2,9 +2,11 @@ import testUtils from "web.test_utils"; import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; +import { makeFakeUserService } from "@web/../tests/helpers/mock_services"; import { Model } from "web.Model"; import Registry from "web.Registry"; import SearchBar from "web.SearchBar"; +import { registry } from "@web/core/registry"; let serverData; @@ -321,6 +323,8 @@ QUnit.module("Search Bar (legacy)", (hooks) => { `; + registry.category("services").add("user", makeFakeUserService()); + let rpcs; const webClient = await createWebClient({ serverData, diff --git a/addons/web/static/tests/legacy/views/pivot_tests.js b/addons/web/static/tests/legacy/views/pivot_tests.js index f8bf7c0f735..ebd9d3784e3 100644 --- a/addons/web/static/tests/legacy/views/pivot_tests.js +++ b/addons/web/static/tests/legacy/views/pivot_tests.js @@ -6,6 +6,8 @@ var PivotView = require('web.PivotView'); const PivotController = require("web.PivotController"); var testUtils = require('web.test_utils'); var testUtilsDom = require('web.test_utils_dom'); +const { registry } = require('@web/core/registry'); +const legacyViewRegistry = require('web.view_registry'); const { createWebClient, doAction } = require('@web/../tests/webclient/helpers'); @@ -31,6 +33,9 @@ let serverData; QUnit.module('Views', { beforeEach: function () { + registry.category("views").remove("pivot"); // remove new pivot from registry + legacyViewRegistry.add("pivot", PivotView); // add legacy pivot -> will be wrapped and added to new registry + this.data = { partner: { fields: { @@ -117,7 +122,7 @@ QUnit.module('Views', { serverData = { models: this.data }; }, }, function () { - QUnit.module('PivotView'); + QUnit.module('Legacy PivotView'); QUnit.test('simple pivot rendering', async function (assert) { assert.expect(3); @@ -2986,67 +2991,6 @@ QUnit.module('Views', { pivot.destroy(); }); - QUnit.test('Navigation list view for a group and back with breadcrumbs', async function (assert) { - assert.expect(16); - // create a webClient to test the interactions with the search view - - serverData.views = { - 'partner,false,pivot': '' + - '' + - '', - 'partner,false,search': '', - 'partner,false,list': '', - 'partner,false,form': '
', - }; - - let readGroupCount = 0; - const mockRPC = (route, args) => { - if (args.method === 'read_group') { - assert.step('read_group'); - const domain = args.kwargs.domain; - if ([0,1].indexOf(readGroupCount) !== -1) { - assert.deepEqual(domain, [], 'domain empty'); - } else if ([2,3,4,5].indexOf(readGroupCount) !== -1) { - assert.deepEqual(domain, [['foo', '=', 12]], - 'domain conserved when back with breadcrumbs'); - } - readGroupCount++; - } - if (route === '/web/dataset/search_read') { - assert.step('search_read'); - const domain = args.domain; - assert.deepEqual(domain, ['&', ['customer', '=', 1], ['foo', '=', 12]], - 'list domain is correct'); - } - }; - - const webClient = await createWebClient({serverData, mockRPC}); - - await doAction(webClient, { - res_model: 'partner', - type: 'ir.actions.act_window', - views: [[false, 'pivot']], - }); - - - await cpHelpers.toggleFilterMenu(webClient); - await cpHelpers.toggleMenuItem(webClient, 0); - await testUtils.nextTick(); - - await testUtilsDom.click($(webClient.el).find('.o_pivot_cell_value:nth(1)')); - await testUtils.nextTick(); - - assert.containsOnce(webClient, '.o_list_view'); - - await testUtilsDom.click($(webClient.el).find('.o_control_panel ol.breadcrumb li.breadcrumb-item').eq(0)); - - assert.verifySteps([ - 'read_group', 'read_group', - 'read_group', 'read_group', - 'search_read', - 'read_group', 'read_group']); - }); - QUnit.test('Cell values are kept when flippin a pivot view in comparison mode', async function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/legacy/views/search_panel_tests.js b/addons/web/static/tests/legacy/views/search_panel_tests.js index bdde2878fcf..0a411e8cdc6 100644 --- a/addons/web/static/tests/legacy/views/search_panel_tests.js +++ b/addons/web/static/tests/legacy/views/search_panel_tests.js @@ -10,8 +10,11 @@ const SearchPanel = require("web.searchPanel"); const cpHelpers = testUtils.controlPanel; const createView = testUtils.createView; +const { makeFakeUserService } = require("@web/../tests/helpers/mock_services"); const { createWebClient, doAction } = require('@web/../tests/webclient/helpers'); const { legacyExtraNextTick } = require("@web/../tests/helpers/utils"); +const { registry } = require("@web/core/registry"); +const { PivotView } = require("@web/views/pivot/pivot_view"); /** * Return the list of counters displayed in the search panel (if any). @@ -2336,6 +2339,7 @@ QUnit.module('Views', { QUnit.test('search panel is available on list and kanban by default', async function (assert) { assert.expect(8); + registry.category("services").add("user", makeFakeUserService()); const webClient = await createWebClient({ serverData }); await doAction(webClient, 1); @@ -2359,7 +2363,7 @@ QUnit.module('Views', { assert.containsNone(webClient, '.o_content .o_search_panel'); }); - QUnit.test('search panel with view_types attribute', async function (assert) { + QUnit.skip('search panel with view_types attribute', async function (assert) { assert.expect(6); serverData.views['partner,false,search'] = @@ -2371,6 +2375,7 @@ QUnit.module('Views', { `; + registry.category("views").add("pivot", PivotView, { force: true }); const webClient = await createWebClient({ serverData }); await doAction(webClient, 1); @@ -2486,9 +2491,10 @@ QUnit.module('Views', { ]); }); - QUnit.test('search panel filters are kept when switching to a view with no search panel', async function (assert) { + QUnit.skip('search panel filters are kept when switching to a view with no search panel', async function (assert) { assert.expect(13); + registry.category("views").add("pivot", PivotView, { force: true }); const webClient = await createWebClient({ serverData }); await doAction(webClient, 1); diff --git a/addons/web/static/tests/views/helpers.js b/addons/web/static/tests/views/helpers.js index e07cfb07a25..4f8cb1f69b7 100644 --- a/addons/web/static/tests/views/helpers.js +++ b/addons/web/static/tests/views/helpers.js @@ -1,9 +1,9 @@ /** @odoo-module **/ +import { View } from "@web/views/view"; import { getFixture } from "@web/../tests/helpers/utils"; import { makeTestEnv } from "@web/../tests/helpers/mock_env"; import { registerCleanup } from "@web/../tests/helpers/cleanup"; -import { View } from "@web/views/view"; import { _fieldsViewGet } from "../helpers/mock_server"; import { addLegacyMockEnvironment } from "../webclient/helpers"; diff --git a/addons/web/static/tests/views/pivot_view_tests.js b/addons/web/static/tests/views/pivot_view_tests.js new file mode 100644 index 00000000000..3ddf7760f55 --- /dev/null +++ b/addons/web/static/tests/views/pivot_view_tests.js @@ -0,0 +1,4931 @@ +/** @odoo-module **/ + +import { dialogService } from "@web/core/dialog/dialog_service"; +import { registry } from "@web/core/registry"; +import { session } from "@web/session"; +import { makeFakeLocalizationService, makeFakeUserService } from "../helpers/mock_services"; +import { + click, + nextTick, + legacyExtraNextTick, + makeDeferred, + mockDownload, + patchDate, + patchWithCleanup, + triggerEvent, + triggerEvents, +} from "../helpers/utils"; +import { makeView } from "./helpers"; +import { + applyGroup, + editFavoriteName, + setupControlPanelServiceRegistry, + toggleAddCustomGroup, + toggleComparisonMenu, + toggleFavoriteMenu, + toggleFilterMenu, + toggleGroupByMenu, + toggleMenuItem, + toggleMenuItemOption, + toggleSaveFavorite, + saveFavorite, + selectGroup, + removeFacet, + toggleMenu, +} from "../search/helpers"; +import { createWebClient, doAction } from "../webclient/helpers"; + +const serviceRegistry = registry.category("services"); + +/** + * Helper function that returns, given a pivot instance, the values of the + * table, separated by ','. + * + * @returns {string} + */ +function getCurrentValues(pivot) { + return [...pivot.el.querySelectorAll(".o_pivot_cell_value div")] + .map((el) => el.innerText) + .join(); +} + +let serverData; +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + serverData = { + models: { + partner: { + fields: { + foo: { + string: "Foo", + type: "integer", + searchable: true, + group_operator: "sum", + }, + bar: { string: "bar", type: "boolean", store: true, sortable: true }, + date: { string: "Date", type: "date", store: true, sortable: true }, + product_id: { + string: "Product", + type: "many2one", + relation: "product", + store: true, + sortable: true, + }, + other_product_id: { + string: "Other Product", + type: "many2one", + relation: "product", + store: true, + sortable: true, + }, + non_stored_m2o: { + string: "Non Stored M2O", + type: "many2one", + relation: "product", + }, + customer: { + string: "Customer", + type: "many2one", + relation: "customer", + store: true, + sortable: true, + }, + computed_field: { + string: "Computed and not stored", + type: "integer", + compute: true, + group_operator: "sum", + }, + company_type: { + string: "Company Type", + type: "selection", + selection: [ + ["company", "Company"], + ["individual", "individual"], + ], + searchable: true, + sortable: true, + store: true, + }, + }, + records: [ + { + id: 1, + foo: 12, + bar: true, + date: "2016-12-14", + product_id: 37, + customer: 1, + computed_field: 19, + company_type: "company", + }, + { + id: 2, + foo: 1, + bar: true, + date: "2016-10-26", + product_id: 41, + customer: 2, + computed_field: 23, + company_type: "individual", + }, + { + id: 3, + foo: 17, + bar: true, + date: "2016-12-15", + product_id: 41, + customer: 2, + computed_field: 26, + company_type: "company", + }, + { + id: 4, + foo: 2, + bar: false, + date: "2016-04-11", + product_id: 41, + customer: 1, + computed_field: 19, + company_type: "individual", + }, + ], + }, + product: { + fields: { + name: { string: "Product Name", type: "char" }, + }, + records: [ + { + id: 37, + display_name: "xphone", + }, + { + id: 41, + display_name: "xpad", + }, + ], + }, + customer: { + fields: { + name: { string: "Customer Name", type: "char" }, + }, + records: [ + { + id: 1, + display_name: "First", + }, + { + id: 2, + display_name: "Second", + }, + ], + }, + }, + }; + setupControlPanelServiceRegistry(); + serviceRegistry.add("dialog", dialogService); + serviceRegistry.add("localization", makeFakeLocalizationService()); + serviceRegistry.add("user", makeFakeUserService()); + }); + + QUnit.module("PivotView"); + + QUnit.test("simple pivot rendering", async function (assert) { + assert.expect(3); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + mockRPC(route, args) { + assert.strictEqual( + args.kwargs.lazy, + false, + "the read_group should be done with the lazy=false option" + ); + }, + }); + + assert.hasClass(pivot.el.querySelector("table"), "o_enable_linking"); + assert.containsOnce( + pivot, + "td.o_pivot_cell_value:contains(32)", + "should contain a pivot cell with the sum of all records" + ); + }); + + QUnit.test("pivot rendering with widget", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + assert.containsOnce( + pivot, + "td.o_pivot_cell_value:contains(32:00)", + "should contain a pivot cell with the sum of all records" + ); + }); + + QUnit.test("pivot rendering with string attribute on field", async function (assert) { + serverData.models.partner.fields.foo = { + string: "Foo", + type: "integer", + store: true, + group_operator: "sum", + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.strictEqual( + pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item").innerText, + "BAR" + ); + assert.strictEqual( + pivot.el.querySelector(".o_pivot_measure_row").innerText, + "BAR", + "the displayed name should be the one set in the string attribute" + ); + }); + + QUnit.test( + "pivot rendering with string attribute on non stored field", + async function (assert) { + serverData.models.partner.fields.fubar = { + string: "Fubar", + type: "integer", + store: false, + group_operator: "sum", + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + assert.strictEqual( + pivot.el.querySelector(".o_pivot_measure_row").innerText, + "fubar", + "the displayed name should be the one set in the string attribute" + ); + } + ); + + QUnit.test("pivot rendering with invisible attribute on field", async function (assert) { + // when invisible, a field should neither be an active measure nor be a selectable measure + Object.assign(serverData.models.partner.fields, { + foo: { string: "Foo", type: "integer", store: true, group_operator: "sum" }, + foo2: { string: "Foo2", type: "integer", store: true, group_operator: "sum" }, + }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + // there should be only one displayed measure as the other one is invisible + assert.containsOnce(pivot, ".o_pivot_measure_row"); + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + // there should be only one measure besides count, as the other one is invisible + assert.containsN(pivot, ".o_cp_bottom_left .o_dropdown_menu .dropdown-item", 2); + // the invisible field souldn't be in the groupable fields neither + await click(pivot.el.querySelector(".o_pivot_header_cell_closed")); + assert.containsNone(pivot, '.o_dropdown_menu a[data-field="foo2"]'); + }); + + QUnit.test('pivot view without "string" attribute', async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + // this is important for export functionality. + assert.strictEqual( + pivot.model.meta.title, + pivot.env._t("Untitled"), + "should have a valid title" + ); + }); + + QUnit.test("group headers should have a tooltip", async function (assert) { + assert.expect(2); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.strictEqual( + pivot.el.querySelectorAll("tbody .o_pivot_header_cell_closed")[0].dataset.tooltip, + "Date" + ); + assert.strictEqual( + pivot.el.querySelectorAll("thead .o_pivot_header_cell_closed")[1].dataset.tooltip, + "Product" + ); + }); + + QUnit.test( + "pivot view add computed fields explicitly defined as measure", + async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.containsOnce( + pivot, + ".o_cp_bottom_left .o_dropdown_menu .dropdown-item:contains(Computed and not stored)" + ); + assert.strictEqual( + pivot.el.querySelector(".o_pivot_measure_row").innerText, + "Computed and not stored" + ); + } + ); + + QUnit.test("clicking on a cell triggers a doAction", async function (assert) { + assert.expect(2); + + patchWithCleanup(session, { + user_context: { userContextKey: true }, + }); + + const fakeActionService = { + start() { + return { + doAction(action) { + assert.deepEqual( + action, + { + context: { someKey: true, uid: 7, userContextKey: true }, + domain: [["product_id", "=", 37]], + name: "Partners", + res_model: "partner", + target: "current", + type: "ir.actions.act_window", + view_mode: "list", + views: [ + [false, "list"], + [2, "form"], + ], + }, + "should trigger do_action with the correct args" + ); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + context: { someKey: true, search_default_test: 3 }, + title: "Partners", // FIXME props ignored + views: [ + [2, "form"], + [5, "kanban"], + [false, "list"], + [false, "pivot"], + ], + }); + + assert.hasClass(pivot.el.querySelector("table"), "o_enable_linking"); + await click(pivot.el.querySelectorAll(".o_pivot_cell_value")[1]); // should trigger a do_action + }); + + QUnit.test("row and column are highlighted when hovering a cell", async function (assert) { + assert.expect(11); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + // check row highlighting + assert.hasClass( + pivot.el.querySelector("table"), + "table-hover", + "with className 'table-hover', rows are highlighted (bootstrap)" + ); + + // check column highlighting + // hover third measure + await triggerEvents(pivot.el, "th.o_pivot_measure_row:nth-of-type(3)", ["mouseover"]); + assert.containsN(pivot, ".o_cell_hover", 3); + for (var i = 1; i <= 3; i++) { + assert.hasClass( + pivot.el.querySelector(`tbody tr:nth-of-type(${i}) td:nth-of-type(3)`), + "o_cell_hover" + ); + } + await triggerEvents(pivot.el, "th.o_pivot_measure_row:nth-of-type(3)", ["mouseout"]); + assert.containsNone(pivot, ".o_cell_hover"); + + // hover second cell, second row + await triggerEvents(pivot.el, "tbody tr:nth-of-type(1) td:nth-of-type(2)", ["mouseover"]); + assert.containsN(pivot, ".o_cell_hover", 3); + for (i = 1; i <= 3; i++) { + assert.hasClass( + pivot.el.querySelector(`tbody tr:nth-of-type(${i}) td:nth-of-type(2)`), + "o_cell_hover" + ); + } + await triggerEvents(pivot.el, "tbody tr:nth-of-type(2) td:nth-of-type(2)", ["mouseout"]); + assert.containsNone(pivot, ".o_cell_hover"); + }); + + QUnit.test('pivot view with disable_linking="True"', async function (assert) { + const fakeActionService = { + start() { + return { + doAction() { + throw new Error("should not execute an action"); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + assert.doesNotHaveClass(pivot.el.querySelector("table"), "o_enable_linking"); + assert.containsOnce(pivot, ".o_pivot_cell_value"); + await click(pivot.el.querySelector(".o_pivot_cell_value")); // should not trigger a do_action + }); + + QUnit.test('clicking on the "Total" cell with time range activated', async function (assert) { + assert.expect(2); + + patchDate(2016, 11, 20, 1, 0, 0); + + const fakeActionService = { + start() { + return { + doAction(action) { + assert.deepEqual( + action.domain, + ["&", ["date", ">=", "2016-12-01"], ["date", "<=", "2016-12-31"]], + "should trigger do_action with the correct action domain" + ); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + searchViewArch: ` + + + `, + context: { search_default_date_filter: true }, + title: "Partners", + }); + + assert.hasClass( + pivot.el.querySelector("table"), + "o_enable_linking", + "root node should have classname 'o_enable_linking'" + ); + await click(pivot.el.querySelector(".o_pivot_cell_value")); + }); + + QUnit.test( + 'clicking on a fake cell value ("empty group") in comparison mode', + async function (assert) { + assert.expect(3); + + patchDate(2016, 11, 20, 1, 0, 0); + + serverData.models.partner.records[0].date = "2016-11-15"; + serverData.models.partner.records[1].date = "2016-11-17"; + serverData.models.partner.records[2].date = "2016-11-22"; + serverData.models.partner.records[3].date = "2016-11-03"; + + const expectedDomains = [ + ["&", ["date", ">=", "2016-12-01"], ["date", "<=", "2016-12-31"]], + [[0, "=", 1]], + ]; + const fakeActionService = { + start() { + return { + doAction(action) { + assert.deepEqual(action.domain, expectedDomains.shift()); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ``, + searchViewArch: ` + + + `, + context: { search_default_date_filter: true }, + title: "Partners", + }); + + await toggleComparisonMenu(pivot); + await toggleMenuItem(pivot, "Date: Previous period"); + + assert.hasClass(pivot.el.querySelector("table"), "o_enable_linking"); + // here we click on the group corresponding to Total/Total/This Month + pivot.el.querySelectorAll(".o_pivot_cell_value")[1].click(); // should trigger a do_action with appropriate domain + // here we click on the group corresponding to xphone/Total/This Month + pivot.el.querySelectorAll(".o_pivot_cell_value")[4].click(); // should trigger a do_action with appropriate domain + } + ); + + QUnit.test("pivot view grouped by date field", async function (assert) { + assert.expect(2); + + await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + mockRPC(route, params) { + const wrongFields = params.kwargs.fields.filter((field) => { + return !(field.split(":")[0] in serverData.models.partner.fields); + }); + assert.ok( + !wrongFields.length, + "fields given to read_group should exist on the model" + ); + }, + }); + }); + + QUnit.test("without measures, pivot view uses __count by default", async function (assert) { + assert.expect(4); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + mockRPC(route, args) { + if (args.method === "read_group") { + assert.deepEqual( + args.kwargs.fields, + ["__count"], + "should make a read_group with no valid fields" + ); + } + }, + }); + + await click(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_toggler")); + assert.containsOnce(pivot.el, ".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item"); + const measure = pivot.el.querySelector( + ".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item" + ); + assert.strictEqual(measure.innerText, "Count"); + assert.hasClass(measure, "selected", "The count measure should be selected"); + }); + + QUnit.test("pivot view can be reloaded", async function (assert) { + let readGroupCount = 0; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + searchViewArch: ` + + + `, + mockRPC(route, args) { + if (args.method === "read_group") { + readGroupCount++; + } + }, + }); + + assert.containsOnce( + pivot, + "td.o_pivot_cell_value:contains(4)", + "should contain a pivot cell with the number of all records" + ); + assert.strictEqual(readGroupCount, 1, "should have done 1 rpc"); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Some Filter"); + assert.containsOnce( + pivot, + "td.o_pivot_cell_value:contains(2)", + "should contain a pivot cell with the number of remaining records" + ); + assert.strictEqual(readGroupCount, 2, "should have done 2 rpcs"); + }); + + QUnit.test("pivot view grouped by many2one field", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.containsOnce(pivot, ".o_pivot_header_cell_opened", "should have one opened header"); + assert.containsOnce( + pivot, + ".o_pivot_header_cell_closed:contains(xphone)", + "should display one header with 'xphone'" + ); + assert.containsOnce( + pivot, + ".o_pivot_header_cell_closed:contains(xpad)", + "should display one header with 'xpad'" + ); + }); + + QUnit.test("basic folding/unfolding", async function (assert) { + assert.expect(7); + + let rpcCount = 0; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + mockRPC() { + rpcCount++; + }, + }); + assert.containsN( + pivot, + "tbody tr", + 3, + "should have 3 rows: 1 for the opened header, and 2 for data" + ); + + // click on the opened header to close it + await click(pivot.el, ".o_pivot_header_cell_opened"); + + assert.containsOnce(pivot, "tbody tr", "should have 1 row"); + + // click on closed header to open dropdown + await click(pivot.el, "tbody .o_pivot_header_cell_closed"); + assert.containsOnce(pivot, ".o_pivot .o_dropdown_menu"); + assert.strictEqual( + pivot.el.querySelector(".o_pivot .o_dropdown_menu").innerText.replace(/\s/g, ""), + "CompanyTypeCustomerDateOtherProductProductbarAddCustomGroup" + ); + + // open the Date sub dropdown + await click(pivot.el, ".o_pivot .o_dropdown_menu .o_dropdown_toggler.o_menu_item"); + assert.strictEqual( + pivot.el + .querySelector(".o_pivot .o_dropdown_menu .o_dropdown_menu") + .innerText.replace(/\s/g, ""), + "YearQuarterMonthWeekDay" + ); + + await click( + pivot.el.querySelectorAll( + ".o_pivot .o_dropdown_menu .o_dropdown_menu .o_dropdown_item" + )[2] + ); + + assert.containsN(pivot, "tbody tr", 4, "should have 4 rows: one for header, 3 for data"); + assert.strictEqual( + rpcCount, + 3, + "should have done 3 rpcs (initial load) + open header with different groupbys" + ); + }); + + QUnit.test("more folding/unfolding", async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + // open dropdown to zoom into first row + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + // click on date by day + await click(pivot.el.querySelector("tbody .o_dropdown_menu .o_dropdown_toggler")); + await click( + pivot.el.querySelector("tbody .o_dropdown_menu .o_dropdown_menu li:nth-child(5)") + ); + + // open dropdown to zoom into second row + await click(pivot.el.querySelectorAll("tbody th.o_pivot_header_cell_closed")[1]); + + assert.containsN( + pivot, + "tbody tr", + 7, + "should have 7 rows (1 for total, 1 for xphone, 1 for xpad, 4 for data)" + ); + }); + + QUnit.test("fold and unfold header group", async function (assert) { + assert.expect(3); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.containsN(pivot, "thead tr", 3); + + // fold opened col group + await click(pivot.el.querySelector("thead .o_pivot_header_cell_opened")); + assert.containsN(pivot, "thead tr", 2); + + // unfold it + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelector(".o_dropdown_menu li:nth-child(5)")); + assert.containsN(pivot, "thead tr", 3); + }); + + QUnit.test("unfold second header group", async function (assert) { + assert.expect(4); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.containsN(pivot, "thead tr", 3); + let values = ["12", "20", "32"]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + + // unfold it + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed:last-child")); + await click(pivot.el.querySelector(".o_dropdown_menu li:nth-child(1)")); + assert.containsN(pivot, "thead tr", 4); + values = ["12", "3", "17", "32"]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + }); + + QUnit.test( + "pivot renders group dropdown same as search groupby dropdown if group bys are specified in search arch", + async function (assert) { + assert.expect(6); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + `, + // TOASK DAM: won´t appear in groupbymenu ? + searchViewArch: ` + + + + + `, + }); + + // open group by dropdown + await toggleGroupByMenu(pivot); + assert.containsN( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + 3, + "should have 3 dropdown items in searchview groupby" + ); + assert.containsOnce( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_add_custom_group_menu", + "should have custom group generator in searchview groupby" + ); + + // click on closed header to open dropdown + await click(pivot.el, "tbody tr:last-child .o_pivot_header_cell_closed"); + assert.containsN( + pivot, + ".o_dropdown_menu > .dropdown-item", + 3, + "should have 3 dropdown items same as searchview groupby" + ); + assert.containsOnce( + pivot, + ".o_dropdown_menu .o_add_custom_group_menu", + "should have custom group generator same as searchview groupby" + ); + // check custom groupby selection has groupable fields only + await click(pivot.el, ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_toggler"); + assert.containsN( + pivot, + ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_menu option", + 6, + "should have 6 fields in custom groupby" + ); + const optionDescriptions = [ + ...pivot.el.querySelectorAll( + ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_menu option" + ), + ].map((option) => option.innerText.trim()); + assert.deepEqual( + optionDescriptions, + ["Company Type", "Customer", "Date", "Other Product", "Product", "bar"], + "should only have groupable fields in custom groupby" + ); + } + ); + + QUnit.test("pivot group dropdown sync with search groupby dropdown", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + + `, + }); + + // open group by dropdown + await toggleGroupByMenu(pivot); + assert.containsN( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + 2, + "should have 2 dropdown items in searchview groupby" + ); + + // click on closed header to open dropdown + await click(pivot.el, "tbody tr:last-child .o_pivot_header_cell_closed"); + assert.containsN( + pivot, + ".o_dropdown_menu .o_menu_item", + 2, + "should have 2 dropdown items in pivot groupby" + ); + + // add a custom group in searchview groupby + await toggleGroupByMenu(pivot); + await toggleAddCustomGroup(pivot); + await applyGroup(pivot); + assert.containsN( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + 3, + "should have 3 dropdown items in searchview groupby now" + ); + await click(pivot.el, "tbody tr:last-child .o_pivot_header_cell_closed"); + assert.containsN( + pivot, + ".o_dropdown_menu .o_menu_item", + 2, + "should still have 2 dropdown items in pivot groupby" + ); + + // add a custom group in pivot groupby + await click(pivot.el, ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_toggler"); + pivot.el.querySelector(".o_dropdown_menu .o_add_custom_group_menu select").value = "date"; + await triggerEvent(pivot.el, ".o_dropdown_menu .o_add_custom_group_menu select", "change"); + await click(pivot.el, ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_menu .btn"); + // click on closed header to open groupby selection dropdown + await click(pivot.el, "tbody tr:last-child .o_pivot_header_cell_closed"); + assert.containsN( + pivot, + ".o_dropdown_menu .o_menu_item", + 3, + "should have 3 dropdown items in pivot groupby dropdown" + ); + + // applying custom groupby in pivot groupby dropdown will not update search dropdown + await toggleGroupByMenu(pivot); + assert.containsN( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + 3, + "should still have 3 dropdown items in searchview groupby dropdown" + ); + }); + + QUnit.test( + "pivot groupby dropdown renders custom search at the end with separator", + async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + + `, + }); + + // open group by dropdown + await toggleGroupByMenu(pivot); + assert.containsN( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + 2, + "should have 2 dropdown items in searchview groupby" + ); + await toggleAddCustomGroup(pivot); + await applyGroup(pivot); + assert.containsN( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + 3, + "should have 3 dropdown items in searchview groupby now" + ); + + // click on closed header to open dropdown + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_closed")[1]); + let items = pivot.el.querySelectorAll(".o_menu_item"); + assert.deepEqual( + [...items].map((it) => it.innerText), + ["bar", "product"] + ); + assert.containsOnce( + pivot, + "tbody .o_dropdown_menu .dropdown-divider", + "pivot groupby menu should only have one separator" + ); + assert.hasClass( + items[items.length - 1].nextSibling, + "dropdown-divider", + "pivot groupby menu separator is placed after all menu items" + ); + + // add a custom group in pivot groupby + await click(pivot.el, ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_toggler"); + pivot.el.querySelector(".o_add_custom_group_menu select").value = "customer"; + await triggerEvent( + pivot.el, + ".o_dropdown_menu .o_add_custom_group_menu select", + "change" + ); + await click( + pivot.el, + ".o_dropdown_menu .o_add_custom_group_menu .o_dropdown_menu .btn" + ); + + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_closed")[1]); + items = pivot.el.querySelectorAll(".o_menu_item"); + assert.deepEqual( + [...items].map((it) => it.innerText), + ["bar", "product", "Customer"] + ); + assert.containsN( + pivot, + "tbody .o_dropdown_menu .dropdown-divider", + 2, + "pivot groupby menu should now have two separators" + ); + assert.hasClass( + items[items.length - 1].previousSibling, + "dropdown-divider", + "last pivot groupby menu item is placed after a separator" + ); + assert.hasClass( + items[items.length - 1].nextSibling, + "dropdown-divider", + "a pivot groupby menu separator is placed after all menu items" + ); + } + ); + + QUnit.test( + "pivot custom groupby: grouping on date field use default interval month", + async function (assert) { + assert.expect(1); + + let checkReadGroup = false; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + mockRPC(route, args) { + if (args.method === "read_group" && checkReadGroup) { + assert.deepEqual( + args.kwargs.groupby, + ["date:month"], + "should use default month as an interval in read_group" + ); + checkReadGroup = false; + } + }, + }); + + // click on closed header to open dropdown and apply groupby on date field + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click( + pivot.el.querySelector( + "thead .o_dropdown_menu .o_add_custom_group_menu .o_dropdown_toggler " + ) + ); + + checkReadGroup = true; + const select = pivot.el.querySelector(".o_add_custom_group_menu select"); + select.value = "date"; + select.dispatchEvent(new Event("change")); + await click(pivot.el.querySelector(".o_add_custom_group_menu .btn-primary")); + } + ); + + QUnit.test("pivot view without group by specified in search arch", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + // open group by dropdown + await toggleGroupByMenu(pivot); + assert.containsNone( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + "should not have any dropdown item in searchview groupby" + ); + assert.containsOnce( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_add_custom_group_menu", + "should have add custom group item in searchview groupby" + ); + // click on closed header to open dropdown + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_closed")[1]); + assert.containsN( + pivot, + "tbody .o_dropdown_menu .o_menu_item", + 6, + "should have 6 dropdown items i.e. all groupable fields available" + ); + assert.containsOnce( + pivot, + ".o_dropdown_menu .o_add_custom_group_menu", + "should have custom group generator in groupby dropdown" + ); + }); + + QUnit.test( + "pivot view do not show custom group selection if there are no groupable fields", + async function (assert) { + assert.expect(4); + + for (const fieldName of [ + "bar", + "company_type", + "customer", + "date", + "other_product_id", + ]) { + delete serverData.models.partner.fields[fieldName]; + } + + // Keep product_id but make it ungroupable + delete serverData.models.partner.fields.product_id.sortable; + delete serverData.models.partner.fields.product_id.store; + + serverData.models.partner.records = [ + { + id: 1, + foo: 12, + product_id: 37, + computed_field: 19, + }, + ]; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + }); + + // open group by dropdown + await toggleGroupByMenu(pivot); + assert.containsOnce( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item", + "should have 1 dropdown item in searchview groupby" + ); + assert.containsNone( + pivot, + ".o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_add_custom_group_menu", + "should not have custom group generator in searchview groupby" + ); + + // click on closed header to open dropdown + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + assert.containsOnce( + pivot, + "tbody .o_dropdown_menu .o_dropdown_item", + "should have 1 dropdown items" + ); + assert.containsNone( + pivot, + ".o_dropdown_menu .o_add_custom_group_menu", + "should not have custom group generator in groupby dropdown" + ); + } + ); + + QUnit.test( + "pivot custom groupby: adding a custom group close the pivot groupby menu", + async function (assert) { + assert.expect(3); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + }); + + // click on closed header to open dropdown + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + assert.containsOnce(pivot, "thead .o_dropdown_menu .o_add_custom_group_menu"); + await click( + pivot.el.querySelector( + "thead .o_dropdown_menu .o_add_custom_group_menu .o_dropdown_toggler" + ) + ); + assert.containsOnce( + pivot, + "thead .o_dropdown_menu .o_add_custom_group_menu .o_dropdown_menu" + ); + + // click on apply button should close dropdown + await click( + pivot.el.querySelector( + "thead .o_dropdown_menu .o_add_custom_group_menu .o_dropdown_menu .btn-primary" + ) + ); + assert.containsNone(pivot, "thead .o_dropdown_menu"); + } + ); + + QUnit.test("can toggle extra measure", async function (assert) { + let rpcCount = 0; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + mockRPC() { + rpcCount++; + }, + }); + + rpcCount = 0; + assert.containsN( + pivot, + ".o_pivot_cell_value", + 3, + "should have 3 cells: 1 for the open header, and 2 for data" + ); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.doesNotHaveClass( + $(pivot.el).find(".o_dropdown_item:contains(Count)"), + "selected", + "the __count measure should not be selected" + ); + await click($(pivot.el).find(".o_cp_bottom_left .o_dropdown_item:contains(Count)")[0]); + + assert.hasClass( + $(pivot.el).find(".o_cp_bottom_left .o_dropdown_item:contains(Count)"), + "selected", + "the __count measure should be selected" + ); + assert.containsN( + pivot, + ".o_pivot_cell_value", + 6, + "should have 6 cells: 2 for the open header, and 4 for data" + ); + assert.strictEqual(rpcCount, 2, "should have done 2 rpcs to reload data"); + + await click($(pivot.el).find(".o_cp_bottom_left .o_dropdown_item:contains(Count)")[0]); + + assert.doesNotHaveClass( + $(pivot.el).find(".o_dropdown_item:contains(Count)")[0], + "selected", + "the __count measure should not be selected" + ); + assert.containsN( + pivot, + ".o_pivot_cell_value", + 3, + "should have 3 cells: 1 for the open header, and 2 for data" + ); + assert.strictEqual(rpcCount, 2, "should not have done any extra rpcs"); + }); + + QUnit.test("no content helper when no active measure", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ``, + }); + + assert.containsNone(pivot, ".o_view_nocontent"); + assert.containsOnce(pivot, "table"); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + await click($(pivot.el).find(".o_cp_bottom_left .o_dropdown_item:contains(Count)")[0]); + + assert.containsOnce(pivot, ".o_view_nocontent"); + assert.containsNone(pivot, "table"); + }); + + QUnit.test("no content helper when no data", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ``, + searchViewArch: ` + + + `, + }); + + assert.containsNone(pivot, ".o_view_nocontent"); + assert.containsOnce(pivot, "table"); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Some Filter"); + + assert.containsOnce(pivot, ".o_view_nocontent"); + assert.containsNone(pivot, "table"); + }); + + QUnit.test("no content helper when no data, part 2", async function (assert) { + serverData.models.partner.records = []; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + }); + + assert.containsOnce(pivot, ".o_view_nocontent"); + }); + + QUnit.test("no content helper when no data, part 3", async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + searchViewArch: ` + + + + `, + context: { + search_default_foo: 12345, + }, + }); + + assert.containsOnce(pivot, ".o_searchview .o_searchview_facet"); + assert.containsOnce(pivot, ".o_view_nocontent"); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Some Filter"); + assert.containsN(pivot, ".o_searchview .o_searchview_facet", 2); + assert.containsOnce(pivot, ".o_view_nocontent"); + + await toggleMenuItem(pivot, "Some Filter"); + assert.containsOnce(pivot, ".o_searchview .o_searchview_facet"); + assert.containsOnce(pivot, ".o_view_nocontent"); + + await click(pivot.el, ".o_facet_remove"); + assert.containsNone(pivot, ".o_searchview .o_searchview_facet"); + assert.containsNone(pivot, ".o_view_nocontent"); + + // tries to open a field selection menu, to make sure it was not + // removed from the dom. + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + assert.containsOnce(pivot, "tbody .o_dropdown_menu"); + }); + + QUnit.test("tries to restore previous state after domain change", async function (assert) { + assert.expect(7); + + let rpcCount = 0; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + mockRPC() { + rpcCount++; + }, + }); + + assert.containsN( + pivot, + ".o_pivot_cell_value", + 3, + "should have 3 cells: 1 for the open header, and 2 for data" + ); + assert.containsOnce( + pivot, + ".o_pivot_measure_row:contains(Foo)", + "should have 1 row for measure Foo" + ); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My Filter"); + assert.containsNone(pivot, "table"); + + rpcCount = 0; + await removeFacet(pivot); + + assert.containsOnce(pivot, "table"); + assert.equal(rpcCount, 2, "should have reloaded data"); + assert.containsN( + pivot, + ".o_pivot_cell_value", + 3, + "should still have 3 cells: 1 for the open header, and 2 for data" + ); + assert.containsOnce( + pivot, + ".o_pivot_measure_row:contains(Foo)", + "should still have 1 row for measure Foo" + ); + }); + + QUnit.test("can be grouped with the search view", async function (assert) { + assert.expect(4); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + searchViewArch: ` + + + `, + }); + + assert.containsOnce(pivot, ".o_pivot_cell_value", "should have only 1 cell"); + assert.containsOnce(pivot, "tbody tr", "should have 1 rows"); + + await toggleGroupByMenu(pivot); + await toggleMenuItem(pivot, "Product"); + + assert.containsN(pivot, ".o_pivot_cell_value", 3, "should have 3 cells"); + assert.containsN(pivot, "tbody tr", 3, "should have 3 rows"); + }); + + QUnit.test("can sort data in a column by clicking on header", async function (assert) { + assert.expect(3); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + let values = ["32", "12", "20"]; + assert.strictEqual( + getCurrentValues(pivot), + values.join(","), + "should have proper values in cells (total, result 1, result 2)" + ); + + await click(pivot.el.querySelector("th.o_pivot_measure_row")); + + values = ["32", "12", "20"]; + assert.strictEqual( + getCurrentValues(pivot), + values.join(","), + "should have proper values in cells (total, result 1, result 2)" + ); + + await click(pivot.el.querySelector("th.o_pivot_measure_row")); + + values = ["32", "20", "12"]; + assert.strictEqual( + getCurrentValues(pivot), + values.join(","), + "should have proper values in cells (total, result 1, result 2)" + ); + }); + + QUnit.test("can expand all rows", async function (assert) { + assert.expect(7); + + let nbReadGroups = 0; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "read_group") { + nbReadGroups++; + } + }, + }); + + assert.strictEqual(nbReadGroups, 2, "should have done 2 read_group RPCS"); + assert.strictEqual( + getCurrentValues(pivot), + "32,12,20", + "should have proper values in cells (total, result 1, result 2)" + ); + + // expand on date:days, product + await toggleGroupByMenu(pivot); + await toggleMenuItem(pivot, "Date"); + await toggleMenuItemOption(pivot, "Date", "Month"); + nbReadGroups = 0; + await toggleMenuItem(pivot, "Product"); + + assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS"); + assert.containsN( + pivot, + "tbody tr", + 8, + "should have 7 rows (total + 3 for December and 2 for October and April)" + ); + + // collapse the last two rows + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_opened")[3]); + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_opened")[2]); + + assert.containsN(pivot, "tbody tr", 6, "should have 6 rows now"); + + // expand all + nbReadGroups = 0; + await click(pivot.el.querySelector(".o_pivot_expand_button")); + + assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS"); + assert.containsN(pivot, "tbody tr", 8, "should have 8 rows again"); + }); + + QUnit.test("expand all with a delay", async function (assert) { + assert.expect(4); + + let def; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "read_group") { + return Promise.resolve(def); + } + }, + }); + + // expand on date:days, product + await toggleGroupByMenu(pivot); + await toggleMenuItem(pivot, "Date"); + await toggleMenuItemOption(pivot, "Date", "Month"); + await toggleMenuItem(pivot, "Product"); + assert.containsN( + pivot, + "tbody tr", + 8, + "should have 7 rows (total + 3 for December and 2 for October and April)" + ); + + // collapse the last two rows + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_opened")[3]); + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_opened")[2]); + + assert.containsN(pivot, "tbody tr", 6, "should have 6 rows now"); + + // expand all + def = makeDeferred(); + await click(pivot.el.querySelector(".o_pivot_expand_button")); + assert.containsN(pivot, "tbody tr", 6, "should have 6 rows now"); + def.resolve(); + await nextTick(); + assert.containsN(pivot, "tbody tr", 8, "should have 8 rows again"); + }); + + QUnit.test("can download a file", async function (assert) { + assert.expect(1); + + mockDownload(({ url }) => { + assert.strictEqual(url, "/web/pivot/export_xlsx"); + return Promise.resolve(); + }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + await click(pivot.el.querySelector(".o_pivot_download")); + }); + + QUnit.test( + "download a file with single measure, measure row displayed in table", + async function (assert) { + assert.expect(2); + + mockDownload(({ url, data }) => { + data = JSON.parse(data.data); + assert.strictEqual(url, "/web/pivot/export_xlsx"); + assert.strictEqual( + data.measure_headers.length, + 4, + "should have measure_headers in data" + ); + return Promise.resolve(); + }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + await click(pivot.el.querySelector(".o_pivot_download")); + } + ); + + QUnit.test("download button is disabled when there is no data", async function (assert) { + assert.expect(1); + + serverData.models.partner.records = []; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.hasAttrValue( + pivot.el.querySelector(".o_pivot_download"), + "disabled", + "disabled", + "download button should be disabled" + ); + }); + + QUnit.test("correctly save measures and groupbys to favorite", async function (assert) { + assert.expect(3); + + let expectedContext; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "create_or_replace") { + assert.deepEqual(args.args[0].context, expectedContext); + return true; + } + }, + }); + + expectedContext = { + group_by: [], + pivot_column_groupby: ["date:day"], + pivot_measures: ["foo"], + pivot_row_groupby: [], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "Fav1"); + await saveFavorite(pivot); + + // expand header on field customer + await click(pivot.el.querySelectorAll("thead .o_pivot_header_cell_closed")[1]); + await click(pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_item")[1]); + expectedContext = { + group_by: [], + pivot_column_groupby: ["date:day", "customer"], + pivot_measures: ["foo"], + pivot_row_groupby: [], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "Fav2"); + await saveFavorite(pivot); + + // expand row on field product_id + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("tbody .o_dropdown_menu .o_dropdown_item")[4]); + expectedContext = { + group_by: [], + pivot_column_groupby: ["date:day", "customer"], + pivot_measures: ["foo"], + pivot_row_groupby: ["product_id"], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "Fav3"); + await saveFavorite(pivot); + }); + + QUnit.test("correctly remove pivot_ keys from the context", async function (assert) { + assert.expect(5); + + // in this test, we use "foo" as a measure + serverData.models.partner.fields.foo.store = true; + serverData.models.partner.fields.amount = { + string: "Amount", + type: "float", + group_operator: "sum", + }; + + let expectedContext; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + context: { + search_default_initial_context: 1, + }, + searchViewArch: ` + + + `, + mockRPC(route, args) { + if (args.method === "create_or_replace") { + assert.deepEqual(args.args[0].context, expectedContext); + return true; + } + }, + }); + + // Unload the filter + await removeFacet(pivot); // remove previous favorite + expectedContext = { + group_by: [], + pivot_column_groupby: ["customer"], + pivot_measures: ["foo"], + pivot_row_groupby: ["product_id"], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "1"); + await saveFavorite(pivot); + + // Let's get rid of the rows groupBy + await click(pivot.el, "tbody .o_pivot_header_cell_opened"); + expectedContext = { + group_by: [], + pivot_column_groupby: ["customer"], + pivot_measures: ["foo"], + pivot_row_groupby: [], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "2"); + await saveFavorite(pivot); + + // And now, get rid of both col and row groupby + //await click(pivot.el, "tbody .o_pivot_header_cell_opened"); //It was already removed + await click(pivot.el, "thead .o_pivot_header_cell_opened"); + expectedContext = { + group_by: [], + pivot_column_groupby: [], + pivot_measures: ["foo"], + pivot_row_groupby: [], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "3"); + await saveFavorite(pivot); + + // Group row by product_id + await click(pivot.el, "tbody .o_pivot_header_cell_closed"); + await click(pivot.el, ".o_dropdown_menu li:nth-child(5)"); + expectedContext = { + group_by: [], + pivot_column_groupby: [], + pivot_measures: ["foo"], + pivot_row_groupby: ["product_id"], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "4"); + await saveFavorite(pivot); + + // Group column by customer + await click(pivot.el, "thead .o_pivot_header_cell_closed"); + await click(pivot.el, ".o_dropdown_menu li:nth-child(2)"); + expectedContext = { + group_by: [], + pivot_column_groupby: ["customer"], + pivot_measures: ["foo"], + pivot_row_groupby: ["product_id"], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "5"); + await saveFavorite(pivot); + }); + + QUnit.test("Apply two groupby, and remove facet", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + `, + "partner,false,search": ` + + + + `, + }; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + }); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "First" + ); + + // Apply both groupbys + await toggleGroupByMenu(webClient); + await toggleMenuItem(webClient, "Product"); + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "xphone" + ); + + await toggleMenuItem(webClient, "Bar"); + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "true" + ); + + // remove filter + await removeFacet(webClient); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "true" + ); + }); + + QUnit.test("Add a group by on the CP when a favorite already exists", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + `, + "partner,false,search": ` + + + `, + }; + + serverData.models.partner.filters = [ + { + context: "{'pivot_row_groupby': ['date']}", + domain: "[]", + id: 7, + is_default: true, + name: "My favorite", + sort: "[]", + user_id: [2, "Mitchell Admin"], + }, + ]; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + }); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "December 2016" + ); + + // Apply BAR groupbys + await toggleGroupByMenu(webClient); + await toggleMenuItem(webClient, "Bar"); + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "true" + ); + + // remove groupBy + await toggleMenuItem(webClient, "Bar"); + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "December 2016" + ); + + // remove all facets + await removeFacet(webClient); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "December 2016" + ); + }); + + QUnit.skip("Removing or adding filter shouldn't modify the row group", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + `, + "partner,false,search": ` + + + `, + }; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + context: { search_default_bayou: 1, group_by: ["company_type"] }, + }); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "Company" + ); + + // Let's get rid of the rows groupBy + await click(webClient.el, "tbody .o_pivot_header_cell_opened"); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "Total" + ); + + // Group row by product_id + await click(webClient.el, "tbody .o_pivot_header_cell_closed"); + await click(webClient.el, ".o_dropdown_menu li:nth-child(5)"); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "xphone" + ); + + // remove filter + await removeFacet(webClient); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "xphone" + ); + }); + + QUnit.test( + "Adding a Favorite at anytime should modify the row/column groupby", + async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + + `, + "partner,false,search": "", + }; + serverData.models["partner"].filters = [ + { + user_id: [2, "Mitchell Admin"], + name: "My favorite", + id: 5, + context: `{"pivot_row_groupby":["product_id"], "pivot_column_groupby": ["bar"]}`, + sort: "[]", + domain: "", + is_default: false, + model_id: "partner", + action_id: false, + }, + ]; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + }); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "First" + ); + + assert.strictEqual( + webClient.el.querySelector("thead .o_pivot_header_cell_closed").textContent, + "December 2016" + ); + + // activate the unique existing favorite + await toggleFavoriteMenu(webClient); + await toggleMenuItem(webClient, 0); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "xphone" + ); + + assert.strictEqual( + webClient.el.querySelector("thead .o_pivot_header_cell_closed").textContent, + "true" + ); + + // desactivate the unique existing favorite + await toggleMenuItem(webClient, 0); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "xphone" + ); + + assert.strictEqual( + webClient.el.querySelector("thead .o_pivot_header_cell_closed").textContent, + "true" + ); + + // Let's get rid of the rows and columns groupBy + await click(webClient.el, "tbody .o_pivot_header_cell_opened"); + await click(webClient.el, "thead .o_pivot_header_cell_opened"); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "Total" + ); + + assert.strictEqual( + webClient.el.querySelector("thead .o_pivot_header_cell_closed").textContent, + "Total" + ); + + // activate AGAIN the unique existing favorite + await toggleFavoriteMenu(webClient); + await toggleMenuItem(webClient, 0); + + assert.strictEqual( + webClient.el.querySelector("tbody .o_pivot_header_cell_closed").textContent, + "xphone" + ); + + assert.strictEqual( + webClient.el.querySelector("thead .o_pivot_header_cell_closed").textContent, + "true" + ); + } + ); + + QUnit.test("Unload Filter, reset display, load another filter", async function (assert) { + assert.expect(18); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + context: { + pivot_measures: ["foo"], + pivot_column_groupby: ["customer"], + pivot_row_groupby: ["product_id"], + }, + + searchViewArch: ` + + + + `, + }); + + // Check Columns + assert.strictEqual( + $(pivot.el).find("thead .o_pivot_header_cell_opened").length, + 1, + "The column should be grouped" + ); + assert.strictEqual( + $(pivot.el).find('thead tr:contains("First")').length, + 1, + 'There should be a column "First"' + ); + assert.strictEqual( + $(pivot.el).find('thead tr:contains("Second")').length, + 1, + 'There should be a column "Second"' + ); + + // Check Rows + assert.strictEqual( + $(pivot.el).find("tbody .o_pivot_header_cell_opened").length, + 1, + "The row should be grouped" + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("xphone")').length, + 1, + 'There should be a row "xphone"' + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("xpad")').length, + 1, + 'There should be a row "xpad"' + ); + + // Equivalent to unload the filter + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My fake favorite"); + // collapse all headers + await click(pivot.el, ".o_pivot_header_cell_opened:first-child"); + await click(pivot.el, ".o_pivot_header_cell_opened"); + + // Check Columns + assert.strictEqual( + $(pivot.el).find("thead .o_pivot_header_cell_closed").length, + 1, + "The column should not be grouped" + ); + assert.strictEqual( + $(pivot.el).find('thead tr:contains("First")').length, + 0, + 'There should not be a column "First"' + ); + assert.strictEqual( + $(pivot.el).find('thead tr:contains("Second")').length, + 0, + 'There should not be a column "Second"' + ); + + // Check Rows + assert.strictEqual( + $(pivot.el).find("tbody .o_pivot_header_cell_closed").length, + 1, + "The row should not be grouped" + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("xphone")').length, + 0, + 'There should not be a row "xphone"' + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("xpad")').length, + 0, + 'There should not be a row "xpad"' + ); + + // Equivalent to load another filter + await removeFacet(pivot); // remove previously saved favorite + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My fake favorite 2"); + + // Check Columns + assert.strictEqual( + $(pivot.el).find("thead .o_pivot_header_cell_opened").length, + 1, + "The column should be grouped" + ); + assert.strictEqual( + $(pivot.el).find('thead tr:contains("First")').length, + 1, + 'There should be a column "First"' + ); + assert.strictEqual( + $(pivot.el).find('thead tr:contains("Second")').length, + 1, + 'There should be a column "Second"' + ); + + // Check Rows + assert.strictEqual( + $(pivot.el).find("tbody .o_pivot_header_cell_opened").length, + 1, + "The row should be grouped" + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("xphone")').length, + 1, + 'There should be a row "xphone"' + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("xpad")').length, + 1, + 'There should be a row "xpad"' + ); + }); + + QUnit.test("Reload, group by columns, reload", async function (assert) { + assert.expect(2); + + let expectedContext; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + searchViewArch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "create_or_replace") { + assert.deepEqual(args.args[0].context, expectedContext); + return true; + } + }, + }); + + // Set a column groupby + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("thead .o_dropdown_item")[1]); + + // Set a domain + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My Filter 1"); + + // Save to favorites and check that column groupbys were not lost + expectedContext = { + group_by: [], + pivot_column_groupby: ["customer"], + pivot_measures: ["__count"], + pivot_row_groupby: [], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "My favorite 1"); + await saveFavorite(pivot); + + // Set a column groupby + await removeFacet(pivot); // remove previously saved favorite + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_item")[4]); + + // Set a domain + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My Filter 2"); + + expectedContext = { + group_by: [], + pivot_column_groupby: ["customer", "product_id"], + pivot_measures: ["__count"], + pivot_row_groupby: [], + }; + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "My favorite 2"); + await saveFavorite(pivot); + }); + + QUnit.test("folded groups remain folded at reload", async function (assert) { + assert.expect(5); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + `, + searchViewArch: ` + + + `, + }); + + let values = ["29", "3", "32", "12", "12", "17", "3", "20"]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + + // expand a col group + await click(pivot.el.querySelectorAll("thead .o_pivot_header_cell_closed")[1]); + await click(pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_item")[1]); + + values = ["29", "1", "2", "32", "12", "12", "17", "1", "2", "20"]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + + // expand a row group + await click(pivot.el.querySelectorAll("tbody .o_pivot_header_cell_closed")[1]); + await click(pivot.el.querySelectorAll("tbody .o_dropdown_menu .o_dropdown_item")[3]); + + values = ["29", "1", "2", "32", "12", "12", "17", "1", "2", "20", "17", "1", "2", "20"]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + + // reload (should keep folded groups folded as col/row groupbys didn't change) + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Dummy Filter"); + + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + + await click(pivot.el.querySelector(".o_pivot_expand_button")); + + // sanity check of what the table should look like if all groups are + // expanded, to ensure that the former asserts are pertinent + values = [ + "12", + "17", + "1", + "2", + "32", + "12", + "12", + "12", + "12", + "17", + "1", + "2", + "20", + "17", + "1", + "2", + "20", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + }); + + QUnit.test("Empty results keep groupbys", async function (assert) { + assert.expect(6); + + const expectedContext = { + group_by: [], + pivot_column_groupby: ["customer"], + pivot_measures: ["__count"], + pivot_row_groupby: [], + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + searchViewArch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "create_or_replace") { + assert.deepEqual(args.args[0].context, expectedContext); + return true; + } + }, + }); + + // Set a column groupby + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_item")[1]); + + assert.containsOnce(pivot, "table"); + + // Set a domain for empty results + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My Filter 1"); + assert.containsNone(pivot, "table"); + + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "My favorite 1"); + await saveFavorite(pivot); + + // Set a domain for not empty results + await removeFacet(pivot); // remove previously saved favorite + assert.containsOnce(pivot, "table"); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My Filter 2"); + assert.containsOnce(pivot, "table"); + + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "My favorite 2"); + await saveFavorite(pivot); + }); + + QUnit.test("correctly uses pivot_ keys from the context", async function (assert) { + assert.expect(7); + + // in this test, we use "foo" as a measure + serverData.models.partner.fields.foo.store = true; + serverData.models.partner.fields.amount = { + string: "Amount", + type: "float", + group_operator: "sum", + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + context: { + pivot_measures: ["foo"], + pivot_column_groupby: ["customer"], + pivot_row_groupby: ["product_id"], + }, + }); + + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_opened", + "column: should have one opened header" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(First)", + "column: should display one closed header with 'First'" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(Second)", + "column: should display one closed header with 'Second'" + ); + + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_opened", + "row: should have one opened header" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xphone)", + "row: should display one closed header with 'xphone'" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xpad)", + "row: should display one closed header with 'xpad'" + ); + + assert.strictEqual( + pivot.el.querySelector("tbody tr").querySelectorAll("td")[2].innerText, + "32", + "selected measure should be foo, with total 32" + ); + }); + + QUnit.test("clear table cells data after closeGroup", async function (assert) { + assert.expect(2); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + groupBy: ["product_id"], + }); + + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelector("thead .o_dropdown_menu .o_dropdown_toggler")); + await click( + pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_menu .o_dropdown_item")[3] + ); + + // close and reopen row groupings after changing value + serverData.models.partner.records.find((r) => r.product_id === 37).date = "2016-10-27"; + + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_opened")); + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("tbody .o_dropdown_menu .o_dropdown_item")[4]); + assert.strictEqual(pivot.el.querySelectorAll(".o_pivot_cell_value")[4].innerText, ""); // xphone December 2016 + + // invert axis, and reopen column groupings + await click(pivot.el.querySelector(".o_cp_bottom_left .o_pivot_flip_button")); + await click(pivot.el.querySelector("thead .o_pivot_header_cell_opened")); + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_item")[4]); + assert.strictEqual(pivot.el.querySelectorAll(".o_pivot_cell_value")[3].innerText, ""); // December 2016 xphone + }); + + QUnit.test("correctly group data after flip (1)", async function (assert) { + assert.expect(4); + + serverData.views = { + "partner,false,pivot": "", + "partner,false,search": ``, + "partner,false,list": '', + "partner,false,form": '
', + }; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + context: { group_by: ["product_id"] }, + }); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total", "xphone", "xpad"] + ); + + // flip axis + await click(webClient.el.querySelector(".o_pivot_flip_button")); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total"] + ); + + // select filter "Bayou" in control panel + await toggleFilterMenu(webClient); + await toggleMenuItem(webClient, "Bayou"); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total", "xphone", "xpad"] + ); + + // close row header "Total" + await click(webClient.el.querySelector("tbody .o_pivot_header_cell_opened")); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total"] + ); + }); + + QUnit.test("correctly group data after flip (2)", async function (assert) { + assert.expect(5); + + serverData.views = { + "partner,false,pivot": "", + "partner,false,search": ``, + "partner,false,list": '', + "partner,false,form": '
', + }; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + context: { group_by: ["product_id"] }, + }); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total", "xphone", "xpad"] + ); + + // select filter "Bayou" in control panel + await toggleFilterMenu(webClient); + await toggleMenuItem(webClient, "Bayou"); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total", "xphone", "xpad"] + ); + + // flip axis + await click(webClient.el.querySelector(".o_pivot_flip_button")); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total"] + ); + + // unselect filter "Bayou" in control panel + await toggleFilterMenu(webClient); + await toggleMenuItem(webClient, "Bayou"); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total", "xphone", "xpad"] + ); + + // close row header "Total" + await click(webClient.el.querySelector("tbody .o_pivot_header_cell_opened")); + + assert.deepEqual( + [...webClient.el.querySelectorAll("tbody th")].map((e) => e.innerText), + ["Total"] + ); + }); + + QUnit.test("correctly uses pivot_ keys from the context (at reload)", async function (assert) { + assert.expect(8); + + // in this test, we use "foo" as a measure + serverData.models.partner.fields.foo.store = true; + serverData.models.partner.fields.amount = { + string: "Amount", + type: "float", + group_operator: "sum", + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + }); + + assert.strictEqual( + pivot.el.querySelector("tbody tr").querySelectorAll("td.o_pivot_cell_value")[4] + .innerText, + "0.00", + "the active measure should be amount" + ); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "My fake favorite"); + + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_opened", + "column: should have one opened header" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(First)", + "column: should display one closed header with 'First'" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(Second)", + "column: should display one closed header with 'Second'" + ); + + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_opened", + "row: should have one opened header" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xphone)", + "row: should display one closed header with 'xphone'" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xpad)", + "row: should display one closed header with 'xpad'" + ); + + assert.strictEqual( + pivot.el.querySelector("tbody tr").querySelectorAll("td")[2].innerText, + "32", + "selected measure should be foo, with total 32" + ); + }); + + QUnit.test("correctly use group_by key from the context", async function (assert) { + assert.expect(7); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + groupBy: ["product_id"], + }); + + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_opened", + "column: should have one opened header" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(First)", + 'column: should display one closed header with "First"' + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(Second)", + 'column: should display one closed header with "Second"' + ); + + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_opened", + "row: should have one opened header" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xphone)", + 'row: should display one closed header with "xphone"' + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xpad)", + 'row: should display one closed header with "xpad"' + ); + + assert.strictEqual( + pivot.el.querySelector("tbody tr").querySelectorAll("td")[2].innerText, + "32", + "selected measure should be foo, with total 32" + ); + }); + + QUnit.test( + "correctly uses pivot_row_groupby key with default groupBy from the context", + async function (assert) { + assert.expect(6); + + serverData.models.partner.fields.amount = { + string: "Amount", + type: "float", + group_operator: "sum", + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + groupBy: ["customer"], + context: { + pivot_row_groupby: ["product_id"], + }, + }); + + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_opened", + "column: should have one opened header" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(First)", + "column: should display one closed header with 'First'" + ); + assert.containsOnce( + pivot, + "thead .o_pivot_header_cell_closed:contains(Second)", + "column: should display one closed header with 'Second'" + ); + + // With pivot_row_groupby, groupBy customer should replace and eventually display product_id + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_opened", + "row: should have one opened header" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xphone)", + "row: should display one closed header with 'xphone'" + ); + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed:contains(xpad)", + "row: should display one closed header with 'xpad'" + ); + } + ); + + QUnit.test("pivot still handles __count__ measure", async function (assert) { + // for retro-compatibility reasons, the pivot view still handles + // '__count__' measure. + assert.expect(4); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: "", + mockRPC(route, args) { + if (args.method === "read_group") { + assert.deepEqual( + args.kwargs.fields, + ["__count"], + "should make a read_group with field __count" + ); + } + }, + context: { + pivot_measures: ["__count__"], + }, + }); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.containsOnce(pivot, ".o_cp_bottom_left .o_dropdown_item"); + assert.strictEqual( + pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item").innerText, + "Count" + ); + assert.hasClass(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item"), "selected"); + }); + + QUnit.test("not use a many2one as a measure by default", async function (assert) { + assert.expect(3); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.containsOnce(pivot, ".o_cp_bottom_left .o_dropdown_item"); + assert.strictEqual( + pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item").innerText, + "Count" + ); + assert.hasClass(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item"), "selected"); + }); + + QUnit.test( + "use a many2one as a measure with specified additional measure", + async function (assert) { + assert.expect(3); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + additionalMeasures: ["product_id"], + }); + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.containsN(pivot, ".o_cp_bottom_left .o_dropdown_item", 2); + assert.strictEqual( + pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item").innerText, + "Product" + ); + assert.doesNotHaveClass( + pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item"), + "selected" + ); + } + ); + + QUnit.test("pivot view with many2one field as a measure", async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.strictEqual( + pivot.el.querySelector("table tbody tr").innerText.replace(/\s/g, ""), + "Total2112", + "should display product_id count as measure" + ); + }); + + QUnit.test("m2o as measure, drilling down into data", async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + // click on date by month + await click(pivot.el.querySelector("tbody .o_dropdown_menu .o_dropdown_toggler")); + await click( + pivot.el.querySelectorAll("tbody .o_dropdown_menu .o_dropdown_menu .o_dropdown_item")[3] + ); + + assert.strictEqual( + [...pivot.el.querySelectorAll(".o_pivot_cell_value")].map((c) => c.innerText).join(""), + "2211", + "should have loaded the proper data" + ); + }); + + QUnit.test( + "pivot view with same many2one field as a measure and grouped by", + async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + additionalMeasures: ["product_id"], + }); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + await click(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_item")); + assert.strictEqual( + [...pivot.el.querySelectorAll(".o_pivot_cell_value")] + .map((c) => c.innerText) + .join(""), + "421131", + "should have loaded the proper data" + ); + } + ); + + QUnit.test( + "pivot view with same many2one field as a measure and grouped by (and drill down)", + async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("tbody .o_dropdown_item")[4]); + + assert.strictEqual( + [...pivot.el.querySelectorAll(".o_pivot_cell_value")] + .map((c) => c.innerText) + .join(""), + "211", + "should have loaded the proper data" + ); + } + ); + + QUnit.test("Row and column groupbys plus a domain", async function (assert) { + assert.expect(3); + + const expectedContext = { + group_by: [], + pivot_column_groupby: ["customer"], + pivot_measures: ["foo"], + pivot_row_groupby: ["product_id"], + }; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + searchViewArch: ` + + + `, + mockRPC(route, args) { + if (args.method === "create_or_replace") { + assert.deepEqual(args.args[0].context, expectedContext); + return true; + } + }, + }); + + // Set a column groupby + await click(pivot.el.querySelector("thead .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("thead .o_dropdown_item")[1]); + + // Set a Row groupby + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("tbody .o_dropdown_item")[4]); + + // Add a filter + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Some Filter"); + + assert.containsOnce( + pivot, + "tbody .o_pivot_header_cell_closed", + "There should be only one product line because of the domain" + ); + assert.strictEqual( + pivot.el.querySelector("tbody .o_pivot_header_cell_closed").innerText, + "xpad", + "The product should be the right one" + ); + + // Save current search to favorite + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "My favorite"); + await saveFavorite(pivot); + }); + + QUnit.test( + "parallel data loading should discard all but the last one", + async function (assert) { + assert.expect(6); + + let def; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + searchViewArch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "read_group") { + return Promise.resolve(def); + } + }, + }); + + assert.containsOnce(pivot, ".o_pivot_cell_value", "should have 1 cell initially"); + assert.containsOnce(pivot, "tbody tr", "should have 1 row initially"); + + def = makeDeferred(); + await toggleGroupByMenu(pivot); + await toggleMenuItem(pivot, "Product"); + await toggleMenuItem(pivot, "Customer"); + + assert.containsOnce(pivot, ".o_pivot_cell_value", "should still have 1 cell"); + assert.containsOnce(pivot, "tbody tr", "should still have 1 row"); + + await def.resolve(); + await nextTick(); + + assert.containsN(pivot, ".o_pivot_cell_value", 6, "should have 6 cells"); + assert.containsN(pivot, "tbody tr", 6, "should have 6 rows"); + } + ); + + QUnit.test("pivot measures should be alphabetically sorted", async function (assert) { + assert.expect(1); + + // It's important to compare capitalized and lowercased words + // to be sure the sorting is effective with both of them + serverData.models.partner.fields.bouh = { + string: "bouh", + type: "integer", + group_operator: "sum", + }; + serverData.models.partner.fields.modd = { + string: "modd", + type: "integer", + group_operator: "sum", + }; + serverData.models.partner.fields.zip = { + string: "Zip", + type: "integer", + group_operator: "sum", + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + + `, + }); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + assert.strictEqual( + [...pivot.el.querySelectorAll(".o_cp_bottom_left .o_dropdown_item")] + .map((i) => i.innerText) + .join(""), + "bouhFoomoddZipCount" + ); + }); + + QUnit.test("pivot view should use default order for auto sorting", async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + assert.hasClass( + pivot.el.querySelector("thead th.o_pivot_measure_row"), + "o_pivot_sort_order_asc" + ); + }); + + QUnit.test("pivot view can be flipped", async function (assert) { + assert.expect(5); + + var rpcCount = 0; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + mockRPC() { + rpcCount++; + }, + }); + + assert.containsN( + pivot, + "tbody tr", + 3, + "should have 3 rows: 1 for the open header, and 2 for data" + ); + let values = ["4", "1", "3"]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + rpcCount = 0; + await click(pivot.el.querySelector(".o_pivot_flip_button")); + + assert.strictEqual(rpcCount, 0, "should not have done any rpc"); + assert.containsOnce(pivot, "tbody tr", "should have 1 rows: 1 for the main header"); + + values = ["1", "3", "4"]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + }); + + QUnit.test("rendering of pivot view with comparison", async function (assert) { + assert.expect(8); + + serverData.models.partner.records[0].date = "2016-12-15"; + serverData.models.partner.records[1].date = "2016-12-17"; + serverData.models.partner.records[2].date = "2016-11-22"; + serverData.models.partner.records[3].date = "2016-11-03"; + + patchDate(2016, 11, 20, 1, 0, 0); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + additionalMeasures: ["product_id"], + context: { search_default_date_filter: 1 }, + mockRPC(route, args) { + if (args.method === "create_or_replace") { + assert.deepEqual(args.args[0].context, { + pivot_measures: ["__count"], + pivot_column_groupby: [], + pivot_row_groupby: ["product_id"], + group_by: [], + comparison: { + comparisonId: "previous_period", + comparisonRange: [ + "&", + ["date", ">=", "2016-11-01"], + ["date", "<=", "2016-11-30"], + ], + comparisonRangeDescription: "November 2016", + fieldDescription: "Date", + fieldName: "date", + range: [ + "&", + ["date", ">=", "2016-12-01"], + ["date", "<=", "2016-12-31"], + ], + rangeDescription: "December 2016", + }, + }); + return true; + } + }, + }); + + // with no data + await toggleComparisonMenu(pivot); + await toggleMenuItem(pivot, "Date: Previous period"); + + assert.containsOnce(pivot, "p.o_view_nocontent_empty_folder"); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Date"); + await toggleMenuItemOption(pivot, "Date", "December"); + await toggleMenuItemOption(pivot, "Date", "2016"); + await toggleMenuItemOption(pivot, "Date", "2015"); + + assert.containsN( + pivot, + ".o_pivot thead tr:last th", + 9, + "last header row should contains 9 cells (3*[December 2016, November 2016, Variation]" + ); + let values = ["19", "0", "-100%", "0", "13", "100%", "19", "13", "-31.58%"]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // with data, with row groupby + await click(pivot.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("tbody .o_dropdown_menu .o_dropdown_item")[4]); + values = [ + "19", + "0", + "-100%", + "0", + "13", + "100%", + "19", + "13", + "-31.58%", + "19", + "0", + "-100%", + "0", + "1", + "100%", + "19", + "1", + "-94.74%", + "0", + "12", + "100%", + "0", + "12", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await click(pivot.el.querySelector(".o_cp_bottom_left button.o_dropdown_toggler")); + await click( + pivot.el.querySelectorAll(".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item")[0] + ); + await click( + pivot.el.querySelectorAll(".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item")[1] + ); + values = [ + "1", + "0", + "-100%", + "0", + "2", + "100%", + "1", + "2", + "100%", + "1", + "0", + "-100%", + "0", + "1", + "100%", + "1", + "1", + "0%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await click( + pivot.el.querySelectorAll(".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item")[2] + ); + await click( + pivot.el.querySelectorAll(".o_cp_bottom_left .o_dropdown_menu .o_dropdown_item")[1] + ); + values = [ + "2", + "0", + "-100%", + "0", + "2", + "100%", + "2", + "2", + "0%", + "2", + "0", + "-100%", + "0", + "1", + "100%", + "2", + "1", + "-50%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await click(pivot.el.querySelector("thead .o_pivot_header_cell_opened")); + values = ["2", "2", "0%", "2", "1", "-50%", "0", "1", "100%"]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await toggleFavoriteMenu(pivot); + await toggleSaveFavorite(pivot); + await editFavoriteName(pivot, "Fav"); + await saveFavorite(pivot); + }); + + QUnit.test("export data in excel with comparison", async function (assert) { + assert.expect(12); + + serverData.models.partner.records[0].date = "2016-12-15"; + serverData.models.partner.records[1].date = "2016-12-17"; + serverData.models.partner.records[2].date = "2016-11-22"; + serverData.models.partner.records[3].date = "2016-11-03"; + + patchDate(2016, 11, 20, 1, 0, 0); + + mockDownload(({ url, data }) => { + data = JSON.parse(data.data); + for (const l of data.col_group_headers) { + const titles = l.map((o) => o.title); + assert.step(JSON.stringify(titles)); + } + const measures = data.measure_headers.map((o) => o.title); + assert.step(JSON.stringify(measures)); + const origins = data.origin_headers.map((o) => o.title); + assert.step(JSON.stringify(origins)); + assert.step(String(data.measure_count)); + assert.step(String(data.origin_count)); + const valuesLength = data.rows.map((o) => o.values.length); + assert.step(JSON.stringify(valuesLength)); + assert.strictEqual( + url, + "/web/pivot/export_xlsx", + "should call get_file with correct parameters" + ); + return Promise.resolve(); + }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + searchViewArch: ` + + + `, + context: { search_default_date_filter: 1 }, + }); + + // open comparison menu + await toggleComparisonMenu(pivot); + // compare October 2016 to September 2016 + await toggleMenuItem(pivot, "Date: Previous period"); + + // With the data above, the time ranges contain no record. + assert.containsOnce(pivot, "p.o_view_nocontent_empty_folder", "there should be no data"); + // export data should be impossible since the pivot buttons + // are deactivated (exception: the 'Measures' button). + assert.ok( + pivot.el + .querySelector(".o_control_panel button.o_pivot_download") + .getAttribute("disabled") + ); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Date"); + await toggleMenuItemOption(pivot, "Date", "December"); + await toggleMenuItemOption(pivot, "Date", "October"); + assert.notOk( + pivot.el + .querySelector(".o_control_panel button.o_pivot_download") + .getAttribute("disabled") + ); + + // With the data above, the time ranges contain some records. + // export data. Should execute 'get_file' + await click(pivot.el.querySelector(".o_control_panel button.o_pivot_download")); + + assert.verifySteps([ + // col group headers + '["Total",""]', + '["November 2016","December 2016"]', + // measure headers + '["Foo","Foo","Foo"]', + // origin headers + '["November 2016","December 2016","Variation","November 2016","December 2016"' + + ',"Variation","November 2016","December 2016","Variation"]', + // number of 'measures' + "1", + // number of 'origins' + "2", + // rows values length + "[9]", + ]); + }); + + QUnit.test("rendering pivot view with comparison and count measure", async function (assert) { + assert.expect(2); + + let mockMock = false; + let nbReadGroup = 0; + + serverData.models.partner.records[0].date = "2016-12-15"; + serverData.models.partner.records[1].date = "2016-12-17"; + serverData.models.partner.records[2].date = "2016-12-22"; + serverData.models.partner.records[3].date = "2016-12-03"; + + patchDate(2016, 11, 20, 1, 0, 0); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: '', + searchViewArch: ` + + + `, + context: { search_default_date_filter: 1 }, + mockRPC(route, args) { + if (args.method === "read_group" && mockMock) { + nbReadGroup++; + if (nbReadGroup === 4) { + // this modification is necessary because mockReadGroup does not + // properly reflect the server response when there is no record + // and a groupby list of length at least one. + return Promise.resolve([{}]); + } + } + }, + }); + + mockMock = true; + + // compare December 2016 to November 2016 + await toggleComparisonMenu(pivot); + await toggleMenuItem(pivot, "Date: Previous period"); + + const values = ["0", "4", "100%", "0", "2", "100%", "0", "2", "100%"]; + assert.strictEqual(getCurrentValues(pivot), values.join(",")); + assert.containsN( + pivot, + ".o_pivot_header_cell_closed", + 3, + "there should be exactly three closed header ('Total','First', 'Second')" + ); + }); + + QUnit.test( + "can sort a pivot view with comparison by clicking on header", + async function (assert) { + assert.expect(6); + + serverData.models.partner.records[0].date = "2016-12-15"; + serverData.models.partner.records[1].date = "2016-12-17"; + serverData.models.partner.records[2].date = "2016-11-22"; + serverData.models.partner.records[3].date = "2016-11-03"; + + patchDate(2016, 11, 20, 1, 0, 0); + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + `, + searchViewArch: ` + + + `, + additionalMeasures: ["product_id"], + context: { search_default_date_filter: 1 }, + }); + + // compare December 2016 to November 2016 + await toggleComparisonMenu(pivot); + await toggleMenuItem(pivot, "Date: Previous period"); + + // initial sanity check + let values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'Foo' in column Total/Company (should sort by the period of interest, ASC) + await click(pivot.el.querySelector(".o_pivot_measure_row")); + values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click again on 'Foo' in column Total/Company (should sort by the period of interest, DESC) + await click(pivot.el.querySelector(".o_pivot_measure_row")); + values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'This Month' in column Total/Individual/Foo + await click(pivot.el.querySelectorAll(".o_pivot_origin_row")[3]); + values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'Previous Period' in column Total/Individual/Foo + await click(pivot.el.querySelectorAll(".o_pivot_origin_row")[4]); + values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'Variation' in column Total/Foo + await click(pivot.el.querySelectorAll(".o_pivot_origin_row")[8]); + values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + } + ); + + QUnit.test("Click on the measure list but not on a menu item", async function (assert) { + assert.expect(4); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + // have at least a measure to have a separator in the Measures dropdown: + // + // Foo + // ----- + // Count + arch: ``, + }); + + assert.containsNone(pivot, ".o_cp_bottom_left .o_dropdown_menu"); + + // open the "Measures" menu + await click(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_toggler")); + assert.containsOnce(pivot, ".o_cp_bottom_left .o_dropdown_menu"); + + // click on the divider in the "Measures" menu does not crash + await click(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_menu .dropdown-divider")); + // the menu should still be open + assert.containsOnce(pivot, ".o_cp_bottom_left .o_dropdown_menu"); + + // click on the measure list but not on a menu item or the separator + await click(pivot.el.querySelector(".o_cp_bottom_left .o_dropdown_menu")); + // the menu should still be open + assert.containsOnce(pivot, ".o_cp_bottom_left .o_dropdown_menu"); + }); + + QUnit.test( + "Navigation list view for a group and back with breadcrumbs", + async function (assert) { + assert.expect(16); + + serverData.views = { + "partner,false,pivot": ` + + + `, + "partner,false,search": ` + + + `, + "partner,false,list": '', + "partner,false,form": '
', + }; + + let readGroupCount = 0; + const mockRPC = (route, args) => { + if (args.method === "read_group") { + assert.step("read_group"); + const domain = args.kwargs.domain; + if ([0, 1].indexOf(readGroupCount) !== -1) { + assert.deepEqual(domain, [], "domain empty"); + } else if ([2, 3, 4, 5].indexOf(readGroupCount) !== -1) { + assert.deepEqual( + domain, + [["foo", "=", 12]], + "domain conserved when back with breadcrumbs" + ); + } + readGroupCount++; + } + if (route === "/web/dataset/search_read") { + assert.step("search_read"); + const domain = args.domain; + assert.deepEqual( + domain, + ["&", ["customer", "=", 1], ["foo", "=", 12]], + "list domain is correct" + ); + } + }; + + const webClient = await createWebClient({ serverData, mockRPC }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + }); + + await toggleFilterMenu(webClient); + await toggleMenuItem(webClient, 0); + await nextTick(); + + await click(webClient.el.querySelectorAll(".o_pivot_cell_value")[1]); + await legacyExtraNextTick(); + + assert.containsOnce(webClient, ".o_list_view"); + + await click( + webClient.el.querySelector(".o_control_panel ol.breadcrumb li.breadcrumb-item") + ); + + assert.verifySteps([ + "read_group", + "read_group", + "read_group", + "read_group", + "search_read", + "read_group", + "read_group", + ]); + } + ); + + QUnit.test( + "Cell values are kept when flippin a pivot view in comparison mode", + async function (assert) { + assert.expect(2); + + serverData.models.partner.records[0].date = "2016-12-15"; + serverData.models.partner.records[1].date = "2016-12-17"; + serverData.models.partner.records[2].date = "2016-11-22"; + serverData.models.partner.records[3].date = "2016-11-03"; + + patchDate(2016, 11, 20, 1, 0, 0); + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + `, + searchViewArch: ` + + + `, + additionalMeasures: ["product_id"], + context: { search_default_date_filter: 1 }, + }); + + // compare December 2016 to November 2016 + await toggleComparisonMenu(pivot); + await toggleMenuItem(pivot, "Date: Previous period"); + + // initial sanity check + let values = [ + "17", + "12", + "-29.41%", + "2", + "1", + "-50%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "17", + "0", + "-100%", + "2", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "0", + "1", + "100%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // flip table + await click(pivot.el.querySelector(".o_pivot_flip_button")); + + values = [ + "17", + "0", + "-100%", + "2", + "0", + "-100%", + "0", + "12", + "100%", + "0", + "1", + "100%", + "19", + "13", + "-31.58%", + "17", + "0", + "-100%", + "0", + "12", + "100%", + "17", + "12", + "-29.41%", + "2", + "0", + "-100%", + "0", + "1", + "100%", + "2", + "1", + "-50%", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + } + ); + + QUnit.test("Flip then compare, table col groupbys are kept", async function (assert) { + assert.expect(6); + + serverData.models.partner.records[0].date = "2016-12-15"; + serverData.models.partner.records[1].date = "2016-12-17"; + serverData.models.partner.records[2].date = "2016-11-22"; + serverData.models.partner.records[3].date = "2016-11-03"; + + patchDate(2016, 11, 20, 1, 0, 0); + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + `, + searchViewArch: ` + + + `, + additionalMeasures: ["product_id"], + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + ["", "Total", "", "Company", "individual", "Foo", "Foo", "Foo"], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "2016-12-15", "2016-12-17", "2016-11-22", "2016-11-03"], + "The row headers should be as expected" + ); + + // flip + await click(pivot.el.querySelector(".o_pivot_flip_button")); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + [ + "", + "Total", + "", + "2016-12-15", + "2016-12-17", + "2016-11-22", + "2016-11-03", + "Foo", + "Foo", + "Foo", + "Foo", + "Foo", + ], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "Company", "individual"], + "The row headers should be as expected" + ); + + // Filter on December 2016 + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Date"); + await toggleMenuItemOption(pivot, "Date", "December"); + + // compare December 2016 to November 2016 + await toggleComparisonMenu(pivot); + await toggleMenuItem(pivot, "Date: Previous period"); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + [ + "", + "Total", + "", + "2016-11-22", + "2016-11-03", + "2016-12-15", + "2016-12-17", + "Foo", + "Foo", + "Foo", + "Foo", + "Foo", + "November 2016", + "December 2016", + "Variation", + "November 2016", + "December 2016", + "Variation", + "November 2016", + "December 2016", + "Variation", + "November 2016", + "December 2016", + "Variation", + "November 2016", + "December 2016", + "Variation", + ], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "Company", "individual"], + "The row headers should be as expected" + ); + }); + + QUnit.test( + "correctly compute group domain when a date field has false value", + async function (assert) { + assert.expect(1); + + serverData.models.partner.records.forEach((r) => (r.date = false)); + + patchDate(2016, 11, 20, 1, 0, 0); + + const fakeActionService = { + start() { + return { + doAction(action) { + assert.deepEqual(action.domain, [["date", "=", false]]); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + await click(pivot.el.querySelectorAll(".o_value")[1]); + } + ); + QUnit.test( + "Does not identify 'false' with false as keys when creating group trees", + async function (assert) { + assert.expect(2); + + serverData.models.partner.fields.favorite_animal = { + string: "Favorite animal", + type: "char", + store: true, + }; + serverData.models.partner.records[0].favorite_animal = "Dog"; + serverData.models.partner.records[1].favorite_animal = "false"; + serverData.models.partner.records[2].favorite_animal = "Undefined"; + + patchDate(2016, 11, 20, 1, 0, 0); + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + ["", "Total", "Count"], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "Dog", "false", "Undefined", "Undefined"], + "The row headers should be as expected" + ); + } + ); + + QUnit.test( + "group bys added via control panel and expand Header do not stack", + async function (assert) { + assert.expect(8); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + additionalMeasures: ["product_id"], + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + ["", "Total", "Foo"], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total"], + "The row headers should be as expected" + ); + + // open group by menu and add new groupby + await toggleGroupByMenu(pivot); + await toggleAddCustomGroup(pivot); + await applyGroup(pivot); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + ["", "Total", "Foo"], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "Company", "individual"], + "The row headers should be as expected" + ); + + // Set a Row groupby + await click(pivot.el, "tbody tr:nth-child(2) .o_pivot_header_cell_closed"); + await click(pivot.el, "tbody .o_dropdown_menu .o_menu_item:nth-child(5)"); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + ["", "Total", "Foo"], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "Company", "xphone", "xpad", "individual"], + "The row headers should be as expected" + ); + + // open groupby menu generator and add a new groupby + await toggleGroupByMenu(pivot); + await toggleAddCustomGroup(pivot); + await selectGroup(pivot, "bar"); + await applyGroup(pivot); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((th) => th.innerText), + ["", "Total", "Foo"], + "The col headers should be as expected" + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map((th) => th.innerText), + ["Total", "Company", "true", "individual", "true", "Undefined"], + "The row headers should be as expected" + ); + } + ); + + QUnit.test("display only one dropdown menu", async function (assert) { + assert.expect(1); + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + `, + additionalMeasures: ["product_id"], + }); + + // add a col groupby on Product + await click(pivot.el.querySelector("thead th.o_pivot_header_cell_closed")); + await click(pivot.el.querySelectorAll("thead .o_dropdown_menu .o_dropdown_item")[5]); + + // Click on the two header dropdown togglers + await click(pivot.el.querySelectorAll("thead th.o_pivot_header_cell_closed")[0]); + await click(pivot.el.querySelectorAll("thead th.o_pivot_header_cell_closed")[1]); + + assert.containsOnce( + pivot, + "thead .o_dropdown_menu", + "Only one dropdown should be displayed at a time" + ); + }); + + QUnit.test("Server order is kept by default", async function (assert) { + assert.expect(1); + + let isSecondReadGroup = false; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + mockRPC(route, args) { + if (args.method === "read_group" && isSecondReadGroup) { + return Promise.resolve([ + { + customer: [2, "Second"], + foo: 18, + __count: 2, + __domain: [["customer", "=", 2]], + }, + { + customer: [1, "First"], + foo: 14, + __count: 2, + __domain: [["customer", "=", 1]], + }, + ]); + } + isSecondReadGroup = true; + }, + }); + + const values = [ + "32", // Total Value + "18", // Second + "14", // First + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + }); + + QUnit.test("pivot rendering with boolean field", async function (assert) { + assert.expect(4); + + serverData.models.partner.fields.bar = { + string: "bar", + type: "boolean", + store: true, + searchable: true, + group_operator: "bool_or", + }; + serverData.models.partner.records = [ + { id: 1, bar: true, date: "2019-12-14" }, + { id: 2, bar: false, date: "2019-05-14" }, + ]; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + + `, + }); + + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("2019-12-14")').length, + 1, + "There should be a first column" + ); + assert.ok( + $(pivot.el).find('tbody tr:contains("2019-12-14") [type="checkbox"]').is(":checked"), + "first column contains checkbox and value should be ticked" + ); + assert.strictEqual( + $(pivot.el).find('tbody tr:contains("2019-05-14")').length, + 1, + "There should be a second column" + ); + assert.notOk( + $(pivot.el).find('tbody tr:contains("2019-05-14") [type="checkbox"]').is(":checked"), + "second column should have checkbox that is not checked by default" + ); + }); + + QUnit.test("empty pivot view with action helper", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + + `, + "partner,false,search": ` + + + `, + }; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + context: { search_default_small_than_0: true }, + noContentHelp: '

click to add a foo

', + views: [[false, "search"]], + }); + + assert.containsOnce(pivot, ".o_view_nocontent .abc"); + assert.containsNone(pivot, "table"); + + await removeFacet(pivot); + + assert.containsNone(pivot, ".o_view_nocontent .abc"); + assert.containsOnce(pivot, "table"); + }); + + QUnit.test("empty pivot view with sample data", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + + `, + "partner,false,search": ` + + + `, + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + context: { search_default_small_than_0: true }, + noContentHelp: '

click to add a foo

', + views: [[false, "search"]], + }); + + assert.hasClass(pivot.el, "o_view_sample_data"); + assert.containsOnce(pivot, ".o_view_nocontent .abc"); + assert.containsOnce(pivot, "table.o_sample_data_disabled"); + + await removeFacet(pivot); + + assert.doesNotHaveClass(pivot.el, "o_view_sample_data"); + assert.containsNone(pivot, ".o_view_nocontent .abc"); + assert.containsOnce(pivot, "table"); + assert.doesNotHaveClass(pivot.el.querySelector("table"), "o_sample_data_disabled"); + }); + + QUnit.test("non empty pivot view with sample data", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + + `, + "partner,false,search": ` + + + `, + }; + + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + noContentHelp: '

click to add a foo

', + views: [[false, "search"]], + }); + + assert.doesNotHaveClass(pivot.el, "o_view_sample_data"); + assert.containsNone(pivot, ".o_view_nocontent .abc"); + assert.containsOnce(pivot, "table"); + assert.doesNotHaveClass(pivot.el.querySelector("table"), "o_sample_data_disabled"); + + await toggleFilterMenu(pivot); + await toggleMenuItem(pivot, "Small Than 0"); + + assert.doesNotHaveClass(pivot.el, "o_view_sample_data"); + assert.containsOnce(pivot, ".o_view_nocontent .abc"); + assert.containsNone(pivot, "table"); + }); + + QUnit.test("pivot is reloaded when leaving and coming back", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + `, + "partner,false,search": ``, + "partner,false,list": '', + }; + + const mockRPC = (route, args) => { + assert.step(args.method || route); + }; + const webClient = await createWebClient({ serverData, mockRPC }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [ + [false, "pivot"], + [false, "list"], + ], + }); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["4", "2", "2"].join(",")); + + assert.verifySteps(["/web/webclient/load_menus", "load_views", "read_group", "read_group"]); + + // switch to list view + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_list")); + await legacyExtraNextTick(); + + assert.containsOnce(webClient, ".o_list_view"); + assert.verifySteps(["/web/dataset/search_read"]); + + // switch back to pivot + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_pivot")); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["4", "2", "2"].join(",")); + + assert.verifySteps(["read_group", "read_group"]); + }); + + QUnit.test("expanded groups are kept when leaving and coming back", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + `, + "partner,false,search": ``, + "partner,false,list": '', + }; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [ + [false, "pivot"], + [false, "list"], + ], + }); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["4", "2", "2"].join(",")); + + // drill down first row group (group by company_type) + await click(webClient.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(webClient.el.querySelector(".o_pivot .o_dropdown_menu .o_dropdown_item")); + + assert.strictEqual(getCurrentValues(webClient), ["4", "2", "1", "1", "2"].join(",")); + + // switch to list view + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_list")); + await legacyExtraNextTick(); + + assert.containsOnce(webClient, ".o_list_view"); + + // switch back to pivot + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_pivot")); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["4", "2", "1", "1", "2"].join(",")); + }); + + QUnit.test("sorted rows are kept when leaving and coming back", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + + `, + "partner,false,search": ``, + "partner,false,list": '', + }; + + const webClient = await createWebClient({ serverData }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [ + [false, "pivot"], + [false, "list"], + ], + }); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["32", "12", "20"].join(",")); + + // sort the first group + await click(webClient.el.querySelector("th.o_pivot_measure_row")); + await click(webClient.el.querySelector("th.o_pivot_measure_row")); + + assert.strictEqual(getCurrentValues(webClient), ["32", "20", "12"].join(",")); + + // switch to list view + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_list")); + await legacyExtraNextTick(); + + assert.containsOnce(webClient, ".o_list_view"); + + // switch back to pivot + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_pivot")); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["32", "20", "12"].join(",")); + }); + + QUnit.test("correctly handle concurrent reloads", async function (assert) { + serverData.views = { + "partner,false,pivot": ` + + + + `, + "partner,false,search": ``, + "partner,false,list": '', + }; + + let def; + let readGroupCount = 0; + const mockRPC = (route, args) => { + if (args.method === "read_group" && def) { + readGroupCount++; + if (readGroupCount === 2) { + // slow down last read_group of first reload + return Promise.resolve(def); + } + } + }; + const webClient = await createWebClient({ serverData, mockRPC }); + + await doAction(webClient, { + res_model: "partner", + type: "ir.actions.act_window", + views: [ + [false, "pivot"], + [false, "list"], // s.t. there is a pivot view switcher + ], + }); + + assert.containsOnce(webClient, ".o_pivot_view"); + assert.strictEqual(getCurrentValues(webClient), ["32", "12", "20"].join(",")); + + // drill down first row group (group by company_type) + await click(webClient.el.querySelector("tbody .o_pivot_header_cell_closed")); + await click(webClient.el.querySelector(".o_pivot .o_dropdown_menu .o_dropdown_item")); + + assert.strictEqual(getCurrentValues(webClient), ["32", "12", "12", "20"].join(",")); + + // reload twice by clicking on pivot view switcher + def = makeDeferred(); + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_pivot")); + await click(webClient.el.querySelector(".o_control_panel .o_switch_view.o_pivot")); + + def.resolve(); + await nextTick(); + + assert.strictEqual(getCurrentValues(webClient), ["32", "12", "12", "20"].join(",")); + }); + + QUnit.test( + "if no measure is set in arch, 'Count' is used as measure initially", + async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ``, + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((e) => e.innerText), + ["", "Total", "Count"] + ); + } + ); + + QUnit.test( + "if (at least) one measure is set in arch and display_quantity is false or unset, 'Count' is not used as measure initially", + async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((e) => e.innerText), + ["", "Total", "Foo"] + ); + } + ); + + QUnit.test( + "if (at least) one measure is set in arch and display_quantity is true, 'Count' is used as measure initially", + async function (assert) { + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ` + + + + `, + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map((e) => e.innerText), + ["", "Total", "Count", "Foo"] + ); + } + ); + + QUnit.test("'Measures' menu when there is no measurable fields", async function (assert) { + serverData.models.partner = { + fields: {}, + records: [{ id: 1, display_name: "The one" }], + }; + const pivot = await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: ``, + }); + + await toggleMenu(pivot, "Measures"); + + // "Count" is the only measure available + assert.deepEqual( + [...pivot.el.querySelectorAll(".o_cp_bottom_left .o_dropdown_menu li")].map( + (e) => e.innerText + ), + ["Count"] + ); + // No separator should be displayed in the menu "Measures" + assert.containsNone(pivot, ".o_cp_bottom_left .o_dropdown_menu div.dropdown-divider"); + }); +}); diff --git a/addons/web/static/tests/webclient/actions/window_action_tests.js b/addons/web/static/tests/webclient/actions/window_action_tests.js index 87db617e07a..12b9f8fd4e8 100644 --- a/addons/web/static/tests/webclient/actions/window_action_tests.js +++ b/addons/web/static/tests/webclient/actions/window_action_tests.js @@ -22,6 +22,7 @@ import { errorService } from "../../../src/core/errors/error_service"; import { RPCError } from "@web/core/network/rpc_service"; import { registerCleanup } from "../../helpers/cleanup"; import { WarningDialog } from "@web/core/errors/error_dialogs"; +import { makeFakeUserService } from "@web/../tests/helpers/mock_services"; let serverData; const serviceRegistry = registry.category("services"); @@ -2304,6 +2305,8 @@ QUnit.module("ActionManager", (hooks) => { assert.step(args.method); } }; + + registry.category("services").add("user", makeFakeUserService()); const webClient = await createWebClient({ serverData, mockRPC }); await doAction(webClient, 3); assert.doesNotHaveClass(