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(
|