[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
This commit is contained in:
Aaron Bohy
2020-03-30 09:55:08 +00:00
parent 9be9a75e67
commit 7af5fb31ec
8 changed files with 291 additions and 51 deletions
+3 -2
View File
@@ -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
@@ -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';
@@ -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 = $('<div>');
} 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();
},
/**
+14
View File
@@ -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;
}
}
+12
View File
@@ -859,6 +859,18 @@
</div>
</t>
<t t-name="ListView.selection">
<div class="o_list_selection_box">
<t t-if="isDomainSelected">All <t t-esc="nbTotal"/> selected</t>
<t t-else="">
<t t-esc="nbSelected"/> selected
<a t-if="isPageSelected and nbTotal > nbSelected" href="#" class="o_list_select_domain">
<i class="fa fa-arrow-right"/> Select all <t t-esc="nbTotal"/>
</a>
</t>
</div>
</t>
<t t-name="ListView.confirmModal">
<main role="alert">
<p>
@@ -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');
@@ -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));
+152 -8
View File
@@ -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: '<tree><field name="foo"/><field name="bar"/></tree>',
});
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: '<tree limit="3"><field name="foo"/><field name="bar"/></tree>',
});
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', {
'</tree>',
});
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: '<tree limit="2"><field name="foo"/></tree>',
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();