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 @@ + + + + +