diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 3c5839aa042..748e469ff28 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -26,7 +26,7 @@ class Http(models.AbstractModel): version_info = odoo.service.common.exp_version() user_context = request.session.get_context() if request.session.uid else {} - + IrConfigSudo = self.env['ir.config_parameter'].sudo() session_info = { "uid": request.session.uid, "is_system": user._is_system() if request.session.uid else False, @@ -40,7 +40,8 @@ class Http(models.AbstractModel): "partner_display_name": user.partner_id.display_name, "company_id": user.company_id.id if request.session.uid else None, # YTI TODO: Remove this from the user context "partner_id": user.partner_id.id if request.session.uid and user.partner_id else None, - "web.base.url": self.env['ir.config_parameter'].sudo().get_param('web.base.url', default=''), + "web.base.url": IrConfigSudo.get_param('web.base.url', default=''), + "active_ids_limit": int(IrConfigSudo.get_param('web.active_ids_limit', default='20000')), } if self.env.user.has_group('base.group_user'): # the following is only useful in the context of a webclient bootstrapping diff --git a/addons/web/static/src/js/components/action_menus.js b/addons/web/static/src/js/components/action_menus.js index 69a10423373..22e549a271e 100644 --- a/addons/web/static/src/js/components/action_menus.js +++ b/addons/web/static/src/js/components/action_menus.js @@ -3,7 +3,6 @@ odoo.define('web.ActionMenus', function (require) { const Context = require('web.Context'); const DropdownMenu = require('web.DropdownMenu'); - const pyUtils = require('web.py_utils'); const Registry = require('web.Registry'); const { Component } = owl; @@ -95,15 +94,25 @@ odoo.define('web.ActionMenus', function (require) { * @param {OwlEvent} ev */ async _executeAction(action) { - const activeIdsContext = { - active_id: this.props.activeIds[0], - active_ids: this.props.activeIds, - active_model: this.env.action.res_model, - select_all: this.props.selectAll || false, - }; - if (this.props.domain) { - activeIdsContext.active_domain = this.props.domain; + let activeIds = this.props.activeIds; + if (this.props.isDomainSelected) { + activeIds = await this.rpc({ + model: this.env.action.res_model, + method: 'search', + args: [this.props.domain], + kwargs: { + limit: this.env.session.active_ids_limit, + }, + }); } + const activeIdsContext = { + active_id: activeIds[0], + active_ids: activeIds, + active_model: this.env.action.res_model, + // keep active_domain in context for backward compatibility + // reasons, and to allow actions to bypass the active_ids_limit + active_domain: this.props.domain, + }; const context = new Context(this.props.context, activeIdsContext).eval(); const result = await this.rpc({ @@ -149,9 +158,10 @@ odoo.define('web.ActionMenus', function (require) { ActionMenus.components = { DropdownMenu }; ActionMenus.props = { - activeIds: { type: Array, element: [Number|String] }, // virtual IDs are strings. + activeIds: { type: Array, element: [Number | String] }, // virtual IDs are strings. context: Object, domain: { type: Array, optional: 1 }, + isDomainSelected: { type: Boolean, optional: 1 }, items: { type: Object, shape: { @@ -160,7 +170,6 @@ odoo.define('web.ActionMenus', function (require) { other: { type: Array, optional: 1 }, }, }, - selectAll: { type: Boolean, optional: 1 }, }; ActionMenus.template = 'web.ActionMenus'; diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index a2096a59f34..0e2e207a125 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -24,6 +24,7 @@ var ListController = BasicController.extend({ buttons_template: 'ListView.buttons', events: _.extend({}, BasicController.prototype.events, { 'click .o_list_export_xlsx': '_onDirectExportData', + 'click .o_list_select_domain': '_onSelectDomain', }), custom_events: _.extend({}, BasicController.prototype.custom_events, { activate_next_widget: '_onActivateNextWidget', @@ -54,7 +55,8 @@ var ListController = BasicController.extend({ this.selectedRecords = params.selectedRecords || []; this.multipleRecordsSavingPromise = null; this.fieldChangedPrevented = false; - this.selectAll = false; + this.isPageSelected = false; // true iff all records of the page are selected + this.isDomainSelected = false; // true iff the user selected all records matching the domain Object.defineProperty(this, 'mode', { get: () => this.renderer.isEditable() ? 'edit' : 'readonly', set: () => {}, @@ -128,20 +130,22 @@ var ListController = BasicController.extend({ */ renderButtons: function ($node) { if (this.noLeaf || !this.hasButtons) { - return; + this.hasButtons = false; + this.$buttons = $('
'); + } else { + this.$buttons = $(qweb.render(this.buttons_template, {widget: this})); + this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this)); + this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add')); + this.$buttons.find('.o_list_button_add').tooltip({ + delay: {show: 200, hide: 0}, + title: function () { + return qweb.render('CreateButton.tooltip'); + }, + trigger: 'manual', + }); + this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this)); + this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); } - this.$buttons = $(qweb.render(this.buttons_template, {widget: this})); - this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this)); - this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add')); - this.$buttons.find('.o_list_button_add').tooltip({ - delay: {show: 200, hide: 0}, - title: function () { - return qweb.render('CreateButton.tooltip'); - }, - trigger: 'manual', - }); - this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this)); - this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); if ($node) { this.$buttons.appendTo($node); } @@ -153,15 +157,20 @@ var ListController = BasicController.extend({ */ update: function (params, options) { var self = this; + let res_ids; if (options && options.keepSelection) { // filter out removed records from selection - var res_ids = this.model.get(this.handle).res_ids; + res_ids = this.model.get(this.handle).res_ids; this.selectedRecords = _.filter(this.selectedRecords, function (id) { return _.contains(res_ids, self.model.get(id).res_id); }); } else { this.selectedRecords = []; } + if (this.selectedRecords.length === 0 || this.selectedRecords.length < res_ids.length) { + this.isDomainSelected = false; + this.isPageSelected = false; + } params.selectedRecords = this.selectedRecords; return this._super.apply(this, arguments); @@ -174,16 +183,16 @@ var ListController = BasicController.extend({ * @param {string} mode either 'readonly' or 'edit' */ updateButtons: function (mode) { - if (!this.$buttons) { - return; - } - this.$buttons.toggleClass('o-editing', mode === 'edit'); - const state = this.model.get(this.handle, {raw: true}); - if (state.count) { - this.$('.o_list_export_xlsx').show(); - } else { - this.$('.o_list_export_xlsx').hide(); + if (this.hasButtons) { + this.$buttons.toggleClass('o-editing', mode === 'edit'); + const state = this.model.get(this.handle, {raw: true}); + if (state.count) { + this.$('.o_list_export_xlsx').show(); + } else { + this.$('.o_list_export_xlsx').hide(); + } } + this._updateSelectionBox(); }, //-------------------------------------------------------------------------- @@ -354,9 +363,9 @@ var ListController = BasicController.extend({ } return Object.assign(props, { items: Object.assign({}, this.toolbarActions, { other: otherActionItems }), - context: this.model.get(this.handle, { raw: true }).getContext(), + context: state.getContext(), domain: state.getDomain(), - selectAll: this.selectAll, + isDomainSelected: this.isDomainSelected, }); }, /** @@ -484,7 +493,7 @@ var ListController = BasicController.extend({ * @private */ _toggleCreateButton: function () { - if (this.$buttons) { + if (this.hasButtons) { var state = this.model.get(this.handle); var createHidden = this.renderer.isEditable() && state.groupedBy.length && state.data.length; this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden); @@ -499,6 +508,31 @@ var ListController = BasicController.extend({ this._toggleCreateButton(); this.updateButtons('readonly'); }, + /** + * When records are selected, a box is displayed in the control panel (next + * to the buttons). It indicates the number of selected records, and allows + * the user to select the whole domain instead of the current page (when the + * page is selected). This function renders and displays this box when at + * least one record is selected. + * + * @private + */ + _updateSelectionBox() { + if (this.$selectionBox) { + this.$selectionBox.remove(); + this.$selectionBox = null; + } + if (this.selectedRecords.length) { + const state = this.model.get(this.handle, {raw: true}); + this.$selectionBox = $(qweb.render('ListView.selection', { + isDomainSelected: this.isDomainSelected, + isPageSelected: this.isPageSelected, + nbSelected: this.selectedRecords.length, + nbTotal: state.count, + })); + this.$selectionBox.appendTo(this.$buttons); + } + }, //-------------------------------------------------------------------------- // Handlers @@ -690,6 +724,15 @@ var ListController = BasicController.extend({ .then(ev.data.onSuccess) .guardedCatch(ev.data.onFailure); }, + /** + * @private + */ + _onSelectDomain: function (ev) { + ev.preventDefault(); + this.isDomainSelected = true; + this._updateSelectionBox(); + this._updateControlPanel(); + }, /** * When the current selection changes (by clicking on the checkboxes on the * left), we need to display (or hide) the 'sidebar'. @@ -699,7 +742,9 @@ var ListController = BasicController.extend({ */ _onSelectionChanged: function (ev) { this.selectedRecords = ev.data.selection; - this.selectAll = ev.data.allChecked; + this.isPageSelected = ev.data.allChecked; + this.isDomainSelected = false; + this._updateSelectionBox(); this._updateControlPanel(); }, /** diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 2c864a25613..a9076270ed2 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -348,3 +348,17 @@ } } } + +// Selection box (next to the buttons, in ControlPanel) +$selected-box-color: #e7f2f6; +.o_list_selection_box { + display: inline-block; + padding: $btn-padding-y $btn-padding-x; + vertical-align: middle; // align with the buttons + border: 1px solid $selected-box-color; + background-color: $selected-box-color; + .o_list_select_domain { + font-weight: bold; + margin-left: 4px; + } +} diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 54ee1d035da..ee72f282337 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -859,6 +859,18 @@
+ +
+ All selected + + selected + + Select all + + +
+
+

diff --git a/addons/web/static/tests/components/action_menus_tests.js b/addons/web/static/tests/components/action_menus_tests.js index f4229b30493..e17ae95ba78 100644 --- a/addons/web/static/tests/components/action_menus_tests.js +++ b/addons/web/static/tests/components/action_menus_tests.js @@ -105,7 +105,6 @@ odoo.define('web.action_menus_tests', function (require) { active_id: 23, active_ids: [23], active_model: 'hobbit', - select_all: false, // default props }; assert.deepEqual(args.context, expectedContext); assert.step('load-action'); @@ -178,7 +177,6 @@ odoo.define('web.action_menus_tests', function (require) { active_id: 23, active_ids: [23], active_model: 'hobbit', - select_all: false, // default props }; assert.deepEqual(args.context, expectedContext); assert.step('load-action'); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index a59a925306f..d59cafacd28 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -1093,6 +1093,20 @@ var MockServer = Class.extend({ } return true; }, + /** + * Simulate a 'search' operation + * + * @private + * @param {string} model + * @param {Array} args + * @param {Object} kwargs + * @param {integer} [kwargs.limit] + * @returns {integer[]} + */ + _mockSearch: function (model, args, kwargs) { + const limit = kwargs.limit || Number.MAX_VALUE; + return this._getRecords(model, args[0]).map(r => r.id).slice(0, limit); + }, /** * Simulate a 'search_count' operation * @@ -1340,6 +1354,9 @@ var MockServer = Class.extend({ case 'read_progress_bar': return Promise.resolve(this._mockReadProgressBar(args.model, args.kwargs)); + case 'search': + return Promise.resolve(this._mockSearch(args.model, args.args, args.kwargs)); + case 'search_count': return Promise.resolve(this._mockSearchCount(args.model, args.args)); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 852c74d922b..cf0736f86f1 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -1251,8 +1251,76 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('selection box is properly displayed (single page)', async function (assert) { + assert.expect(11); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + }); + + assert.containsN(list, '.o_data_row', 4); + assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box'); + + // select a record + await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input')); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); + assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain'); + assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '1 selected'); + + // select all records of first page + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); + assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain'); + assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '4 selected'); + + // unselect a record + await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_list_record_selector input')); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); + assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain'); + assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '3 selected'); + + list.destroy(); + }); + + QUnit.test('selection box is properly displayed (multi pages)', async function (assert) { + assert.expect(10); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + }); + + assert.containsN(list, '.o_data_row', 3); + assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box'); + + // select a record + await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input')); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); + assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain'); + assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '1 selected'); + + // select all records of first page + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); + assert.containsOnce(list.$('.o_list_selection_box'), '.o_list_select_domain'); + assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(), + '3 selected Select all 4'); + + // select all domain + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); + assert.strictEqual(list.$('.o_list_selection_box').text().trim(), 'All 4 selected'); + + list.destroy(); + }); + QUnit.test('selection is reset on reload', async function (assert) { - assert.expect(5); + assert.expect(8); var list = await createView({ View: ListView, @@ -1264,6 +1332,7 @@ QUnit.module('Views', { '', }); + assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box'); assert.strictEqual(list.$('tfoot td:nth(2)').text(), '32', "total should be 32 (no record selected)"); @@ -1271,6 +1340,7 @@ QUnit.module('Views', { var $firstRowSelector = list.$('tbody .o_list_record_selector input').first(); testUtils.dom.click($firstRowSelector); assert.ok($firstRowSelector.is(':checked'), "first row should be selected"); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); assert.strictEqual(list.$('tfoot td:nth(2)').text(), '10', "total should be 10 (first record selected)"); @@ -1279,6 +1349,7 @@ QUnit.module('Views', { $firstRowSelector = list.$('tbody .o_list_record_selector input').first(); assert.notOk($firstRowSelector.is(':checked'), "first row should no longer be selected"); + assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box'); assert.strictEqual(list.$('tfoot td:nth(2)').text(), '32', "total should be 32 (no more record selected)"); @@ -1286,7 +1357,7 @@ QUnit.module('Views', { }); QUnit.test('selection is kept on render without reload', async function (assert) { - assert.expect(6); + assert.expect(10); var list = await createView({ View: ListView, @@ -1301,17 +1372,20 @@ QUnit.module('Views', { }); assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus'); + assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box'); // open blip grouping and check all lines await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")')); await testUtils.dom.click(list.$('.o_data_row:first input')); assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus'); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); // open yop grouping and verify blip are still checked await testUtils.dom.click(list.$('.o_group_header:contains("yop (1)")')); assert.containsOnce(list, '.o_data_row input:checked', "opening a grouping does not uncheck others"); assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus'); + assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box'); // close and open blip grouping and verify blip are unchecked await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")')); @@ -1319,6 +1393,7 @@ QUnit.module('Views', { assert.containsNone(list, '.o_data_row input:checked', "opening and closing a grouping uncheck its elements"); assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus'); + assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box'); list.destroy(); }); @@ -2865,7 +2940,7 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.test('list without import button', async function (assert) { + QUnit.test('list with group_by_no_leaf flag in context', async function (assert) { assert.expect(1); var list = await createView({ @@ -2878,7 +2953,7 @@ QUnit.module('Views', { } }); - assert.ok(!list.$buttons, "should not have any buttons"); + assert.containsNone(list, '.o_list_buttons', "should not have any buttons"); list.destroy(); }); @@ -4260,7 +4335,7 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.test('execute ActionMenus actions with correct params', async function (assert) { + QUnit.test('execute ActionMenus actions with correct params (single page)', async function (assert) { assert.expect(12); const list = await createView({ @@ -4320,9 +4395,78 @@ QUnit.module('Views', { await cpHelpers.toggleMenuItem(list, "Custom Action"); assert.verifySteps([ - '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","select_all":true,"active_domain":[]}}', - '{"action_id":44,"context":{"active_id":2,"active_ids":[2,3,4],"active_model":"foo","select_all":false,"active_domain":[]}}', - '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","select_all":false,"active_domain":[["bar","=",true]]}}', + '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","active_domain":[]}}', + '{"action_id":44,"context":{"active_id":2,"active_ids":[2,3,4],"active_model":"foo","active_domain":[]}}', + '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","active_domain":[["bar","=",true]]}}', + ]); + + list.destroy(); + }); + + QUnit.test('execute ActionMenus actions with correct params (multi pages)', async function (assert) { + assert.expect(13); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + toolbar: { + action: [{ + id: 44, + name: 'Custom Action', + type: 'ir.actions.server', + }], + print: [], + }, + mockRPC: function (route, args) { + if (route === '/web/action/load') { + assert.step(JSON.stringify(args)); + return Promise.resolve({}); + } + return this._super(...arguments); + }, + viewOptions: { + hasActionMenus: true, + }, + }); + + assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus'); + assert.containsN(list, '.o_data_row', 2); + + // select all records + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + assert.containsN(list, '.o_list_record_selector input:checked', 3); + assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain'); + assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus'); + + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Custom Action"); + + // select all domain + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + assert.containsN(list, '.o_list_record_selector input:checked', 3); + + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Custom Action"); + + // add a domain + await list.reload({ domain: [['bar', '=', true]] }); + assert.containsNone(list, '.o_list_selection_box .o_list_select_domain'); + + // select all domain + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + assert.containsN(list, '.o_list_record_selector input:checked', 3); + assert.containsNone(list, '.o_list_selection_box .o_list_select_domain'); + + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Custom Action"); + + assert.verifySteps([ + '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","active_domain":[]}}', + '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","active_domain":[]}}', + '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3],"active_model":"foo","active_domain":[["bar","=",true]]}}', ]); list.destroy();