[IMP] web: use search groupbys in pivot

Before this commit: all groupable fields were offered to selection when
a user would click on a suitable pivot header. While guaranteeing maximal
flexibility, this was easily overwhelming to any new commer.

After this commit: when at least one groupby is defined in the search
arch, the selection is done in the search arch groupbys (plus eventually
some custom groupbys) via a menu similar to the "Group By" menu in the
control panel. The advanced users have still access to more options in a
"Add custom group" menu again as in the control panel. When no groupby
is defined in the search arch, the selection is done among the groupable
fields as before.
This has led to a small modification of control_panel_model_extension.js:
it has become necessary to distinguish the groupBy filters coming from
the arch from those created by the user via the "Add custom group" menu.

Task ID: 2376505

closes odoo/odoo#61572

Related: odoo/enterprise#17341
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Mohammed Shekha <msh@odoo.com>
This commit is contained in:
Mohammed Shekha
2021-03-31 13:56:38 +00:00
committed by Mathieu Duckerts-Antoine
co-authored by Mathieu Duckerts-Antoine
parent 26203f194a
commit 3ed1890c7a
7 changed files with 494 additions and 56 deletions
@@ -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)) {
@@ -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);
@@ -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.
*
@@ -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;
@@ -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");
+49 -14
View File
@@ -76,32 +76,67 @@
</t>
<div t-name="web.PivotGroupBySelection" class="dropdown" owl="1">
<PivotGroupByMenu
customGroupBys="customGroupBys"
fields="props.groupableFields"
hasSearchArchGroupBys="hasSearchArchGroupBys"
searchModel="props.searchModel"
t-on-groupby-menu-selection="_onGroupByMenuSelection"
/>
</div>
<t t-name="web.PivotGroupByMenu" owl="1">
<div class="dropdown-menu o_pivot_field_menu show" data-display="static" role="menu">
<t t-foreach="props.selectionGroupBys" t-as="field" t-key="field.field.name">
<t t-if="(field.field.type === 'date') || (field.field.type === 'datetime')">
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.fieldName">
<div t-if="item.groupNumber and currentGroup !== null and currentGroup !== item.groupNumber"
class="dropdown-divider"
role="separator"/>
<t t-if="(item.fieldType === 'date') || (item.fieldType === 'datetime')">
<div role="menuitem" aria-haspopup="true" class="o_inline_dropdown">
<a href="#"
t-on-click.prevent="_onClickMenuGroupBy(field, 'month')"
t-on-click.prevent="_onClickMenuGroupBy(item.fieldName, 'month')"
class="dropdown-item o_pivot_field_selection"
t-att-class="{ disabled: field.active }"
t-att-data-field="field.name"
t-att-data-field="item.fieldName"
>
<t t-esc="field.field.string"/>
<t t-esc="item.description"/>
</a>
<div class="dropdown-menu" role="menu">
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'day')">Day</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'week')">Week</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'month')">Month</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'quarter')">Quarter</a>
<a role="menuitem" t-att-data-field="field.name" href="#" class="dropdown-item" t-on-click.prevent="_onClickMenuGroupBy(field, 'year')">Year</a>
<t t-foreach="Object.values(intervalOptions)" t-as="option" t-key="option.id">
<a role="menuitem"
t-att-data-field="item.fieldName"
href="#"
class="dropdown-item"
t-on-click.prevent="_onClickMenuGroupBy(item.fieldName, option.id)"
>
<t t-esc="option.description"/>
</a>
</t>
</div>
</div>
</t>
<a t-else="" href="#" role="menuitem" class="dropdown-item" t-att-data-field="field.name" t-att-class="{ disabled: field.active }" t-on-click.prevent="_onClickMenuGroupBy(field, false)">
<t t-esc="field.field.string"/>
<a t-else=""
href="#"
role="menuitem"
class="dropdown-item"
t-att-data-field="item.fieldName"
t-on-click.prevent="_onClickMenuGroupBy(item.fieldName, null)"
>
<t t-esc="item.description"/>
</a>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
<t t-if="props.fields.length and props.hasSearchArchGroupBys">
<li t-if="items.length" class="dropdown-divider" role="separator"/>
<PivotCustomGroupByItem fields="props.fields"/>
</t>
</div>
</div>
</t>
<t t-name="web.PivotCustomGroupByItem" t-inherit="web.CustomGroupByItem" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_generator_menu')]" position="attributes">
<attribute name="t-on-click">_onToggleCustomGroupbyItem</attribute>
</xpath>
</t>
</templates>
@@ -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: `
<pivot>
<field name="product_id" type="row"/>
<field name="bar" type="col"/>
<field name="foo" type="measure"/>
</pivot>`,
archs: {
'partner,false,search': `
<search>
<filter name="bar" string="bar" context="{'group_by': 'bar'}"/>
<field name="foo" string="foo" context="{'group_by': 'foo'}"/>
<filter name="product_id" string="product" context="{'group_by': 'product_id'}"/>
</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: `
<pivot>
<field name="product_id" type="row"/>
<field name="foo" type="measure"/>
</pivot>`,
archs: {
'partner,false,search': `
<search>
<filter name="bar" string="bar" context="{'group_by': 'bar'}"/>
<filter name="product_id" string="product" context="{'group_by': 'product_id'}"/>
</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: `
<pivot>
<field name="product_id" type="row"/>
<field name="foo" type="measure"/>
</pivot>`,
archs: {
'partner,false,search': `
<search>
<filter name="bar" string="bar" context="{'group_by': 'bar'}"/>
<filter name="product_id" string="product" context="{'group_by': 'product_id'}"/>
</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: `
<pivot>
<field name="product_id" type="row"/>
<field name="foo" type="measure"/>
</pivot>`,
archs: {
'partner,false,search': `
<search>
<filter name="bar" string="bar" context="{'group_by': 'bar'}"/>
</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: `
<pivot>
<field name="product_id" type="row"/>
<field name="foo" type="measure"/>
</pivot>`,
archs: {
'partner,false,search': `<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: `
<pivot>
<field name="foo" type="measure"/>
<field name="product_id" invisible="1"/>
</pivot>`,
archs: {
'partner,false,search': `
<search>
<filter name="product_id" string="product" context="{'group_by': 'product_id'}"/>
</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: `
<pivot>
<field name="product_id" type="row"/>
<field name="foo" type="measure"/>
</pivot>`,
archs: {
'partner,false,search': `
<search>
<filter name="bar" string="bar" context="{'group_by': 'bar'}"/>
</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);