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);