diff --git a/addons/web/static/src/js/control_panel/control_panel_model_extension.js b/addons/web/static/src/js/control_panel/control_panel_model_extension.js
index eafa1ab762f..aba25a83849 100644
--- a/addons/web/static/src/js/control_panel/control_panel_model_extension.js
+++ b/addons/web/static/src/js/control_panel/control_panel_model_extension.js
@@ -323,6 +323,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
groupNumber,
id: filterId,
type: 'groupBy',
+ custom: true,
};
this.state.filters[filterId] = filter;
if (['date', 'datetime'].includes(field.type)) {
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index d040d788166..97cfcf2366d 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -35,8 +35,6 @@
* @param model
* @param renderer
* @param {Object} params
- * @param {Object} params.groupableFields a map from field names to field
- * props
*/
init: function (parent, model, renderer, params) {
this._super(...arguments);
@@ -260,10 +258,10 @@
_onGroupByMenuSelection: async function (ev) {
ev.stopPropagation();
- let groupBy = ev.data.field.name;
- const interval = ev.data.interval;
+ const { fieldName, interval } = ev.data;
+ let groupBy = fieldName;
if (interval) {
- groupBy = groupBy + ':' + interval;
+ groupBy = `${groupBy}:${interval}`;
}
this.model.addGroupBy(groupBy, this.selectedGroup.type);
await this.model.expandGroup(this.selectedGroup, groupBy);
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index f6d96bdc518..17fd53fa931 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -534,7 +534,6 @@ var PivotModel = AbstractModel.extend({
measures: this.data.measures,
origins: this.data.origins,
rowGroupBys: groupBys.rowGroupBys,
- selectionGroupBys: this._getSelectionGroupBy(groupBys),
modelName: this.modelName
};
if (!raw && state.hasData) {
@@ -566,7 +565,6 @@ var PivotModel = AbstractModel.extend({
* @param {string[]} params.rowGroupBys
* @param {string} [params.default_order]
* @param {string} params.modelName
- * @param {Object[]} params.groupableFields
* @param {Object} params.timeRanges
* @returns {Promise}
*/
@@ -577,7 +575,6 @@ var PivotModel = AbstractModel.extend({
this.fields = params.fields;
this.modelName = params.modelName;
- this.groupableFields = params.groupableFields;
const measures = this._processMeasures(params.context.pivot_measures) ||
params.measures.map(m => m);
this.data = {
@@ -1061,30 +1058,6 @@ var PivotModel = AbstractModel.extend({
return originRow;
},
- /**
- * Get the selection needed to display the group by dropdown
- * @returns {Object[]}
- * @private
- */
- _getSelectionGroupBy: function (groupBys) {
- let groupedFieldNames = groupBys.rowGroupBys
- .concat(groupBys.colGroupBys)
- .map(function (g) {
- return g.split(':')[0];
- });
-
- var fields = Object.keys(this.groupableFields)
- .map((fieldName, index) => {
- return {
- name: fieldName,
- field: this.groupableFields[fieldName],
- active: groupedFieldNames.includes(fieldName)
- }
- })
- .sort((left, right) => left.field.string < right.field.string ? -1 : 1);
- return fields;
- },
-
/**
* Returns a description of the pivot table.
*
diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js
index 07801fca0b9..67493529613 100644
--- a/addons/web/static/src/js/views/pivot/pivot_renderer.js
+++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js
@@ -1,10 +1,106 @@
/** @odoo-module alias=web.PivotRenderer **/
+ import DropdownMenu from 'web.DropdownMenu';
+ import DropdownMenuItem from 'web.DropdownMenuItem';
import OwlAbstractRenderer from '../abstract_renderer_owl';
import field_utils from 'web.field_utils';
+ import { DEFAULT_INTERVAL, INTERVAL_OPTIONS } from 'web.searchUtils';
const { useExternalListener, useState, onMounted, onPatched } = owl.hooks;
+ class PivotCustomGroupByItem extends DropdownMenuItem {
+ constructor() {
+ super(...arguments);
+ this.canBeOpened = true;
+ this.state.fieldName = this.props.fields[0].name;
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onApply() {
+ const { fieldName } = this.state;
+ const { type } = this.props.fields.find(f => f.name === fieldName);
+ let interval = null;
+ if (['date', 'datetime'].includes(type)) {
+ interval = DEFAULT_INTERVAL;
+ }
+ this.trigger('groupby-menu-selection', { fieldName, interval, custom: true });
+ this.state.open = false;
+ }
+
+ /**
+ * Stops propagation of click event if custom groupby menu is toggled.
+ * Propagates click event when Apply button is clicked to close dropdown
+ * @param {OwlEvent} ev
+ */
+ _onToggleCustomGroupbyItem(ev) {
+ if (
+ !ev.target.classList.contains('o_apply_group_by') &&
+ (this.el.contains(ev.target) || this.el.contains(document.activeElement))
+ ) {
+ ev.stopPropagation();
+ }
+ }
+ }
+
+ PivotCustomGroupByItem.template = "web.PivotCustomGroupByItem";
+ PivotCustomGroupByItem.props = { fields: Array };
+
+ export class PivotGroupByMenu extends DropdownMenu {
+
+ constructor() {
+ super(...arguments);
+ this.intervalOptions = INTERVAL_OPTIONS;
+ }
+
+ //---------------------------------------------------------------------
+ // Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get items() {
+ if (this.props.hasSearchArchGroupBys) {
+ const groupBys = this.props.searchModel.get('filters', f => f.type === 'groupBy');
+ let groupNumber = 1 + Math.max(...groupBys.map(g => g.groupNumber), 0);
+ for (const [_, customGroupBy] of this.props.customGroupBys) {
+ customGroupBy.groupNumber = groupNumber++;
+ groupBys.push(customGroupBy);
+ }
+ return groupBys;
+ }
+ return this.props.fields;
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @param {string} fieldName
+ * @param {string|null} interval
+ */
+ _onClickMenuGroupBy(fieldName, interval) {
+ this.trigger('groupby-menu-selection', { fieldName, interval });
+ }
+ }
+
+ PivotGroupByMenu.template = "web.PivotGroupByMenu";
+ PivotGroupByMenu.components = { PivotCustomGroupByItem };
+ PivotGroupByMenu.props = {
+ ...DropdownMenu.props,
+ customGroupBys: Map,
+ fields: Object,
+ hasSearchArchGroupBys: Boolean,
+ searchModel: true,
+ };
+
/**
* Here is a basic example of the structure of the Pivot Table:
*
@@ -41,6 +137,15 @@
},
});
+ const searchArchGroupBys = this.props.searchModel.get(
+ 'filters',
+ f => f.type === 'groupBy' && !f.custom
+ );
+ // searchArchGroupBys is not an array when the control panel model
+ // extension is not installed (e.g. in an embedded pivot view)
+ this.hasSearchArchGroupBys = Boolean(searchArchGroupBys && searchArchGroupBys.length);
+ this.customGroupBys = new Map();
+
onMounted(() => this._updateTooltip());
onPatched(() => this._updateTooltip());
@@ -135,19 +240,20 @@
// Handlers
//----------------------------------------------------------------------
-
/**
- * Handles a click on a menu item in the dropdown to select a groupby.
- *
* @private
- * @param {Object} field
- * @param {string} interval
+ * @param {OwlEvent} ev
*/
- _onClickMenuGroupBy(field, interval) {
- this.trigger('groupby_menu_selection', { field, interval });
+ _onGroupByMenuSelection(ev) {
+ if (this.hasSearchArchGroupBys) {
+ const { custom, fieldName } = ev.detail;
+ if (custom && !this.customGroupBys.has(fieldName)) {
+ const field = this.props.groupableFields.find(g => g.fieldName === fieldName)
+ this.customGroupBys.set(fieldName, field);
+ }
+ }
}
-
/**
* Handles a click on a header node
*
@@ -194,5 +300,6 @@
}
PivotRenderer.template = 'web.PivotRenderer';
+ PivotRenderer.components = { PivotGroupByMenu };
export default PivotRenderer;
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index d76f901a021..d8862d6b4d3 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -13,6 +13,7 @@
import PivotController from 'web.PivotController';
import PivotRenderer from './pivot_renderer';
import RendererWrapper from 'web.RendererWrapper';
+ import { sortBy } from 'web.utils';
const _t = core._t;
const _lt = core._lt;
@@ -57,7 +58,12 @@
measures[name] = field;
}
if (GROUPABLE_TYPES.includes(field.type)) {
- groupableFields[name] = field;
+ groupableFields[name] = Object.assign({
+ description: field.string,
+ fieldName: name,
+ fieldType: field.type,
+ name,
+ }, field);
}
}
});
@@ -115,13 +121,15 @@
this.loadParams.rowGroupBys = rowGroupBys;
this.loadParams.fields = this.fields;
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
- this.loadParams.groupableFields = groupableFields;
const disableLinking = !!(this.arch.attrs.disable_linking &&
JSON.stringify(this.arch.attrs.disable_linking));
this.rendererParams.widgets = widgets;
this.rendererParams.disableLinking = disableLinking;
+ const { searchModel } = this.controllerParams;
+ this.rendererParams.searchModel = searchModel;
+ this.rendererParams.groupableFields = sortBy(Object.values(groupableFields), (f) => f.description);
this.controllerParams.disableLinking = disableLinking;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
diff --git a/addons/web/static/src/xml/pivot.xml b/addons/web/static/src/xml/pivot.xml
index ce6e13b33c4..d5b1bd63de9 100644
--- a/addons/web/static/src/xml/pivot.xml
+++ b/addons/web/static/src/xml/pivot.xml
@@ -76,32 +76,67 @@
+
+
-
+
+
+
+
+ _onToggleCustomGroupbyItem
+
+
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index 026741ac682..1dc3350e680 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -712,6 +712,322 @@ QUnit.module('Views', {
pivot.destroy();
});
+ 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 createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': `
+
+
+
+
+
+ `,
+ },
+ });
+
+ // open group by dropdown
+ await cpHelpers.toggleGroupByMenu(pivot);
+ assert.containsN(pivot, '.o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_menu_item', 3,
+ "should have 2 dropdown items in searchview groupby");
+ assert.containsOnce(pivot, '.o_control_panel .o_cp_bottom_right .o_dropdown_menu .o_generator_menu',
+ "should have custom group generator in searchview groupby");
+
+ // click on closed header to open dropdown
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+ assert.containsN(pivot, '.o_pivot_field_menu > .dropdown-item', 3,
+ "should have 3 dropdown items same as searchview groupby");
+ assert.containsOnce(pivot, '.o_pivot_field_menu .o_generator_menu',
+ "should have custom group generator same as searchview groupby");
+ // check custom groupby selection has groupable fields only
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_add_custom_group_by'));
+ assert.containsN(pivot, '.o_pivot_field_menu .o_generator_menu .o_group_by_selector option', 6,
+ "should have 3 fields in custom groupby");
+ const optionDescriptions = [...pivot.$('.o_pivot_field_menu .o_generator_menu .o_group_by_selector 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");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot group dropdown sync with search groupby dropdown', async function (assert) {
+ assert.expect(5);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': `
+
+
+
+
+ `,
+ },
+ });
+
+ // open group by dropdown
+ await cpHelpers.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 cpHelpers.toggleMenuItem(pivot, "bar");
+ // click on closed header to open dropdown
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+
+ await cpHelpers.toggleGroupByMenu(pivot);
+ await cpHelpers.toggleAddCustomGroup(pivot);
+ await cpHelpers.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 testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+ // applying groupby/custom groupby will update pivot groupby dropdown while reverse is not true
+ assert.containsN(pivot, '.o_pivot_field_menu > .dropdown-item', 3,
+ "should have 3 dropdown items same as searchview groupby");
+
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_add_custom_group_by'));
+ await testUtils.fields.editSelect(pivot.$('.o_pivot_field_menu .o_generator_menu .o_group_by_selector'), 'date');
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_apply_group_by'));
+ // click on closed header to open groupby selection dropdown
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+ assert.containsN(pivot, '.o_pivot_field_menu > [role="menuitem"]', 4,
+ "should have 4 dropdown items pivot groupby dropdown");
+
+ // applying custom groupby in pivot groupby dropdown will not update search dropdown
+ await cpHelpers.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 dropdown");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot groupby dropdown renders custom search at the end with separator', async function (assert) {
+ assert.expect(4);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': `
+
+
+
+
+ `,
+ },
+ });
+
+ // open group by dropdown
+ await cpHelpers.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 cpHelpers.toggleAddCustomGroup(pivot);
+ await cpHelpers.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 testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+ assert.deepEqual(pivot.$(".o_pivot_field_menu > .dropdown-item:last"),
+ pivot.$(".o_pivot_field_menu > .dropdown-item[data-field='company_type']"),
+ "custom group should be last item");
+ assert.strictEqual(pivot.$(".o_pivot_field_menu > .dropdown-item[data-field='company_type']").prev().attr('role'),
+ "separator",
+ "custom group should be separated by separator");
+
+ pivot.destroy();
+ });
+
+ 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 createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': `
+
+
+
+ `,
+ },
+ mockRPC: function (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;
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // click on closed header to open dropdown and apply groupby on date field
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_add_custom_group_by'));
+ await testUtils.fields.editSelect(pivot.$('.o_pivot_field_menu .o_generator_menu .o_group_by_selector'), 'date');
+ checkReadGroup = true;
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_apply_group_by'));
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view without group by specified in search arch', async function (assert) {
+ assert.expect(3);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': ``,
+ },
+ });
+
+ // open group by dropdown
+ await cpHelpers.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");
+ // click on closed header to open dropdown
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+ assert.containsN(pivot, '.o_pivot_field_menu > [role="menuitem"]', 6,
+ "should have 6 dropdown items i.e. all groupable fields available");
+ assert.containsNone(pivot, '.o_pivot_field_menu .o_generator_menu',
+ "should not have custom group generator in groupby dropdown");
+
+ pivot.destroy();
+ });
+
+ 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 this.data.partner.fields[fieldName];
+ }
+
+ this.data.partner.records = [{
+ id: 1,
+ foo: 12,
+ product_id: 37,
+ computed_field: 19,
+ }];
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': `
+
+
+
+ `,
+ },
+ });
+
+ // open group by dropdown
+ await cpHelpers.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_generator_menu',
+ "should not have custom group generator in searchview groupby");
+
+ // click on closed header to open dropdown
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+ assert.containsOnce(pivot, '.o_pivot_field_menu > [role="menuitem"]',
+ "should have 1 dropdown items");
+ assert.containsNone(pivot, '.o_pivot_field_menu .o_generator_menu',
+ "should not have custom group generator in groupby dropdown");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot custom groupby: grouping on date field use default interval month', async function (assert) {
+ assert.expect(2);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+
+
+
+ `,
+ archs: {
+ 'partner,false,search': `
+
+
+
+ `,
+ },
+ });
+
+ // click on closed header to open dropdown and apply groupby on date field
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_add_custom_group_by'));
+ assert.containsOnce(pivot, '.o_pivot_field_menu .o_generator_menu',
+ "should have custom group generator in groupby dropdown");
+
+ // click on apply button should close dropdown
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .o_generator_menu button.o_apply_group_by'));
+ assert.containsNone(pivot, '.o_pivot_field_menu .o_generator_menu',
+ "should not have custom group generator in groupby dropdown");
+
+ pivot.destroy();
+ });
+
QUnit.test('can toggle extra measure', async function (assert) {
assert.expect(8);