From 2be28d4d033b5930e5a16ff02407315a116e9043 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 13 Mar 2019 15:42:52 +0000 Subject: [PATCH] [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 --- addons/board/static/tests/dashboard_tests.js | 2 +- addons/web/models/models.py | 34 +++++ addons/web/static/src/js/core/rpc.js | 9 +- .../src/js/views/basic/basic_controller.js | 76 +++++++---- .../static/src/js/views/basic/basic_model.js | 26 +++- .../src/js/views/form/form_controller.js | 9 ++ .../src/js/views/kanban/kanban_controller.js | 10 ++ .../src/js/views/list/list_controller.js | 19 ++- .../web/static/src/js/views/list/list_view.js | 5 +- .../web/static/tests/helpers/mock_server.js | 39 +++++- .../static/tests/views/abstract_view_tests.js | 4 +- .../static/tests/views/basic_model_tests.js | 6 +- .../static/tests/views/kanban_model_tests.js | 4 +- addons/web/static/tests/views/kanban_tests.js | 124 ++++++++++-------- addons/web/static/tests/views/list_tests.js | 124 ++++++++++++++---- .../static/tests/views/search_view_tests.js | 2 +- .../static/tests/views/view_dialogs_tests.js | 17 +-- doc/reference/views.rst | 5 +- odoo/addons/base/rng/tree_view.rng | 5 + 19 files changed, 379 insertions(+), 141 deletions(-) diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 08ddf9d88e0..771325e9281 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -841,7 +841,7 @@ QUnit.test("Dashboard should use correct groupby", async function (assert) { '' + '', 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'); } diff --git a/addons/web/models/models.py b/addons/web/models/models.py index 12e6a422507..271f8b831da 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -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): """ diff --git a/addons/web/static/src/js/core/rpc.js b/addons/web/static/src/js/core/rpc.js index 21ec43e3d95..802f7082cad 100644 --- a/addons/web/static/src/js/core/rpc.js +++ b/addons/web/static/src/js/core/rpc.js @@ -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 || {}; } 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 19d2fd57892..064f7b84a52 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -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()); } }, 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 35435a3e500..d07ec61abdd 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -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 { 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 9997e144ca3..a351c8fa04f 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -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. * 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 58174c7812d..4ea823ed062 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -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 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 217f02c8fd1..a7b71ad4cf2 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -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 diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js index 163ac850817..e65a8c4adc4 100644 --- a/addons/web/static/src/js/views/list/list_view.js +++ b/addons/web/static/src/js/views/list/list_view.js @@ -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); }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 0f96b098bb8..d2060de9f59 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -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)); diff --git a/addons/web/static/tests/views/abstract_view_tests.js b/addons/web/static/tests/views/abstract_view_tests.js index ba819379279..b2e0cee0dcd 100644 --- a/addons/web/static/tests/views/abstract_view_tests.js +++ b/addons/web/static/tests/views/abstract_view_tests.js @@ -129,8 +129,8 @@ QUnit.module('Views', { 'foo,false,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); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 2a9fbec6bd4..d840210d4ff 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -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"); } diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js index 29dd60c3d15..e69b08aa3a8 100644 --- a/addons/web/static/tests/views/kanban_model_tests.js +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -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"); } diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 4cb99e17314..280cf85291f 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -108,7 +108,7 @@ QUnit.module('Views', { '', 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', { '', 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', { '
' + '', 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', { '', 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', { '', 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', { '', 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', { '', 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', { '', 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', { '', 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', { '', 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; }); } diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 66201d3c38b..fbed65758b5 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -1026,7 +1026,7 @@ QUnit.module('Views', { groupBy: ['foo'], arch: '', 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: '', 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: '', 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: '', + 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: '', + 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', ]); diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js index f7d07f8da9d..5980ab9211b 100644 --- a/addons/web/static/tests/views/search_view_tests.js +++ b/addons/web/static/tests/views/search_view_tests.js @@ -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); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index b17112c8978..9dd78f3b724 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -146,22 +146,23 @@ QUnit.module('Views', { '', }, 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: "" diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 0e2e30cf602..b7d4ccec0f6 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -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) diff --git a/odoo/addons/base/rng/tree_view.rng b/odoo/addons/base/rng/tree_view.rng index 8fc5bae7f9f..80563b4ca36 100644 --- a/odoo/addons/base/rng/tree_view.rng +++ b/odoo/addons/base/rng/tree_view.rng @@ -34,6 +34,11 @@ + + + + +