[IMP] web,board: enable pagination in grouped lists

With this rev., when there are a lot of groups in a grouped list
view, groups are displayed under several pages, whereas they were
all displayed in the same page before.

This is especially interesting with the new 'expand' attribute, to
ensure that we don't read records for a large number of groups.

By default, the groups limit is set to 80 (like records), and to 10
is the 'expand' attribute is set to true. This limit can be
overriden with the 'groups_limit' attribute.

Part of task 1915702
This commit is contained in:
Aaron Bohy
2019-04-02 11:55:57 +00:00
parent 5895d23cbd
commit 2be28d4d03
19 changed files with 379 additions and 141 deletions
+1 -1
View File
@@ -841,7 +841,7 @@ QUnit.test("Dashboard should use correct groupby", async function (assert) {
'</board>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
assert.deepEqual(args.kwargs.groupby, ['bar'],
'user defined groupby should have precedence on action groupby');
}
+34
View File
@@ -17,6 +17,40 @@ class IrActionsActWindowView(models.Model):
class Base(models.AbstractModel):
_inherit = 'base'
@api.model
def web_read_group(self, domain, fields, groupby, limit=None, offset=0, orderby=False,
lazy=True):
"""
Returns the result of a read_group and the total number of groups matching the search
domain.
:param domain: search domain
:param fields: list of fields to read (see ``fields``` param of ``read_group``)
:param groupby: list of fields to group on (see ``groupby``` param of ``read_group``)
:param limit: see ``limit`` param of ``read_group``
:param offset: see ``offset`` param of ``read_group``
:param orderby: see ``orderby`` param of ``read_group``
:param lazy: see ``lazy`` param of ``read_group``
:return: {
'groups': array of read groups
'length': total number of groups
}
"""
groups = self.read_group(domain, fields, groupby, offset=offset, limit=limit,
orderby=orderby, lazy=lazy)
if not groups:
length = 0
elif limit and len(groups) == limit:
all_groups = self.read_group(domain, ['display_name'], groupby, lazy=True)
length = len(all_groups)
else:
length = len(groups) + offset
return {
'groups': groups,
'length': length
}
@api.model
def read_progress_bar(self, domain, group_by, progress_bar):
"""
+5 -4
View File
@@ -37,6 +37,7 @@ return {
buildQuery: function (options) {
var route;
var params = options.params || {};
var orderBy;
if (options.route) {
route = options.route;
} else if (options.model && options.method) {
@@ -50,7 +51,7 @@ return {
params.kwargs.context = options.context || params.context || params.kwargs.context;
}
if (options.method === 'read_group') {
if (options.method === 'read_group' || options.method === 'web_read_group') {
if (!(params.args && params.args[0] !== undefined)) {
params.kwargs.domain = options.domain || params.domain || params.kwargs.domain || [];
}
@@ -64,7 +65,7 @@ return {
params.kwargs.limit = options.limit || params.limit || params.kwargs.limit;
// In kwargs, we look for "orderby" rather than "orderBy" (note the absence of capital B),
// since the Python argument to the actual function is "orderby".
var orderBy = options.orderBy || params.orderBy || params.kwargs.orderby;
orderBy = options.orderBy || params.orderBy || params.kwargs.orderby;
params.kwargs.orderby = orderBy ? this._serializeSort(orderBy) : orderBy;
params.kwargs.lazy = 'lazy' in options ? options.lazy : params.lazy;
}
@@ -77,7 +78,7 @@ return {
params.kwargs.limit = options.limit || params.limit || params.kwargs.limit;
// In kwargs, we look for "order" rather than "orderBy" since the Python
// argument to the actual function is "order".
var orderBy = options.orderBy || params.orderBy || params.kwargs.order;
orderBy = options.orderBy || params.orderBy || params.kwargs.order;
params.kwargs.order = orderBy ? this._serializeSort(orderBy) : orderBy;
}
@@ -88,7 +89,7 @@ return {
params.fields = options.fields || params.fields;
params.limit = options.limit || params.limit;
params.offset = options.offset || params.offset;
var orderBy = options.orderBy || params.orderBy;
orderBy = options.orderBy || params.orderBy;
params.sort = orderBy ? this._serializeSort(orderBy) : orderBy;
params.context = options.context || params.context || {};
}
@@ -148,25 +148,28 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
*/
renderPager: function ($node, options) {
var self = this;
var data = this.model.get(this.handle, {raw: true});
this.pager = new Pager(this, data.count, data.offset + 1, data.limit, options);
var params = this._getPagerParams();
this.pager = new Pager(this, params.size, params.current_min, params.limit, options);
this.pager.on('pager_changed', this, function (newState) {
var self = this;
this.pager.disable();
data = this.model.get(this.handle, {raw: true});
var data = this.model.get(this.handle, {raw: true});
var limitChanged = (data.limit !== newState.limit);
this.reload({limit: newState.limit, offset: newState.current_min - 1})
.then(function () {
// Reset the scroll position to the top on page changed only
if (!limitChanged) {
self.trigger_up('scrollTo', {top: 0});
}
})
.then(this.pager.enable.bind(this.pager));
var reloadParams;
if (data.groupedBy && data.groupedBy.length) {
reloadParams = {groupsLimit: newState.limit, groupsOffset: newState.current_min - 1};
} else {
reloadParams = {limit: newState.limit, offset: newState.current_min - 1};
}
this.reload(reloadParams).then(function () {
// reset the scroll position to the top on page changed only
if (!limitChanged) {
self.trigger_up('scrollTo', {top: 0});
}
}).then(this.pager.enable.bind(this.pager));
});
return this.pager.appendTo($node).then(function() {
self._updatePager(); // to force proper visibility
return this.pager.appendTo($node).then(function () {
self._updatePager(); // to force proper visibility
});
},
/**
@@ -412,6 +415,22 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
resIds: env.ids,
});
},
/**
* Return the params (current_min, limit and size) to pass to the pager,
* according to the current state.
*
* @private
* @returns {Object}
*/
_getPagerParams: function () {
var state = this.model.get(this.handle, {raw: true});
var isGrouped = state.groupedBy && state.groupedBy.length;
return {
current_min: (isGrouped ? state.groupsOffset : state.offset) + 1,
limit: isGrouped ? state.groupsLimit : state.limit,
size: isGrouped ? state.groupsCount : state.count,
};
},
/**
* Returns the new sidebar env
*
@@ -425,6 +444,16 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
model: this.modelName,
};
},
/**
* Determine whether or not the pager must be displayed (probably depending
* on the current state). Controllers must override this to implement their
* own logic.
*
* @private
*/
_isPagerVisible: function () {
return true;
},
/**
* Helper function to display a warning that some fields have an invalid
* value. This is used when a save operation cannot be completed.
@@ -533,23 +562,14 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
},
/**
* Helper method, to make sure the information displayed by the pager is up
* to date.
* Update the pager with the current state.
*
* @private
*/
_updatePager: function () {
if (this.pager) {
var data = this.model.get(this.handle, {raw: true});
this.pager.updateState({
current_min: data.offset + 1,
size: data.count,
});
var isRecord = data.type === 'record';
var hasData = !!data.count;
var isGrouped = data.groupedBy ? !!data.groupedBy.length : false;
var isNew = this.model.isNew(this.handle);
var isPagerVisible = isRecord ? !isNew : (hasData && !isGrouped);
this.pager.do_toggle(isPagerVisible);
this.pager.updateState(this._getPagerParams());
this.pager.do_toggle(this._isPagerVisible());
}
},
@@ -88,7 +88,6 @@ var concurrency = require('web.concurrency');
var Context = require('web.Context');
var core = require('web.core');
var Domain = require('web.Domain');
var fieldUtils = require('web.field_utils');
var session = require('web.session');
var utils = require('web.utils');
var viewUtils = require('web.viewUtils');
@@ -639,6 +638,9 @@ var BasicModel = AbstractModel.extend({
getDomain: element.getDomain,
getFieldNames: element.getFieldNames,
groupedBy: element.groupedBy,
groupsCount: element.groupsCount,
groupsLimit: element.groupsLimit,
groupsOffset: element.groupsOffset,
id: element.id,
isDirty: element.isDirty,
isOpen: element.isOpen,
@@ -3732,6 +3734,9 @@ var BasicModel = AbstractModel.extend({
fields: fields,
fieldsInfo: params.fieldsInfo,
groupedBy: params.groupedBy || [],
groupsCount: 0,
groupsLimit: type === 'list' && params.groupsLimit || null,
groupsOffset: 0,
id: _.uniqueId(params.modelName + '_'),
isOpen: params.isOpen,
limit: type === 'record' ? 1 : params.limit,
@@ -4245,20 +4250,25 @@ var BasicModel = AbstractModel.extend({
var groupByField = list.groupedBy[0];
var rawGroupBy = groupByField.split(':')[0];
var fields = _.uniq(list.getFieldNames().concat(rawGroupBy));
var orderedBy = _.filter(list.orderedBy, function(order){
var orderedBy = _.filter(list.orderedBy, function (order) {
return order.name === rawGroupBy || list.fields[order.name].group_operator !== undefined;
});
var openGroupsLimit = list.groupsLimit || self.OPEN_GROUP_LIMIT;
return this._rpc({
model: list.model,
method: 'read_group',
method: 'web_read_group',
fields: fields,
domain: list.domain,
context: list.context,
groupBy: list.groupedBy,
limit: list.groupsLimit,
offset: list.groupsOffset,
orderBy: orderedBy,
lazy: true,
})
.then(function (groups) {
.then(function (result) {
var groups = result.groups;
list.groupsCount = result.length;
var previousGroups = _.map(list.data, function (groupID) {
return self.localData[groupID];
});
@@ -4321,7 +4331,7 @@ var BasicModel = AbstractModel.extend({
// form view) are reloaded
newGroup.limit = oldGroup.limit + oldGroup.loadMoreOffset;
self.localData[newGroup.id] = newGroup;
} else if (!newGroup.openGroupByDefault || openGroupCount >= self.OPEN_GROUP_LIMIT) {
} else if (!newGroup.openGroupByDefault || openGroupCount >= openGroupsLimit) {
newGroup.isOpen = false;
} else if ('__fold' in group) {
newGroup.isOpen = !group.__fold;
@@ -4486,6 +4496,12 @@ var BasicModel = AbstractModel.extend({
if (options.offset !== undefined) {
this._setOffset(element.id, options.offset);
}
if (options.groupsLimit !== undefined) {
element.groupsLimit = options.groupsLimit;
}
if (options.groupsOffset !== undefined) {
element.groupsOffset = options.groupsOffset;
}
if (options.loadMoreOffset !== undefined) {
element.loadMoreOffset = options.loadMoreOffset;
} else {
@@ -347,6 +347,15 @@ var FormController = BasicController.extend({
this._super.apply(this, arguments);
this.renderer.enableButtons();
},
/**
* Only display the pager if we are not on a new record.
*
* @override
* @private
*/
_isPagerVisible: function () {
return !this.model.isNew(this.handle);
},
/**
* Hook method, called when record(s) has been deleted.
*
@@ -137,6 +137,16 @@ var KanbanController = BasicController.extend({
}
return this.renderer.updateRecord(this.model.get(id));
},
/**
* Only display the pager in the ungrouped case, with data.
*
* @override
* @private
*/
_isPagerVisible: function () {
var state = this.model.get(this.handle, {raw: true});
return !!(state.count && !state.groupedBy.length);
},
/**
* @private
* @param {Widget} kanbanRecord
@@ -11,7 +11,6 @@ var core = require('web.core');
var BasicController = require('web.BasicController');
var DataExport = require('web.DataExport');
var Dialog = require('web.Dialog');
var pyUtils = require('web.py_utils');
var Sidebar = require('web.Sidebar');
var _t = core._t;
@@ -342,6 +341,16 @@ var ListController = BasicController.extend({
var record = this.model.get(this.handle);
return _.extend(env, {domain: record.getDomain()});
},
/**
* Only display the pager when there are data to display.
*
* @override
* @private
*/
_isPagerVisible: function () {
var state = this.model.get(this.handle, {raw: true});
return !!state.count;
},
/**
* Allows to change the mode of a single row.
*
@@ -395,11 +404,9 @@ var ListController = BasicController.extend({
* @returns {Promise}
*/
_update: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self._toggleSidebar();
self._toggleCreateButton();
});
return this._super.apply(this, arguments)
.then(this._toggleSidebar.bind(this))
.then(this._toggleCreateButton.bind(this));
},
/**
* This helper simply makes sure that the control panel buttons matches the
@@ -38,6 +38,7 @@ var ListView = BasicView.extend({
var selectedRecords = []; // there is no selected records by default
var mode = this.arch.attrs.editable && !params.readonly ? "edit" : "readonly";
var expandGroups = !!JSON.parse(this.arch.attrs.expand || "0");
this.controllerParams.editable = this.arch.attrs.editable;
this.controllerParams.hasSidebar = params.hasSidebar;
@@ -54,8 +55,10 @@ var ListView = BasicView.extend({
this.rendererParams.addCreateLineInGroups = this.rendererParams.editable && this.controllerParams.activeActions.create;
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.openGroupByDefault = !!JSON.parse(this.arch.attrs.expand || "0");
this.loadParams.openGroupByDefault = expandGroups;
this.loadParams.type = 'list';
var groupsLimit = parseInt(this.arch.attrs.groups_limit, 10);
this.loadParams.groupsLimit = groupsLimit || (expandGroups ? 10 : 80);
},
//--------------------------------------------------------------------------
+38 -1
View File
@@ -808,7 +808,8 @@ var MockServer = Class.extend({
* @param {string[]} kwargs.fields fields that we are aggregating
* @param {Array} kwargs.domain the domain used for the read_group
* @param {boolean} kwargs.lazy still mostly ignored
* @param {integer} kwargs.limit ignored as well
* @param {integer} [kwargs.limit]
* @param {integer} [kwargs.offset]
* @returns {Object[]}
*/
_mockReadGroup: function (model, kwargs) {
@@ -982,6 +983,11 @@ var MockServer = Class.extend({
});
}
if (kwargs.limit) {
var offset = kwargs.offset || 0;
result = result.slice(offset, kwargs.limit + offset);
}
return result;
},
/**
@@ -1164,6 +1170,34 @@ var MockServer = Class.extend({
return true;
},
/**
* Simulate a 'web_read_group' call to the server.
*
* Note: some keys in kwargs are still ignored
*
* @private
* @param {string} model a string describing an existing model
* @param {Object} kwargs various options supported by read_group
* @param {string[]} kwargs.groupby fields that we are grouping
* @param {string[]} kwargs.fields fields that we are aggregating
* @param {Array} kwargs.domain the domain used for the read_group
* @param {boolean} kwargs.lazy still mostly ignored
* @param {integer} [kwargs.limit]
* @param {integer} [kwargs.offset]
* @returns {Object[]}
*/
_mockWebReadGroup: function (model, kwargs) {
var allGroups = this._mockReadGroup(model, {
domain: kwargs.domain,
fields: ['display_name'],
groupby: kwargs.groupby,
lazy: kwargs.lazy,
});
return {
groups: this._mockReadGroup(model, kwargs),
length: allGroups.length,
};
},
/**
* Simulate a 'write' operation
*
@@ -1243,6 +1277,9 @@ var MockServer = Class.extend({
case 'read_group':
return Promise.resolve(this._mockReadGroup(args.model, args.kwargs));
case 'web_read_group':
return Promise.resolve(this._mockWebReadGroup(args.model, args.kwargs));
case 'read_progress_bar':
return Promise.resolve(this._mockReadProgressBar(args.model, args.kwargs));
@@ -129,8 +129,8 @@ QUnit.module('Views', {
'foo,false,search': '<search></search>',
},
data: this.data,
mockRPC: function(route, args) {
if (args.method === 'read_group') {
mockRPC: function (route, args) {
if (args.method === 'web_read_group') {
assert.deepEqual(args.kwargs.groupby, ['bar']);
}
return this._super.apply(this, arguments);
@@ -917,14 +917,14 @@ odoo.define('web.basic_model_tests', function (require) {
Model: BasicModel,
data: this.data,
mockRPC: function (route, args) {
if (args.method === 'read_group' && filterEnabled) {
if (args.method === 'web_read_group' && filterEnabled) {
// as this is not yet supported by the MockServer, simulates
// a read_group that returns empty groups
// this is the case for several models (e.g. project.task
// grouped by stage_id)
return this._super.apply(this, arguments).then(function (result) {
// artificially filter out records of first group
result[0].product_id_count = 0;
result.groups[0].product_id_count = 0;
return result;
});
}
@@ -982,7 +982,7 @@ odoo.define('web.basic_model_tests', function (require) {
Model: BasicModel,
data: this.data,
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
assert.deepEqual(args.kwargs.fields, ['foo', 'date'],
"should have correctly trimmed the magic grouping info from the field name");
}
@@ -117,7 +117,7 @@ QUnit.module('Views', {
// check the rpcs done
assert.strictEqual(Object.keys(calledRoutes).length, 3, 'three different routes have been called');
var nbReadGroups = calledRoutes['/web/dataset/call_kw/partner/read_group'];
var nbReadGroups = calledRoutes['/web/dataset/call_kw/partner/web_read_group'];
var nbSearchRead = calledRoutes['/web/dataset/search_read'];
var nbNameCreate = calledRoutes['/web/dataset/call_kw/product/name_create'];
assert.strictEqual(nbReadGroups, 1, 'should have done 1 read_group');
@@ -170,7 +170,7 @@ QUnit.module('Views', {
Model: KanbanModel,
data: this.data,
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
assert.deepEqual(args.kwargs.groupby, ['product_id'],
"the second level of groupBy should have been removed");
}
+69 -55
View File
@@ -108,7 +108,7 @@ QUnit.module('Views', {
'</t></templates></kanban>',
groupBy: ['bar'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
// the lazy option is important, so the server can fill in
// the empty groups
assert.ok(args.kwargs.lazy, "should use lazy read_group");
@@ -485,7 +485,7 @@ QUnit.module('Views', {
"first column should contain two records");
assert.verifySteps([
'read_group', // initial read_group
'web_read_group', // initial read_group
'/web/dataset/search_read', // initial search_read (first column)
'/web/dataset/search_read', // initial search_read (second column)
'default_get', // quick create
@@ -558,7 +558,7 @@ QUnit.module('Views', {
"first column should contain two records");
assert.verifySteps([
'read_group', // initial read_group
'web_read_group', // initial read_group
'/web/dataset/search_read', // initial search_read (first column)
'/web/dataset/search_read', // initial search_read (second column)
'load_views', // form view in quick create
@@ -616,7 +616,7 @@ QUnit.module('Views', {
"first column should contain three records");
assert.verifySteps([
'read_group', // initial read_group
'web_read_group', // initial read_group
'/web/dataset/search_read', // initial search_read (first column)
'/web/dataset/search_read', // initial search_read (second column)
'default_get', // quick create
@@ -683,7 +683,7 @@ QUnit.module('Views', {
"first column should contain three records");
assert.verifySteps([
'read_group', // initial read_group
'web_read_group', // initial read_group
'/web/dataset/search_read', // initial search_read (first column)
'/web/dataset/search_read', // initial search_read (second column)
'load_views', // form view in quick create
@@ -735,7 +735,6 @@ QUnit.module('Views', {
await testUtils.kanban.clickCreate(kanban);
var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
assert.strictEqual($quickCreate.length, 1,
"should have a quick create element in the first column");
assert.strictEqual($quickCreate.find('.o_field_widget[name=int_field]').val(), '4',
@@ -748,7 +747,7 @@ QUnit.module('Views', {
"onchange should have been triggered");
assert.verifySteps([
'read_group', // initial read_group
'web_read_group', // initial read_group
'/web/dataset/search_read', // initial search_read (first column)
'/web/dataset/search_read', // initial search_read (second column)
'load_views', // form view in quick create
@@ -1824,14 +1823,17 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
var result = [
{__domain: [['product_id', '=', 3]], product_id_count: 0},
{__domain: [['product_id', '=', 5]], product_id_count: 0},
];
var result = {
groups: [
{__domain: [['product_id', '=', 3]], product_id_count: 0},
{__domain: [['product_id', '=', 5]], product_id_count: 0},
],
length: 2,
};
return Promise.resolve(result);
}
return this._super.apply(this, arguments);
@@ -2408,11 +2410,11 @@ QUnit.module('Views', {
await kanban.reload();
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'read',
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'read',
@@ -2444,11 +2446,11 @@ QUnit.module('Views', {
await kanban.reload(kanban);
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'read',
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'read',
@@ -2487,11 +2489,11 @@ QUnit.module('Views', {
await kanban.reload();
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'name_get',
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'name_get',
@@ -2766,7 +2768,7 @@ QUnit.module('Views', {
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/partner/read_group') {
if (route === '/web/dataset/call_kw/partner/web_read_group') {
readGroupCount++;
var correctGroupBy;
if (readGroupCount === 2) {
@@ -2976,10 +2978,10 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
return this._super.apply(this, arguments).then(function (result) {
result[2].__fold = true;
result[8].__fold = true;
result.groups[2].__fold = true;
result.groups[8].__fold = true;
return result;
});
}
@@ -3119,7 +3121,7 @@ QUnit.module('Views', {
'Undefined column could not be edited');
assert.ok(!kanban.$('.o_kanban_group:first .o_column_archive_records').length, "Records of undefined column could not be archived");
assert.ok(!kanban.$('.o_kanban_group:first .o_column_unarchive_records').length, "Records of undefined column could not be restored");
assert.verifySteps(['read_group', 'unlink', 'read_group']);
assert.verifySteps(['web_read_group', 'unlink', 'web_read_group']);
assert.strictEqual(kanban.renderer.widgets.length, 2,
"the old widgets should have been correctly deleted");
@@ -3139,14 +3141,14 @@ QUnit.module('Views', {
kanban.$('.o_kanban_header_title:last'), {position: 'right'}
);
assert.deepEqual([3, newColumnID], resequencedIDs,
"moving the Undefined column should not affect order of other columns")
"moving the Undefined column should not affect order of other columns");
await testUtils.dom.dragAndDrop(
kanban.$('.o_kanban_header_title:first'),
kanban.$('.o_kanban_header_title:nth(1)'), {position: 'right'}
);
await nextTick(); // wait for resequence after drag and drop
assert.deepEqual([newColumnID, 3], resequencedIDs,
"moved column should be resequenced accordingly")
"moved column should be resequenced accordingly");
assert.verifySteps(['name_create', 'read', 'read', 'read']);
kanban.destroy();
@@ -3474,12 +3476,12 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
return this._super.apply(this, arguments).then(function (result) {
_.each(result, function (group) {
_.each(result.groups, function (group) {
group[args.kwargs.groupby[0] + '_count'] = 0;
});
return result;
@@ -3595,10 +3597,13 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
var result = [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
];
if (args.method === 'web_read_group') {
var result = {
groups: [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
],
length: 1,
};
return Promise.resolve(result);
}
return this._super.apply(this, arguments);
@@ -3637,10 +3642,13 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
var result = [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
];
if (args.method === 'web_read_group') {
var result = {
groups: [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
],
length: 1,
};
return Promise.resolve(result);
}
return this._super.apply(this, arguments);
@@ -3682,10 +3690,13 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
var result = [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
];
if (args.method === 'web_read_group') {
var result = {
groups: [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
],
length: 1,
};
return Promise.resolve(result);
}
return this._super.apply(this, arguments);
@@ -3726,10 +3737,13 @@ QUnit.module('Views', {
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
var result = [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
];
if (args.method === 'web_read_group') {
var result = {
groups: [
{__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
],
length: 1,
};
return Promise.resolve(result);
}
return this._super.apply(this, arguments);
@@ -4727,12 +4741,12 @@ QUnit.module('Views', {
assert.verifySteps([
// initial load
'read_group',
'web_read_group',
'read_progress_bar',
'/web/dataset/search_read',
'/web/dataset/search_read',
// reload
'read_group',
'web_read_group',
'read_progress_bar',
'/web/dataset/search_read',
'/web/dataset/search_read',
@@ -5061,7 +5075,7 @@ QUnit.module('Views', {
groupBy: ['bar'],
mockRPC: function (route, args) {
var result = this._super(route, args);
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
var isFirstUpdate = _.isEmpty(args.kwargs.domain) &&
args.kwargs.groupby &&
args.kwargs.groupby[0] === 'bar';
@@ -5200,22 +5214,22 @@ QUnit.module('Views', {
'</t></templates></kanban>',
groupBy: ['state'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
return this._super.apply(this, arguments).then(function (result) {
// add 2 empty columns in the middle
result.splice(1,0,{state_count:0,state:'def',
__domain:[["state","=","def"]]});
result.splice(1,0,{state_count:0,state:'def',
__domain:[["state","=","def"]]});
result.groups.splice(1, 0, {state_count: 0, state: 'def',
__domain: [["state", "=", "def"]]});
result.groups.splice(1, 0, {state_count: 0, state: 'def',
__domain: [["state", "=", "def"]]});
// add 1 empty column in the beginning and the end
result.unshift({state_count:0,state:'def',
__domain:[["state","=","def"]]});
result.push({state_count:0,state:'def',
__domain:[["state","=","def"]]});
result.groups.unshift({state_count: 0, state: 'def',
__domain: [["state", "=", "def"]]});
result.groups.push({state_count: 0, state: 'def',
__domain: [["state", "=", "def"]]});
return result;
});
}
+101 -23
View File
@@ -1026,7 +1026,7 @@ QUnit.module('Views', {
groupBy: ['foo'],
arch: '<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
assert.step(args.kwargs.orderby || 'default order');
}
return this._super.apply(this, arguments);
@@ -1042,7 +1042,7 @@ QUnit.module('Views', {
assert.strictEqual(list.$('tbody .o_list_number').text(), '51017',
"order should be 5, 10, 17");
await testUtils.dom.click(list.$('.o_column_sortable'));
await testUtils.dom.click(list.$('.o_column_sortable'));
assert.strictEqual(list.$('tbody .o_list_number').text(), '17105',
"initial order should be 17, 10, 5");
assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should still be 32");
@@ -1055,10 +1055,10 @@ QUnit.module('Views', {
QUnit.test('groups cannot be sorted on non-aggregable fields', async function (assert) {
assert.expect(6);
this.data.foo.fields.sort_field = {string: "sortable_field", type: "sting", sortable: true, default: "value"};
_.each(this.data.records, function(elem) {
_.each(this.data.records, function (elem) {
elem.sort_field = "value" + elem.id;
});
this.data.foo.fields.foo.sortable= true;
this.data.foo.fields.foo.sortable = true;
var list = await createView({
View: ListView,
model: 'foo',
@@ -1066,7 +1066,7 @@ QUnit.module('Views', {
groupBy: ['foo'],
arch: '<tree editable="bottom"><field name="foo" /><field name="int_field"/><field name="sort_field"/></tree>',
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
assert.step(args.kwargs.orderby || 'default order');
}
return this._super.apply(this, arguments);
@@ -1252,7 +1252,7 @@ QUnit.module('Views', {
assert.isVisible(list.pager.$el, "pager should be visible");
assert.strictEqual(list.pager.state.size, 4, "pager's size should be 4");
await list.update({ groupBy: ['bar']});
assert.isNotVisible(list.pager.$el, "pager should be invisible");
assert.strictEqual(list.pager.state.size, 2, "pager's size should be 2");
list.destroy();
});
@@ -2079,7 +2079,7 @@ QUnit.module('Views', {
arch: '<tree><field name="id"/><field name="int_field"/></tree>',
groupBy: ['m2o', 'foo'],
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
if (args.kwargs.groupby[0] === 'foo') { // nested read_group
// called twice (once when opening the group, once when sorting)
assert.deepEqual(args.kwargs.domain, [['m2o', '=', 1]],
@@ -3709,18 +3709,19 @@ QUnit.module('Views', {
mockRPC: function (route, args) {
// Override of the read_group to display the row even if there is no record in it,
// to mock the behavihour of some fields e.g stage_id on the sale order.
if (args.method === 'read_group' && args.kwargs.groupby[0] === "m2o") {
return Promise.resolve([
{
if (args.method === 'web_read_group' && args.kwargs.groupby[0] === "m2o") {
return Promise.resolve({
groups: [{
id: 8,
m2o:[1,"Value 1"],
m2o: [1, "Value 1"],
m2o_count: 0
}, {
id: 2,
m2o:[2,"Value 2"],
m2o: [2, "Value 2"],
m2o_count: 1
}
]);
}],
length: 1,
});
}
return this._super.apply(this, arguments);
},
@@ -4065,6 +4066,38 @@ QUnit.module('Views', {
delete widgetRegistry.map.asyncWidget;
});
QUnit.test('grouped lists with groups_limit attribute', async function (assert) {
assert.expect(8);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree groups_limit="3"><field name="foo"/></tree>',
groupBy: ['int_field'],
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
});
assert.containsN(list, '.o_group_header', 3); // page 1
assert.containsNone(list, '.o_data_row');
assert.containsOnce(list, '.o_pager_counter'); // has a pager
await testUtils.dom.click(list.$('.o_pager_next')); // switch to page 2
assert.containsN(list, '.o_group_header', 1); // page 2
assert.containsNone(list, '.o_data_row');
assert.verifySteps([
'web_read_group', // read_group page 1
'web_read_group', // read_group page 2
]);
list.destroy();
});
QUnit.test('grouped list with expand attribute', async function (assert) {
assert.expect(6);
@@ -4084,7 +4117,7 @@ QUnit.module('Views', {
assert.containsN(list, '.o_data_row', 4);
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
]);
@@ -4111,9 +4144,54 @@ QUnit.module('Views', {
assert.containsN(list, '.o_group_header', 6);
assert.verifySteps([
'read_group', // global
'read_group', // first group
'read_group', // second group
'web_read_group', // global
'web_read_group', // first group
'web_read_group', // second group
]);
list.destroy();
});
QUnit.test('grouped lists with expand attribute and a lot of groups', async function (assert) {
assert.expect(10);
for (var i = 0; i < 15; i++) {
this.data.foo.records.push({foo: 'record ' + i, int_field: i});
}
var nbSearchRead = 0;
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree expand="1"><field name="foo"/></tree>',
groupBy: ['int_field'],
mockRPC: function (route, args) {
if (args.method === 'web_read_group') {
assert.step(args.method);
}
if (route === '/web/dataset/search_read') {
nbSearchRead++;
}
return this._super.apply(this, arguments);
},
});
assert.containsN(list, '.o_group_header', 10); // page 1
assert.containsN(list, '.o_data_row', 11); // one group contains two records
assert.strictEqual(nbSearchRead, 10);
assert.containsOnce(list, '.o_pager_counter'); // has a pager
nbSearchRead = 0;
await testUtils.dom.click(list.$('.o_pager_next')); // switch to page 2
assert.containsN(list, '.o_group_header', 7); // page 2
assert.containsN(list, '.o_data_row', 7);
assert.strictEqual(nbSearchRead, 7);
assert.verifySteps([
'web_read_group', // read_group page 1
'web_read_group', // read_group page 2
]);
list.destroy();
@@ -4628,7 +4706,7 @@ QUnit.module('Views', {
assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'write',
@@ -4666,7 +4744,7 @@ QUnit.module('Views', {
assert.containsOnce(list, 'tbody tr td:contains(new value)');
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'write',
'read',
@@ -4709,7 +4787,7 @@ QUnit.module('Views', {
assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
assert.verifySteps([
'/web/dataset/call_kw/foo/read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'default_get',
@@ -4752,7 +4830,7 @@ QUnit.module('Views', {
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
]);
@@ -4794,7 +4872,7 @@ QUnit.module('Views', {
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.verifySteps([
'read_group',
'web_read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
]);
@@ -440,7 +440,7 @@ QUnit.module('Search View', {
archs: this.archs,
data: this.data,
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/partner/read_group') {
if (route === '/web/dataset/call_kw/partner/web_read_group') {
assert.deepEqual(args.kwargs.groupby, groupbys.shift());
}
return this._super.apply(this, arguments);
@@ -146,22 +146,23 @@ QUnit.module('Views', {
'</search>',
},
mockRPC: function (route, args) {
if (args.method === 'read_group') {
if (args.method === 'web_read_group') {
assert.deepEqual(args.kwargs, {
context: {},
domain: [["display_name","like","a"], "&", ["display_name","ilike","piou"], ["foo","ilike","piou"]],
fields: ["display_name","foo","bar"],
domain: [["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
fields: ["display_name", "foo", "bar"],
groupby: ["bar"],
orderby: '',
lazy: true
lazy: true,
limit: 80,
}, "should search with the complete domain (domain + search), and group by 'bar'");
}
if (search === 0 && route === '/web/dataset/search_read') {
search++;
assert.deepEqual(args, {
context: {},
domain: [["display_name","like","a"], "&", ["display_name","ilike","piou"], ["foo","ilike","piou"]],
fields: ["display_name","foo"],
domain: [["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
fields: ["display_name", "foo"],
model: "partner",
limit: 80,
sort: ""
@@ -169,8 +170,8 @@ QUnit.module('Views', {
} else if (search === 1 && route === '/web/dataset/search_read') {
assert.deepEqual(args, {
context: {},
domain: [["display_name","like","a"]],
fields: ["display_name","foo"],
domain: [["display_name", "like", "a"]],
fields: ["display_name", "foo"],
model: "partner",
limit: 80,
sort: ""
+4 -1
View File
@@ -220,7 +220,10 @@ root can have the following attributes:
allows *dis*\ abling the corresponding action in the view by setting the
corresponding attribute to ``false``
``limit``
the default size of a page. It should be a positive integer
the default size of a page. It must be a positive integer
``groups_limit``
when the list view is grouped, the default number of groups of a page. It
must be a position integer
``expand``
when the list view is grouped, automatically open the first level of groups
if set to true (default: false)
+5
View File
@@ -34,6 +34,11 @@
<rng:data type="int"/>
</rng:attribute>
</rng:optional>
<rng:optional>
<rng:attribute name="groups_limit">
<rng:data type="int"/>
</rng:attribute>
</rng:optional>
<rng:optional>
<rng:attribute name="editable">
<rng:choice>