diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 276b29b71eb..dbb1b3e557c 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -90,6 +90,8 @@ var AbstractView = Factory.extend({ * @param {boolean} [params.withControlPanel=true] */ init: function (viewInfo, params) { + this._super.apply(this, arguments); + var action = params.action || {}; params = _.defaults(params, this._extractParamsFromAction(action)); 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 a7b71ad4cf2..c96b677e2ea 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -26,6 +26,7 @@ var ListController = BasicController.extend({ activate_next_widget: '_onActivateNextWidget', add_record: '_onAddRecord', button_clicked: '_onButtonClicked', + group_edit_button_clicked: '_onEditGroupClicked', edit_line: '_onEditLine', save_line: '_onSaveLine', resequence: '_onResequence', @@ -528,6 +529,22 @@ var ListController = BasicController.extend({ }); new DataExport(this, record, defaultExportFields).open(); }, + /** + * Opens the related form view. + * + * @private + * @param {OdooEvent} ev + */ + _onEditGroupClicked: function (ev) { + ev.stopPropagation(); + this.do_action({ + type: 'ir.actions.act_window', + views: [[false, 'form']], + res_model: ev.data.record.model, + res_id: ev.data.record.res_id, + flags: {mode: 'edit'}, + }); + }, /** * Force a resequence of the records curently on this page. * diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js new file mode 100644 index 00000000000..0be8f54e4f1 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_model.js @@ -0,0 +1,117 @@ +odoo.define('web.ListModel', function (require) { + "use strict"; + + var BasicModel = require('web.BasicModel'); + + var ListModel = BasicModel.extend({ + + /** + * @override + * @param {Object} params.groupbys + */ + init: function (parent, params) { + this._super.apply(this, arguments); + + this.groupbys = params.groupbys; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Overriden to add `groupData` when performing get on list datapoints. + * + * @override + * @see _readGroupExtraFields + */ + get: function () { + var result = this._super.apply(this, arguments); + var dp = result && this.localData[result.id]; + if (dp && dp.groupData) { + result.groupData = this.get(dp.groupData); + } + return result; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @override + * @private + */ + _readGroup: function (list) { + var self = this; + return this._super.apply(this, arguments).then(function (result) { + var prom; + if (!list.parentID) { + // groupbys buttons are only displayed on the first level of + // groupby so no need to fetch the extra fields for inner + // groups + prom = self._readGroupExtraFields(list); + } + return Promise.resolve(prom).then(_.constant(result)); + }); + }, + /** + * Fetches group specific fields on the group by relation and stores it + * in the column datapoint in a special key `groupData`. + * Data for the groups are fetched in batch for all groups, to avoid + * doing multiple calls. + * Note that the option is only for m2o fields. + * + * @private + * @param {Object} list + * @returns {Promise} + */ + _readGroupExtraFields: function (list) { + var self = this; + var groupByFieldName = list.groupedBy[0].split(':')[0]; + var groupedByField = list.fields[groupByFieldName]; + if (groupedByField.type !== 'many2one' || !this.groupbys[groupByFieldName]) { + return Promise.resolve(); + } + var groupIds = _.reduce(list.data, function (groupIds, id) { + var resId = self.get(id, { raw: true }).res_id; + if (resId) { // the field might be undefined when grouping + groupIds.push(resId); + } + return groupIds; + }, []); + var groupFields = Object.keys(this.groupbys[groupByFieldName].viewFields); + var prom; + if (groupIds.length && groupFields.length) { + prom = this._rpc({ + model: groupedByField.relation, + method: 'read', + args: [groupIds, groupFields], + context: list.context, + }); + } + return Promise.resolve(prom).then(function (result) { + var fvg = self.groupbys[groupByFieldName]; + _.each(list.data, function (id) { + var dp = self.localData[id]; + var groupData = result && _.findWhere(result, { + id: dp.res_id, + }); + var groupDp = self._makeDataPoint({ + context: dp.context, + data: groupData, + fields: fvg.fields, + fieldsInfo: fvg.fieldsInfo, + modelName: groupedByField.relation, + parentID: dp.id, + res_id: dp.res_id, + viewType: 'groupby', + }); + dp.groupData = groupDp.id; + }); + }); + }, + }); + return ListModel; +}); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index e6462d99609..670f311c195 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -31,8 +31,6 @@ var FIELD_CLASSES = { text: 'o_list_text', }; -var HEADING_COLUMNS_TO_SKIP_IN_GROUPS = 2; - var ListRenderer = BasicRenderer.extend({ events: { 'click tbody tr': '_onRowClicked', @@ -68,6 +66,7 @@ var ListRenderer = BasicRenderer.extend({ this.pagers = []; // instantiated pagers (only for grouped lists) this.editable = params.editable; this.isGrouped = this.state.groupedBy.length > 0; + this.groupbys = params.groupbys; }, //-------------------------------------------------------------------------- @@ -191,7 +190,7 @@ var ListRenderer = BasicRenderer.extend({ var self = this; self.handleField = null; this.columns = _.reject(this.arch.children, function (c) { - if (c.tag === 'control') { + if (c.tag === 'control' || c.tag === 'groupby') { return true; } var reject = c.attrs.modifiers.column_invisible; @@ -212,16 +211,12 @@ var ListRenderer = BasicRenderer.extend({ * * @private * @param {any} aggregateValues - * @param {Boolean} isHeader indicates wheter the groups rendered are on the header or on the footer * @returns {jQueryElement[]} a list of with the aggregate values */ - _renderAggregateCells: function (aggregateValues, isHeader) { + _renderAggregateCells: function (aggregateValues) { var self = this; - return _.map(this.columns, function (column, index) { - if (isHeader && index === 0) { - return; - } + return _.map(this.columns, function (column) { var $cell = $(''); if (config.debug) { $cell.addClass(column.attrs.name); @@ -384,12 +379,66 @@ var ListRenderer = BasicRenderer.extend({ aggregates[column.attrs.name] = column.aggregate; } }); - var $cells = this._renderAggregateCells(aggregates, false); + var $cells = this._renderAggregateCells(aggregates); if (this.hasSelectors) { $cells.unshift($('')); } return $('').append($('').append($cells)); }, + /** + * Renders the group button element. + * + * @private + * @param {Object} record + * @param {Object} group + * @returns {jQuery} a -
+
/ diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index d2060de9f59..0eb54bd3e0b 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -203,6 +203,7 @@ var MockServer = Class.extend({ var modifiersNames = ['invisible', 'readonly', 'required']; var onchanges = this.data[model].onchanges || {}; var fieldNodes = {}; + var groupbyNodes = {}; var doc; if (typeof arch === 'string') { @@ -220,6 +221,7 @@ var MockServer = Class.extend({ var modifiers = {}; var isField = (node.tagName === 'field'); + var isGroupby = (node.tagName === 'groupby'); if (isField) { var fieldName = node.getAttribute('name'); @@ -251,6 +253,10 @@ var MockServer = Class.extend({ modifiers[attr] = defaultValue; } }); + } else if (isGroupby && !node._isProcessed) { + var groupbyName = node.getAttribute('name'); + fieldNodes[groupbyName] = node; + groupbyNodes[groupbyName] = node; } // 'transfer_node_to_modifiers' simulation @@ -290,6 +296,10 @@ var MockServer = Class.extend({ node.setAttribute('modifiers', JSON.stringify(modifiers)); } + if (isGroupby && !node._isProcessed) { + return false; + } + return !isField; }); @@ -317,6 +327,21 @@ var MockServer = Class.extend({ node.setAttribute('on_change', "1"); } }); + _.each(groupbyNodes, function (node, name) { + var field = fields[name]; + if (field.type !== 'many2one') { + throw new Error('groupby can only target many2one'); + } + field.views = {}; + relModel = field.relation; + relFields = $.extend(true, {}, self.data[relModel].fields); + node._isProcessed = true; + // postprocess simulation + field.views.groupby = self._fieldsViewGet(node, relModel, relFields, context); + node.childNodes.forEach(function (child) { + node.removeChild(child); + }); + }); var xmlSerializer = new XMLSerializer(); var processedArch = xmlSerializer.serializeToString(doc); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index fbed65758b5..cb6a1ef805d 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -355,7 +355,7 @@ QUnit.module('Views', { }); QUnit.test('basic grouped list rendering 1 col without selector', async function (assert) { - assert.expect(1); + assert.expect(2); var list = await createView({ View: ListView, @@ -367,12 +367,14 @@ QUnit.module('Views', { }); assert.strictEqual(list.$('.o_group_header:first').children().length, 1, - "group header should have exactly 1 column"); + "group header should have exactly 1 column"); + assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "1", + "the header should span the whole table"); list.destroy(); }); QUnit.test('basic grouped list rendering 1 col with selector', async function (assert) { - assert.expect(1); + assert.expect(2); var list = await createView({ View: ListView, @@ -385,11 +387,13 @@ QUnit.module('Views', { assert.strictEqual(list.$('.o_group_header:first').children().length, 1, "group header should have exactly 1 column"); + assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2", + "the header should span the whole table"); list.destroy(); }); - QUnit.test('basic grouped list rendering 2 col without selector', async function (assert) { - assert.expect(1); + QUnit.test('basic grouped list rendering 2 cols without selector', async function (assert) { + assert.expect(2); var list = await createView({ View: ListView, @@ -400,13 +404,15 @@ QUnit.module('Views', { hasSelectors: false, }); - assert.strictEqual(list.$('.o_group_header:first').children().length, 2, - "group header should have exactly 2 column"); + assert.strictEqual(list.$('.o_group_header:first').children().length, 1, + "group header should have exactly 1 column"); + assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2", + "the header should span the whole table"); list.destroy(); }); QUnit.test('basic grouped list rendering 2 col with selector', async function (assert) { - assert.expect(1); + assert.expect(2); var list = await createView({ View: ListView, @@ -417,8 +423,40 @@ QUnit.module('Views', { hasSelectors: true, }); - assert.strictEqual(list.$('.o_group_header:first').children().length, 2, - "group header should have exactly 2 column"); + assert.strictEqual(list.$('.o_group_header:first').children().length, 1, + "group header should have exactly 1 column"); + assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "3", + "the header should span the whole table"); + list.destroy(); + }); + + QUnit.test('basic grouped list rendering 7 cols with aggregates and selector', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + groupBy: ['bar'], + }); + + assert.strictEqual(list.$('.o_group_header:first').children().length, 5, + "group header should have exactly 5 columns (one before first aggregate, one after last aggregate, and all in between"); + assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "3", + "header name should span on the two first fields + selector (colspan 3)"); + assert.containsN(list, '.o_group_header:first td', 3, + "there should be 3 tds (aggregates + fields in between)"); + assert.strictEqual(list.$('.o_group_header:first th:last').attr('colspan'), "2", + "header last cell should span on the two last fields (to give space for the pager) (colspan 2)"); list.destroy(); }); @@ -1092,7 +1130,6 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.test('properly apply onchange in simple case', async function (assert) { assert.expect(2); @@ -1721,6 +1758,159 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('groupby node with a button', async function (assert) { + assert.expect(14); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '