From 63047c1c335b635026e81d2616e55c088e5ae233 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 07:58:23 +0000 Subject: [PATCH 1/9] [REF] web: DataExport tests: lint file and factorize mockRPC --- .../static/tests/widgets/data_export_tests.js | 142 ++++++------------ 1 file changed, 42 insertions(+), 100 deletions(-) diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js index 7abc3e93f6a..978e6e79bf2 100644 --- a/addons/web/static/tests/widgets/data_export_tests.js +++ b/addons/web/static/tests/widgets/data_export_tests.js @@ -1,10 +1,10 @@ odoo.define('web.data_export_tests', function (require) { "use strict"; +const data = require('web.data'); const framework = require('web.framework'); const ListView = require('web.ListView'); const testUtils = require('web.test_utils'); -const data = require('web.data'); const cpHelpers = testUtils.controlPanel; const createView = testUtils.createView; @@ -12,7 +12,7 @@ const createView = testUtils.createView; QUnit.module('widgets', { beforeEach: function () { this.data = { - partner: { + 'partner': { fields: { foo: {string: "Foo", type: "char"}, bar: {string: "Bar", type: "char"}, @@ -40,6 +40,38 @@ QUnit.module('widgets', { records: [], }, }; + + this.mockDataExportRPCs = function (route) { + if (route === '/web/export/formats') { + return Promise.resolve([ + {tag: 'csv', label: 'CSV'}, + {tag: 'xls', label: 'Excel'}, + ]); + } + if (route === '/web/export/get_fields') { + return Promise.resolve([ + { + field_type: "one2many", + string: "Activities", + required: false, + value: "activity_ids/id", + id: "activity_ids", + params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities"}, + relation_field: "res_id", + children: true, + }, { + children: false, + field_type: 'char', + id: "foo", + relation_field: null, + required: false, + string: 'Foo', + value: "foo", + } + ]); + } + return this._super.apply(this, arguments); + }; } }, function () { @@ -66,37 +98,7 @@ QUnit.module('widgets', { viewOptions: { hasActionMenus: true, }, - mockRPC: function (route) { - if (route === '/web/export/formats') { - return Promise.resolve([ - {tag: 'csv', label: 'CSV'}, - {tag: 'xls', label: 'Excel'}, - ]); - } - if (route === '/web/export/get_fields') { - return Promise.resolve([ - { - field_type: "one2many", - string: "Activities", - required: false, - value: "activity_ids/id", - id: "activity_ids", - params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities"}, - relation_field: "res_id", - children: true, - }, { - children: false, - field_type: 'char', - id: "foo", - relation_field: null, - required: false, - string: 'Foo', - value: "foo", - } - ]); - } - return this._super.apply(this, arguments); - }, + mockRPC: this.mockDataExportRPCs, session: { get_file: function (params) { assert.step(params.url); @@ -136,7 +138,7 @@ QUnit.module('widgets', { var create = data.DataSet.prototype.create; - data.DataSet.prototype.create = function (data, options) { + data.DataSet.prototype.create = function () { assert.step('create'); return Promise.resolve([]); }; @@ -149,37 +151,7 @@ QUnit.module('widgets', { viewOptions: { hasActionMenus: true, }, - mockRPC: function (route) { - if (route === '/web/export/formats') { - return Promise.resolve([ - {tag: 'csv', label: 'CSV'}, - {tag: 'xls', label: 'Excel'}, - ]); - } - if (route === '/web/export/get_fields') { - return Promise.resolve([ - { - field_type: "one2many", - string: "Activities", - required: false, - value: "activity_ids/id", - id: "activity_ids", - params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities"}, - relation_field: "res_id", - children: true, - }, { - children: false, - field_type: 'char', - id: "foo", - relation_field: null, - required: false, - string: 'Foo', - value: "foo", - } - ]); - } - return this._super.apply(this, arguments); - }, + mockRPC: this.mockDataExportRPCs, }); @@ -221,37 +193,7 @@ QUnit.module('widgets', { viewOptions: { hasActionMenus: true, }, - mockRPC: function (route) { - if (route === '/web/export/formats') { - return Promise.resolve([ - {tag: 'csv', label: 'CSV' }, - {tag: 'xls', label: 'Excel' }, - ]); - } - if (route === '/web/export/get_fields') { - return Promise.resolve([ - { - field_type: "one2many", - string: "Activities", - required: false, - value: "activity_ids/id", - id: "activity_ids", - params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities" }, - relation_field: "res_id", - children: true, - }, { - children: false, - field_type: 'char', - id: "foo", - relation_field: null, - required: false, - string: 'Foo', - value: "foo", - } - ]); - } - return this._super.apply(this, arguments); - }, + mockRPC: this.mockDataExportRPCs, }); @@ -291,7 +233,7 @@ QUnit.module('widgets', { }); QUnit.test('Direct export list ', async function (assert) { - assert.expect(2) + assert.expect(2); let list = await createView({ View: ListView, @@ -321,7 +263,7 @@ QUnit.module('widgets', { name: 'bar', label: 'Bar', }] - }, "should be called with correct params") + }, "should be called with correct params"); args.complete(); }, }, @@ -336,7 +278,7 @@ QUnit.module('widgets', { }); QUnit.test('Direct export grouped list ', async function (assert) { - assert.expect(2) + assert.expect(2); let list = await createView({ View: ListView, @@ -367,7 +309,7 @@ QUnit.module('widgets', { name: 'bar', label: 'Bar', }] - }, "should be called with correct params") + }, "should be called with correct params"); args.complete(); }, }, From 9be9a75e67ac0727e2d5a0f7584f350f4ee638fb Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 09:39:13 +0000 Subject: [PATCH 2/9] [REF] web: move _archive function to BasicController --- .../src/js/views/basic/basic_controller.js | 19 +++++++++++++++ .../src/js/views/form/form_controller.js | 24 ------------------- .../src/js/views/list/list_controller.js | 22 ----------------- 3 files changed, 19 insertions(+), 46 deletions(-) diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 8b4672bc291..f5944ffe253 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -238,6 +238,25 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { return _super(dataPointID, changes, event); }); }, + /** + * Archive the current selection + * + * @private + * @param {string[]} ids + * @param {boolean} archive + * @returns {Promise} + */ + _archive: async function (ids, archive) { + if (ids.length === 0) { + return Promise.resolve(); + } + if (archive) { + await this.model.actionArchive(ids, this.handle); + } else { + await this.model.actionUnarchive(ids, this.handle); + } + return this.update({}, {reload: false}); + }, /** * When the user clicks on a 'action button', this function determines what * should happen. diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 5c54afb7e41..8af47e4cd4f 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -305,30 +305,6 @@ var FormController = BasicController.extend({ core.bus.trigger('DOM_updated'); return result; }, - - /** - * Archive the current selection - * - * @private - * @param {string[]} ids - * @param {boolean} archive - * @returns {Promise} - */ - _archive: function (ids, archive) { - if (ids.length === 0) { - return Promise.resolve(); - } - if (archive) { - return this.model - .actionArchive(ids, this.handle) - .then(this.update.bind(this, {}, {reload: false})); - } else { - return this.model - .actionUnarchive(ids, this.handle) - .then(this.update.bind(this, {}, {reload: false})); - } - }, - /** * Assign on the buttons save and discard additionnal behavior to facilitate * the work of the users doing input only using the keyboard 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 41dfa18c8bc..a2096a59f34 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -234,28 +234,6 @@ var ListController = BasicController.extend({ }); }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); }, - /** - * Archive the current selection - * - * @private - * @param {string[]} ids - * @param {boolean} archive - * @returns {Promise} - */ - _archive: function (ids, archive) { - if (ids.length === 0) { - return Promise.resolve(); - } - if (archive) { - return this.model - .actionArchive(ids, this.handle) - .then(this.update.bind(this, {}, {reload: false})); - } else { - return this.model - .actionUnarchive(ids, this.handle) - .then(this.update.bind(this, {}, {reload: false})); - } - }, /** * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard * From 7af5fb31ec7a3ef4bfce1756aa01824cba30e592 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 10:25:00 +0000 Subject: [PATCH 3/9] [IMP] web: selection in list views This commit allows the user to apply actions to all records matching the current domain in list views. It also ensures that the user is aware of the set of records actually selected. The number of selected records is now displayed in the control panel, and the user can, with an additional click, select all records matching the current domain instead of the ones of the current page. Task 2185145 --- addons/web/models/ir_http.py | 5 +- .../static/src/js/components/action_menus.js | 31 ++-- .../src/js/views/list/list_controller.js | 101 ++++++++--- addons/web/static/src/scss/list_view.scss | 14 ++ addons/web/static/src/xml/base.xml | 12 ++ .../tests/components/action_menus_tests.js | 2 - .../web/static/tests/helpers/mock_server.js | 17 ++ addons/web/static/tests/views/list_tests.js | 160 +++++++++++++++++- 8 files changed, 291 insertions(+), 51 deletions(-) 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(); From 9755e8875bf8bae1881db38d3a0ec99d98d572a8 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 07:58:32 +0000 Subject: [PATCH 4/9] [IMP] web: adapt export feature to list selection changes The export feature already exported the whole domain when the thead selector checkbox was checked (unlike all other actions which were executed on the current page only). Following the new selection feature, this commit uniformizes the behavior of the export with other actions: if the page is selected, only the records on that page are exported. To export all records matching the current domain, the user has to click on the 'Select all' link in the control panel. Part of task 2185145 --- .../src/js/views/list/list_controller.js | 22 +------- .../static/tests/widgets/data_export_tests.js | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 20 deletions(-) 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 0e2e207a125..922d87600c5 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -67,25 +67,6 @@ var ListController = BasicController.extend({ // Public //-------------------------------------------------------------------------- - /** - * Calculate the active domain of the list view. This should be done only - * if the header checkbox has been checked. This is done by evaluating the - * search results, and then adding the dataset domain (i.e. action domain). - * - * @todo This is done only for the data export. The full mechanism is wrong, - * this method should be private, most of the code in the sidebar should be - * moved to the controller, and we should not use the getParent method... - * - * @returns {Promise} a promise that resolve to the active domain - */ - getActiveDomain: function () { - var self = this; - if (this.selectAll) { - var searchQuery = this._controlPanelModel ? this._controlPanelModel.getQuery() : {}; - var record = self.model.get(self.handle, {raw: true}); - return record.getDomain().concat(searchQuery.domain || []); - } - }, /* * @override */ @@ -314,8 +295,9 @@ var ListController = BasicController.extend({ let state = this.model.get(this.handle); let defaultExportFields = this.renderer.columns.filter(field => field.tag === 'field').map(field => field.attrs.name); let groupedBy = this.renderer.state.groupedBy; + const domain = this.isDomainSelected && state.getDomain(); return new DataExport(this, state, defaultExportFields, groupedBy, - this.getActiveDomain(), this.getSelectedIds()); + domain, this.getSelectedIds()); }, /** * Only display the pager when there are data to display. diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js index 978e6e79bf2..9a6f23b815b 100644 --- a/addons/web/static/tests/widgets/data_export_tests.js +++ b/addons/web/static/tests/widgets/data_export_tests.js @@ -133,6 +133,62 @@ QUnit.module('widgets', { ]); }); + QUnit.test('exporting data in list view (multi pages)', async function (assert) { + assert.expect(4); + + let expectedData; + const list = await createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '', + domain: [['id', '<', 1000]], + viewOptions: { + hasActionMenus: true, + }, + mockRPC: this.mockDataExportRPCs, + session: { + get_file: function (params) { + const data = JSON.parse(params.data.data); + assert.deepEqual({ids: data.ids, domain: data.domain}, expectedData); + params.complete(); + }, + }, + }); + + // select all records (first page) and export + expectedData = { + ids: [1, 2], + domain: [['id', '<', 1000]], + }; + await testUtils.dom.click(list.$('thead th.o_list_record_selector input')); + + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, 'Export'); + + assert.containsOnce(document.body, '.modal'); + + await testUtils.dom.click($('.modal span:contains(Export)')); + await testUtils.dom.click($('.modal span:contains(Close)')); + + // select all domain and export + expectedData = { + ids: false, + domain: [['id', '<', 1000]], + }; + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, 'Export'); + + assert.containsOnce(document.body, '.modal'); + + await testUtils.dom.click($('.modal span:contains(Export)')); + await testUtils.dom.click($('.modal span:contains(Close)')); + + list.destroy(); + }); + QUnit.test('saving fields list when exporting data', async function (assert) { assert.expect(4); From 8608a5ed8dc7d14432556d2c4c469e3f8deda7f1 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 09:56:50 +0000 Subject: [PATCH 5/9] [IMP] web: adapt (un)archive feature to list selection changes Following the new selection feature in list views, this commit adapts the archive and unarchive actions. Part of task 2185145 --- .../src/js/views/basic/basic_controller.js | 2 +- .../static/src/js/views/basic/basic_model.js | 23 ++-- .../src/js/views/form/form_controller.js | 4 +- .../src/js/views/kanban/kanban_controller.js | 2 +- .../src/js/views/list/list_controller.js | 29 ++++- .../static/tests/views/kanban_model_tests.js | 2 +- addons/web/static/tests/views/list_tests.js | 107 ++++++++++++++++++ 7 files changed, 152 insertions(+), 17 deletions(-) diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index f5944ffe253..f0fd3cc8c75 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -242,7 +242,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * Archive the current selection * * @private - * @param {string[]} ids + * @param {number[]} ids * @param {boolean} archive * @returns {Promise} */ diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index a69f6878023..8dc540a8861 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -811,6 +811,15 @@ var BasicModel = AbstractModel.extend({ return dataPoint.id; }); }, + /** + * Returns the list of res_ids for a given list of local ids. + * + * @param {string[]} localIds + * @returns {integer[]} + */ + localIdsToResIds: function (localIds) { + return localIds.map(localId => this.localData[localId].res_id); + }, /** * This helper method is designed to help developpers that want to use a * field widget outside of a view. In that case, we want a way to create @@ -1286,16 +1295,13 @@ var BasicModel = AbstractModel.extend({ /** * Archive the given records * - * @param {Array} recordIDs local ids of the records to (un)archive + * @param {integer[]} resIDs ids of the records to archive * @param {string} parentID id of the parent resource to reload * @returns {Promise} resolves to the parent id */ - actionArchive: function (recordIDs, parentID) { + actionArchive: function (resIDs, parentID) { var self = this; var parent = this.localData[parentID]; - var resIDs = _.map(recordIDs, function (recordID) { - return self.localData[recordID].res_id; - }); return this._rpc({ model: parent.model, method: 'action_archive', @@ -1318,16 +1324,13 @@ var BasicModel = AbstractModel.extend({ /** * Unarchive the given records * - * @param {Array} recordIDs local ids of the records to (un)archive + * @param {integer[]} resIDs ids of the records to unarchive * @param {string} parentID id of the parent resource to reload * @returns {Promise} resolves to the parent id */ - actionUnarchive: function (recordIDs, parentID) { + actionUnarchive: function (resIDs, parentID) { var self = this; var parent = this.localData[parentID]; - var resIDs = _.map(recordIDs, function (recordID) { - return self.localData[recordID].res_id; - }); return this._rpc({ model: parent.model, method: 'action_unarchive', diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 8af47e4cd4f..7147f7aee66 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -652,7 +652,6 @@ var FormController = BasicController.extend({ */ _onSave: function (ev) { ev.stopPropagation(); // Prevent x2m lines to be auto-saved - var self = this; this._disableButtons(); this.saveRecord().then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); }, @@ -679,7 +678,8 @@ var FormController = BasicController.extend({ * @param {boolean} archive */ _toggleArchiveState: function (archive) { - this._archive([this.handle], archive); + const resIds = this.model.localIdsToResIds([this.handle]); + this._archive(resIds, archive); }, }); diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index cca0a43cb5e..a908eb0d138 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -496,7 +496,7 @@ var KanbanController = BasicController.extend({ var self = this; var archive = ev.data.archive; var column = ev.target; - var recordIds = _.pluck(column.records, 'db_id'); + var recordIds = _.pluck(column.records, 'id'); if (recordIds.length) { var prom = archive ? this.model.actionArchive(recordIds, column.db_id) : 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 922d87600c5..49e2e12ff15 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -12,6 +12,7 @@ var BasicController = require('web.BasicController'); var DataExport = require('web.DataExport'); var Dialog = require('web.Dialog'); var ListConfirmDialog = require('web.ListConfirmDialog'); +var session = require('web.session'); var _t = core._t; var qweb = core.qweb; @@ -464,9 +465,33 @@ var ListController = BasicController.extend({ * * @private * @param {boolean} archive + * @returns {Promise} */ - _toggleArchiveState: function (archive) { - this._archive(this.selectedRecords, archive); + _toggleArchiveState: async function (archive) { + let resIds; + let displayNotif = false; + const state = this.model.get(this.handle, {raw: true}); + if (this.isDomainSelected) { + resIds = await this._rpc({ + model: this.modelName, + method: 'search', + args: [state.getDomain()], + kwargs: { + limit: session.active_ids_limit, + }, + }); + displayNotif = (resIds.length === session.active_ids_limit); + } else { + resIds = this.model.localIdsToResIds(this.selectedRecords); + } + await this._archive(resIds, archive); + if (displayNotif) { + const msg = _.str.sprintf( + _t("Of the %d records selected, only the first %d have been archived/unarchived."), + state.count, resIds.length + ); + this.do_notify(_t('Warning'), msg); + } }, /** * Hide the create button in non-empty grouped editable list views, as an diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js index 81deca04968..793a5749b46 100644 --- a/addons/web/static/tests/views/kanban_model_tests.js +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -157,7 +157,7 @@ QUnit.module('Views', { assert.strictEqual(xpadGroup.count, 1, 'xpad group has one record'); // archive the column 'xphone' - var recordIDs = _.pluck(xphoneGroup.data, 'id'); + var recordIDs = xphoneGroup.data.map(record => record.res_id); await model.actionArchive(recordIDs, xphoneGroup.id); state = model.get(resultID); xphoneGroup = _.findWhere(state.data, {res_id: 37}); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index cf0736f86f1..41462827413 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2627,6 +2627,113 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('archive all records matching the domain', async function (assert) { + assert.expect(6); + + // add active field on foo model and make all records active + this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true}; + this.data.foo.records.push({id: 5, bar: true, foo: "xxx"}); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + domain: [['bar', '=', true]], + mockRPC: function (route, args) { + if (args.method === 'action_archive') { + assert.deepEqual(args.args[0], [1, 2, 3, 5]); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + services: { + notification: NotificationService.extend({ + notify: function () { + throw new Error('should not display a notification'); + }, + }), + }, + viewOptions: { + hasActionMenus: true, + }, + }); + + assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records"); + + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain'); + + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Archive"); + + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + list.destroy(); + }); + + QUnit.test('archive all records matching the domain (limit reached)', async function (assert) { + assert.expect(8); + + // add active field on foo model and make all records active + this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true}; + this.data.foo.records.push({id: 5, bar: true, foo: "xxx"}); + this.data.foo.records.push({id: 6, bar: true, foo: "yyy"}); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + domain: [['bar', '=', true]], + mockRPC: function (route, args) { + if (args.method === 'action_archive') { + assert.deepEqual(args.args[0], [1, 2, 3, 5]); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + services: { + notification: NotificationService.extend({ + notify: function () { + assert.step('notify'); + }, + }), + }, + session: { + active_ids_limit: 4, + }, + viewOptions: { + hasActionMenus: true, + }, + }); + + + assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records"); + + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain'); + + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Archive"); + + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + assert.verifySteps(['notify']); + + list.destroy(); + }); + QUnit.test('pager (ungrouped and grouped mode), default limit', async function (assert) { assert.expect(4); From 94583df492113211b5ab3afd9d5c25ae1fe30dba Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 20:18:16 +0000 Subject: [PATCH 6/9] [IMP] web: adapt delete feature to list selection changes Following the new selection feature in list views, this commit adapts the delete action. Part of task 2185145 --- .../src/js/views/basic/basic_controller.js | 7 +- .../src/js/views/list/list_controller.js | 68 ++++++++++-- addons/web/static/tests/views/list_tests.js | 101 ++++++++++++++++++ 3 files changed, 163 insertions(+), 13 deletions(-) diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index f0fd3cc8c75..bbfee26e51f 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -346,9 +346,10 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { .then(self._onDeletedRecords.bind(self, ids)); } if (this.confirmOnDelete) { - Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { - confirm_callback: doIt, - }); + const message = ids.length > 1 ? + _t("Are you sure you want to delete these records?") : + _t("Are you sure you want to delete this record?"); + Dialog.confirm(this, message, { confirm_callback: doIt }); } else { doIt(); } 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 49e2e12ff15..c41e253c583 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -266,6 +266,39 @@ var ListController = BasicController.extend({ return this.renderer.updateState(state, {noRender: true}) .then(this._setMode.bind(this, 'readonly', id)); }, + /** + * Deletes records matching the current domain. We limit the number of + * deleted records to the 'active_ids_limit' config parameter. + * + * @private + */ + _deleteRecordsInCurrentDomain: function () { + const doIt = async () => { + const state = this.model.get(this.handle, {raw: true}); + const resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit); + await this._rpc({ + model: this.modelName, + method: 'unlink', + args: [resIds], + context: state.getContext(), + }); + if (resIds.length === session.active_ids_limit) { + const msg = _.str.sprintf( + _t("Of the %d records selected, only the first %d have been deleted."), + state.count, resIds.length + ); + this.do_notify(_t('Warning'), msg); + } + this.reload(); + }; + if (this.confirmOnDelete) { + Dialog.confirm(this, _t("Are you sure you want to delete these records ?"), { + confirm_callback: doIt, + }); + } else { + doIt(); + } + }, /** * To improve performance, list view must not be rerendered if it is asked * to discard all its changes. Indeed, only the in-edition row needs to be @@ -288,6 +321,24 @@ var ListController = BasicController.extend({ self.updateButtons('readonly'); }); }, + /** + * Returns the ids of records matching the given domain. + * + * @private + * @param {Array[]} domain + * @param {integer} [limit] + * @returns {integer[]} + */ + _domainToResIds: function (domain, limit) { + return this._rpc({ + model: this.modelName, + method: 'search', + args: [domain], + kwargs: { + limit: limit, + }, + }); + }, /** * @returns {DataExport} the export dialog widget * @private @@ -472,14 +523,7 @@ var ListController = BasicController.extend({ let displayNotif = false; const state = this.model.get(this.handle, {raw: true}); if (this.isDomainSelected) { - resIds = await this._rpc({ - model: this.modelName, - method: 'search', - args: [state.getDomain()], - kwargs: { - limit: session.active_ids_limit, - }, - }); + resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit); displayNotif = (resIds.length === session.active_ids_limit); } else { resIds = this.model.localIdsToResIds(this.selectedRecords); @@ -610,8 +654,12 @@ var ListController = BasicController.extend({ * * @private */ - _onDeleteSelectedRecords: function () { - this._deleteRecords(this.selectedRecords); + _onDeleteSelectedRecords: async function () { + if (this.isDomainSelected) { + this._deleteRecordsInCurrentDomain(); + } else { + this._deleteRecords(this.selectedRecords); + } }, /** * Handler called when the user clicked on the 'Discard' button. diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 41462827413..235cffda702 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2581,6 +2581,107 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('delete all records matching the domain', async function (assert) { + assert.expect(6); + + this.data.foo.records.push({id: 5, bar: true, foo: "xxx"}); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + domain: [['bar', '=', true]], + mockRPC: function (route, args) { + if (args.method === 'unlink') { + assert.deepEqual(args.args[0], [1, 2, 3, 5]); + } + return this._super.apply(this, arguments); + }, + services: { + notification: NotificationService.extend({ + notify: function () { + throw new Error('should not display a notification'); + }, + }), + }, + viewOptions: { + hasActionMenus: true, + }, + }); + + assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records"); + + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain'); + + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Delete"); + + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + list.destroy(); + }); + + QUnit.test('delete all records matching the domain (limit reached)', async function (assert) { + assert.expect(8); + + this.data.foo.records.push({id: 5, bar: true, foo: "xxx"}); + this.data.foo.records.push({id: 6, bar: true, foo: "yyy"}); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + domain: [['bar', '=', true]], + mockRPC: function (route, args) { + if (args.method === 'unlink') { + assert.deepEqual(args.args[0], [1, 2, 3, 5]); + } + return this._super.apply(this, arguments); + }, + services: { + notification: NotificationService.extend({ + notify: function () { + assert.step('notify'); + }, + }), + }, + session: { + active_ids_limit: 4, + }, + viewOptions: { + hasActionMenus: true, + }, + }); + + + assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records"); + + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus'); + assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain'); + + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Delete"); + + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + assert.verifySteps(['notify']); + + list.destroy(); + }); + QUnit.test('archiving one record', async function (assert) { assert.expect(12); From 4fd30ecbc1207fc370688c32798dd78eb5cef0e8 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Mon, 30 Mar 2020 13:31:05 +0000 Subject: [PATCH 7/9] [IMP] web: adapt multi edit feature to list selection changes We don't change the multi edit mechanism for now: it still only applies to the selected records, limited to the current page. However, we add the information in the confirm dialog to make it clear for the user. Part of task 2185145 --- .../src/js/views/list/list_confirm_dialog.js | 2 ++ .../src/js/views/list/list_controller.js | 1 + addons/web/static/src/xml/base.xml | 1 + addons/web/static/tests/views/list_tests.js | 27 +++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/addons/web/static/src/js/views/list/list_confirm_dialog.js b/addons/web/static/src/js/views/list/list_confirm_dialog.js index ea0a3191f9e..a499a5debb7 100644 --- a/addons/web/static/src/js/views/list/list_confirm_dialog.js +++ b/addons/web/static/src/js/views/list/list_confirm_dialog.js @@ -22,6 +22,8 @@ const ListConfirmDialog = Dialog.extend({ * @param {Widget} parent * @param {Object} record edited record with updated value * @param {Object} changes changes registered by the list controller + * @param {Object} changes isDomainSelected true iff the user selected the + * whole domain * @param {string} changes.fieldLabel label of the changed field * @param {string} changes.fieldName technical name of the changed field * @param {number} changes.nbRecords number of records (total) 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 c41e253c583..4a3972d50eb 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -464,6 +464,7 @@ var ListController = BasicController.extend({ }; const record = this.model.get(recordId); const dialogChanges = { + isDomainSelected: this.isDomainSelected, fieldLabel: node.attrs.string || record.fields[fieldName].string, fieldName: node.attrs.name, nbRecords: recordIds.length, diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index ee72f282337..2b0ff6f31dd 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -874,6 +874,7 @@

+ This update will only consider the records of the current page.

Among the selected records, are valid for this update.
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 235cffda702..f2a49f5c339 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -6340,6 +6340,33 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable list view: multi edition when the domain is selected', async function (assert) { + assert.expect(1); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + + + + `, + }); + + // select all records, and then select all domain + await testUtils.dom.click(list.$('th.o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain')); + + // edit a field + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666); + + assert.ok($('.modal-body').text().includes('This update will only consider the records of the current page.')); + + list.destroy(); + }); + QUnit.test('editable list view: many2one with readonly modifier', async function (assert) { assert.expect(2); From 009fe3e81bc982f25b052dc4430f6a232e4bff95 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 27 Mar 2020 13:54:46 +0000 Subject: [PATCH 8/9] [FIX] web: list: keep header checkbox selected ... when toggling an optional field. This bug wasn't really important until now, but with the new selection mechanism, it could lead to weird situations (e.g. the selection box says that all records are selected, but the thead checkbox isn't checked). Part of task 2185145 --- .../static/src/js/views/list/list_renderer.js | 3 ++ addons/web/static/tests/views/list_tests.js | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 418c39b14e0..5cf4b022c5d 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -989,6 +989,9 @@ var ListRenderer = BasicRenderer.extend({ return _.contains(self.selection, $(el).data('id')); }); $checked_rows.find('.o_list_record_selector input').prop('checked', true); + if ($checked_rows.length === self.$('.o_data_row').length) { // all rows are checked + self.$('thead .o_list_record_selector input').prop('checked', true); + } } }); return Promise.all([this._super.apply(this, arguments), prom]); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index f2a49f5c339..94a7a3bb156 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -8839,6 +8839,55 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('selection is kept when optional fields are toggled', async function (assert) { + assert.expect(7); + + var RamStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), + }); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '', + services: { + local_storage: RamStorageService, + }, + }); + + assert.containsN(list, 'th', 2); + + // select a record + await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input:first')); + + assert.containsOnce(list, '.o_list_record_selector input:checked'); + + // add an optional field + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input')); + assert.containsN(list, 'th', 3); + + assert.containsOnce(list, '.o_list_record_selector input:checked'); + + // select all records + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + assert.containsN(list, '.o_list_record_selector input:checked', 5); + + // remove an optional field + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input')); + assert.containsN(list, 'th', 2); + + assert.containsN(list, '.o_list_record_selector input:checked', 5); + + list.destroy(); + }); + QUnit.test('change the viewType of the current action', async function (assert) { assert.expect(25); From 7c740639fd78b6b59509e07ef2478913a183c9c1 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 25 Mar 2020 12:57:09 +0000 Subject: [PATCH 9/9] [REF] account: remove select_all Following the new selection feature in list view, we can remove the custom handling in the resequence action, aiming to perform the action on the active_domain when all records of the page were selected. Part of task 2185145 --- addons/account/wizard/account_resequence.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/addons/account/wizard/account_resequence.py b/addons/account/wizard/account_resequence.py index b585395f5f5..bd0b9378cd4 100644 --- a/addons/account/wizard/account_resequence.py +++ b/addons/account/wizard/account_resequence.py @@ -26,9 +26,7 @@ class ReSequenceWizard(models.TransientModel): def default_get(self, fields_list): values = super(ReSequenceWizard, self).default_get(fields_list) active_move_ids = self.env['account.move'] - if self.env.context.get('select_all') and self.env.context.get('active_model') == 'account.move' and 'active_domain' in self.env.context: - active_move_ids = self.env['account.move'].search(self.env.context['active_domain']) - elif self.env.context['active_model'] == 'account.move' and 'active_ids' in self.env.context: + if self.env.context['active_model'] == 'account.move' and 'active_ids' in self.env.context: active_move_ids = self.env['account.move'].browse(self.env.context['active_ids']) if len(active_move_ids.journal_id) > 1: raise UserError(_('You can only resequence items from the same journal'))