[IMP] web,base: put buttons in list group headers

It is now possible to put buttons in the list view group headers.
When the view is grouped by a many2one field, those buttons
appear next to the header title when the group is opened.

The buttons are specified in the views in a <groupby> tag in the
list arch, with the following structure:
    <groupby name="groupedField"> <!-- must be a many2one -->
        <button type="object" name="my_method" string="Button1"/>
    </groupby>

It is also possible to add `field`, inside the `groupby` which can
be used for modifiers. These fields thus belong on the many2one
comodel, like:
    <groupby name="partner_id">
        <field name="name"/> <!-- name of partner_id -->
        <button type="object" name="my_method" string="Button1"
		attrs="{'invisible': [('name', '=', 'Georges')]}"/>
    </groupby>

These extra fields are fetched in batch when grouping on the field.

Part of task 1915702
This commit is contained in:
Martin Geubelle
2019-04-02 11:55:57 +00:00
committed by Aaron Bohy
parent 2be28d4d03
commit 015958dc17
15 changed files with 630 additions and 45 deletions
@@ -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));
@@ -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.
*
@@ -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;
});
@@ -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 <td> 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 = $('<td>');
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($('<td>'));
}
return $('<tfoot>').append($('<tr>').append($cells));
},
/**
* Renders the group button element.
*
* @private
* @param {Object} record
* @param {Object} group
* @returns {jQuery} a <button> element
*/
_renderGroupButton: function (list, node) {
var self = this;
var $button = viewUtils.renderButtonFromNode(node, {
extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
textAsTitle: !!node.attrs.icon,
});
this._handleAttributes($button, node);
this._registerModifiers(node, list.groupData, $button);
// TODO this should be moved to a handler
$button.on("click", function (e) {
e.stopPropagation();
if (node.attrs.type === 'edit') {
self.trigger_up('group_edit_button_clicked', {
record: list.groupData,
});
} else {
self.trigger_up('button_clicked', {
attrs: node.attrs,
record: list.groupData,
});
}
});
return $button;
},
/**
* Renders the group buttons.
*
* @private
* @param {Object} record
* @param {Object} group
* @returns {jQuery} a <button> element
*/
_renderGroupButtons: function (list, group) {
var self = this;
var $buttons = $();
if (list.value) {
// buttons make no sense for 'Undefined' group
group.arch.children.forEach(function (child) {
if (child.tag === 'button') {
$buttons = $buttons.add(self._renderGroupButton(list, child));
}
});
}
return $buttons;
},
/**
* Renders the pager for a given group
*
@@ -418,7 +467,7 @@ var ListRenderer = BasicRenderer.extend({
var pagerProm = pager._widgetRenderAndInsert(function () {}); // start the pager
this.defs.push(pagerProm);
var $el = $('<div>');
var $el = $('<div>');
pagerProm.then(function () {
$el.replaceWith(pager.$el);
});
@@ -434,10 +483,8 @@ var ListRenderer = BasicRenderer.extend({
* @returns {jQueryElement} a <tr> element
*/
_renderGroupRow: function (group, groupLevel) {
var aggregateValues = _.mapObject(group.aggregateValues, function (value) {
return { value: value };
});
var $cells = this._renderAggregateCells(aggregateValues, true);
var cells = [];
var name = group.value === undefined ? _t('Undefined') : group.value;
var groupBy = this.state.groupedBy[groupLevel];
if (group.fields[groupBy.split(':')[0]].type !== 'boolean') {
@@ -445,10 +492,7 @@ var ListRenderer = BasicRenderer.extend({
}
var $th = $('<th>')
.addClass('o_group_name')
.text(name + ' (' + group.count + ')');
if (this.hasSelectors) {
$th.attr('colspan', HEADING_COLUMNS_TO_SKIP_IN_GROUPS);
}
.text(name + ' (' + group.count + ')')
var $arrow = $('<span>')
.css('padding-left', (groupLevel * 20) + 'px')
.css('padding-right', '5px')
@@ -458,18 +502,55 @@ var ListRenderer = BasicRenderer.extend({
.toggleClass('fa-caret-down', group.isOpen);
}
$th.prepend($arrow);
cells.push($th);
var aggregateKeys = Object.keys(group.aggregateValues);
var aggregateValues = _.mapObject(group.aggregateValues, function (value) {
return { value: value };
});
var aggregateCells = this._renderAggregateCells(aggregateValues);
var firstAggregateIndex = _.findIndex(this.columns, function (column) {
return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name);
});
var colspanBeforeAggregate;
if (firstAggregateIndex !== -1) {
// if there are aggregates, the first $th goes until the first
// aggregate then all cells between aggregates are rendered, then
// there is a last $th for the pager
colspanBeforeAggregate = firstAggregateIndex;
var lastAggregateIndex = _.findLastIndex(this.columns, function (column) {
return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name);
});
cells = cells.concat(aggregateCells.slice(firstAggregateIndex, lastAggregateIndex + 1));
cells.push($('<th>').attr('colspan', this.columns.length - 1 - lastAggregateIndex));
} else {
colspanBeforeAggregate = this.columns.length;
}
if (this.hasSelectors) {
colspanBeforeAggregate += 1;
}
$th.attr('colspan', colspanBeforeAggregate);
if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) {
var $lastCell = cells[cells.length - 1];
var $pager = this._renderGroupPager(group);
var $lastCell = $cells[$cells.length - 1] || $th;
$lastCell.addClass('o_group_pager').append($pager);
}
if (group.isOpen && this.groupbys[groupBy]) {
var $buttons = this._renderGroupButtons(group, this.groupbys[groupBy]);
if ($buttons.length) {
var $buttonSection = $('<div>', {
class: 'o_group_buttons',
}).append($buttons);
$th.append($buttonSection);
}
}
return $('<tr>')
.addClass('o_group_header')
.toggleClass('o_group_open', group.isOpen)
.toggleClass('o_group_has_content', group.count > 0)
.data('group', group)
.append($th)
.append($cells);
.append(cells);
},
/**
* Render the content of a given opened group.
@@ -11,6 +11,7 @@ odoo.define('web.ListView', function (require) {
var BasicView = require('web.BasicView');
var core = require('web.core');
var ListModel = require('web.ListModel');
var ListRenderer = require('web.ListRenderer');
var ListController = require('web.ListController');
@@ -21,6 +22,7 @@ var ListView = BasicView.extend({
display_name: _lt('List'),
icon: 'fa-list-ul',
config: _.extend({}, BasicView.prototype.config, {
Model: ListModel,
Renderer: ListRenderer,
Controller: ListController,
}),
@@ -34,12 +36,20 @@ var ListView = BasicView.extend({
* @param {boolean} [params.hasSelectors=true]
*/
init: function (viewInfo, params) {
var self = this;
this._super.apply(this, arguments);
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.groupbys = {};
this.arch.children.forEach(function (child) {
if (child.tag === 'groupby') {
self._extractGroup(child);
}
});
this.controllerParams.editable = this.arch.attrs.editable;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
@@ -47,6 +57,7 @@ var ListView = BasicView.extend({
this.controllerParams.selectedRecords = selectedRecords;
this.rendererParams.arch = this.arch;
this.rendererParams.groupbys = this.groupbys;
this.rendererParams.hasSelectors =
'hasSelectors' in params ? params.hasSelectors : true;
this.rendererParams.editable = params.readonly ? false : this.arch.attrs.editable;
@@ -54,6 +65,8 @@ var ListView = BasicView.extend({
this.rendererParams.addCreateLine = false;
this.rendererParams.addCreateLineInGroups = this.rendererParams.editable && this.controllerParams.activeActions.create;
this.modelParams.groupbys = this.groupbys;
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.openGroupByDefault = expandGroups;
this.loadParams.type = 'list';
@@ -65,6 +78,14 @@ var ListView = BasicView.extend({
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {Object} node
*/
_extractGroup: function (node) {
var innerView = this.fields[node.attrs.name].views.groupby;
this.groupbys[node.attrs.name] = this._processFieldsView(innerView, 'groupby');
},
/**
* @override
*/
+19 -5
View File
@@ -103,12 +103,26 @@
.o_group_name {
white-space: nowrap;
}
.o_group_buttons {
display: inline-block;
> button {
background-color: transparent;
border: none;
padding-top: 0;
padding-bottom: 0;
}
}
.o_group_pager {
cursor: text;
user-select: none;
text-align: right;
padding-top: 0 !important;
padding-bottom: 0 !important;
position: relative;
.o_pager {
@include o-position-absolute(0, 16px);
cursor: text;
user-select: none;
padding-top: 3px !important;
padding-bottom: 0 !important;
}
.o_pager_previous, .o_pager_next {
max-height: 30px;
background-color: $o-list-group-header-color;
+1 -1
View File
@@ -1498,7 +1498,7 @@
</button>
</t>
<div t-name="Pager">
<div t-name="Pager" class="o_pager">
<span class="o_pager_counter">
<span class="o_pager_value"></span> / <span class="o_pager_limit"></span>
</span>
@@ -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);
+202 -12
View File
@@ -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: '<tree>' +
'<field name="datetime"/>' +
'<field name="foo"/>' +
'<field name="int_field" sum="Sum1"/>' +
'<field name="bar"/>' +
'<field name="qux" sum="Sum2"/>' +
'<field name="date"/>' +
'<field name="text"/>' +
'</tree>',
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: '<tree>' +
'<field name="foo"/>' +
'<groupby name="currency_id">' +
'<button string="Button 1" type="object" name="button_method"/>' +
'</groupby>' +
'</tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
intercepts: {
execute_action: function (ev) {
assert.deepEqual(ev.data.env.currentID, 2,
'should call with correct id');
assert.strictEqual(ev.data.env.model, 'res_currency',
'should call with correct model');
assert.strictEqual(ev.data.action_data.name, 'button_method',
"should call correct method");
assert.strictEqual(ev.data.action_data.type, 'object',
'should have correct type');
ev.data.on_success();
},
},
});
assert.verifySteps(['/web/dataset/search_read']);
assert.containsOnce(list, 'thead th:not(.o_list_record_selector)',
"there should be only one column");
await list.update({groupBy: ['currency_id']});
assert.verifySteps(['web_read_group']);
assert.containsN(list, '.o_group_header', 2,
"there should be 2 group headers");
assert.containsNone(list, '.o_group_header button', 0,
"there should be no button in the header");
await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
assert.verifySteps(['/web/dataset/search_read']);
assert.containsOnce(list, '.o_group_header button');
await testUtils.dom.click(list.$('.o_group_header:eq(0) button'));
list.destroy();
});
QUnit.test('groupby node with a button with modifiers', async function (assert) {
assert.expect(11);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree>' +
'<field name="foo"/>' +
'<groupby name="currency_id">' +
'<field name="position"/>' +
'<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
'</groupby>' +
'</tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
if (args.method === 'read' && args.model === 'res_currency') {
assert.deepEqual(args.args, [[2, 1], ['position']]);
}
return this._super.apply(this, arguments);
},
groupBy: ['currency_id'],
});
assert.verifySteps(['web_read_group', 'read']);
await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
assert.verifySteps(['/web/dataset/search_read']);
assert.containsOnce(list, '.o_group_header button.o_invisible_modifier',
"the first group (EUR) should have an invisible button");
await testUtils.dom.click(list.$('.o_group_header:eq(1)'));
assert.verifySteps(['/web/dataset/search_read']);
assert.containsN(list, '.o_group_header button', 2,
"there should be two buttons (one by header)");
assert.doesNotHaveClass(list, '.o_group_header:eq(1) button', 'o_invisible_modifier',
"the second header button should be visible");
list.destroy();
});
QUnit.test('reload list view with groupby node', async function (assert) {
assert.expect(2);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree expand="1">' +
'<field name="foo"/>' +
'<groupby name="currency_id">' +
'<field name="position"/>' +
'<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
'</groupby>' +
'</tree>',
groupBy: ['currency_id'],
});
assert.containsOnce(list, '.o_group_header button:not(.o_invisible_modifier)',
"there should be one visible button");
await list.reload({ domain: [] });
assert.containsOnce(list, '.o_group_header button:not(.o_invisible_modifier)',
"there should still be one visible button");
list.destroy();
});
QUnit.test('groupby node with edit button', async function (assert) {
assert.expect(1);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree expand="1">' +
'<field name="foo"/>' +
'<groupby name="currency_id">' +
'<button string="Button 1" type="edit" name="edit"/>' +
'</groupby>' +
'</tree>',
groupBy: ['currency_id'],
intercepts: {
do_action: function (event) {
assert.deepEqual(event.data.action, {
res_id: 2,
res_model: 'res_currency',
type: 'ir.actions.act_window',
views: [[false, 'form']],
flags: {mode: 'edit'},
}, "should trigger do_action with correct action parameter");
}
},
});
await testUtils.dom.click(list.$('.o_group_header:eq(0) button'));
list.destroy();
});
QUnit.test('list view, editable, without data', async function (assert) {
assert.expect(12);
@@ -2249,7 +2439,7 @@ QUnit.module('Views', {
await testUtils.dom.click(list.$('.o_group_header'));
assert.strictEqual(list.$('.o_group_header').css('height'), headerHeight,
"height of group header shouldn't have changed");
assert.hasClass(list.$('.o_group_header td:last'),'o_group_pager',
assert.hasClass(list.$('.o_group_header th'), 'o_group_pager',
"last cell of open group header should have classname 'o_group_header'");
assert.strictEqual(list.$('.o_group_header .o_pager_value').text(), '1-3',
"pager's value should be correct");
+1
View File
@@ -311,6 +311,7 @@
<script type="text/javascript" src="/web/static/src/js/views/kanban/search_panel.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/quick_create_form_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_editable_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_model.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_controller.js"></script>
+21
View File
@@ -333,6 +333,27 @@ Possible children elements of the list view are:
:ref:`form view <reference/views/form>` is also valid and will
be used when setting up the inline form view
``groupby``
defines custom headers (with buttons) for the current view when grouping
records on many2one fields. It is also possible to add `field`, inside the
`groupby` which can be used for modifiers. These fields thus belong on the
many2one comodel. These extra fields will be fetched in batch.
``name``
the name of a many2one field (on the current model). Custom header will be
displayed when grouping the view on this field name (only for first level).
.. code-block:: xml
<groupby name="partner_id">
<field name="name"/> <!-- name of partner_id -->
<button type="edit" name"edit" string="Edit/>
<button type="object" name="my_method" string="Button1"
attrs="{'invisible': [('name', '=', 'Georges')]}"/>
</groupby>
A special button (`type="edit"`) can be defined to open the many2one form view.
``control``
defines custom controls for the current view.
+26 -2
View File
@@ -910,6 +910,30 @@ actual arch.
if field:
orm.transfer_field_to_modifiers(field, modifiers)
elif node.tag == 'groupby':
# groupby nodes should be considered as nested view because they may
# contain fields on the comodel
field = Model._fields.get(node.get('name'))
if field:
if field.type != 'many2one':
self.raise_view_error(_('groupby can only target many2one (%(field)s') % dict(field=field.name), view_id)
attrs = fields.setdefault(node.get('name'), {})
children = False
# move all children nodes into a new node <groupby>
groupby_node = E.groupby()
for child in list(node):
node.remove(child)
groupby_node.append(child)
# validate the new node as a nested view, and associate it to the field
xarch, xfields = self.with_context(
base_model_name=model,
view_is_editable=False,
).postprocess_and_fields(field.comodel_name, groupby_node, view_id)
attrs['views'] = {'groupby': {
'arch': xarch,
'fields': xfields,
}}
elif node.tag in ('form', 'tree'):
result = Model.view_header_get(False, node.tag)
if result:
@@ -1044,7 +1068,7 @@ actual arch.
if node.tag in VIEW_TYPES:
# determine whether this view is editable
editable = editable and self._view_is_editable(node)
elif node.tag == 'field':
elif node.tag in ('field', 'groupby'):
# determine whether the field is editable
field = model._fields.get(node.get('name'))
if field:
@@ -1058,7 +1082,7 @@ actual arch.
elif key == 'attrs':
process_attrs(val, get, key, val)
if node.tag == 'field' and field and field.relational:
if node.tag in ('field', 'groupby') and field and field.relational:
if editable and not node.get('domain'):
domain = field._description_domain(self.env)
# process the field's domain as if it was in the view
+15 -1
View File
@@ -6,6 +6,20 @@
template
-->
<rng:include href="common.rng"/>
<rng:define name="groupby">
<rng:element name="groupby">
<rng:attribute name="name"/>
<rng:optional><rng:attribute name="expand"/></rng:optional>
<rng:zeroOrMore>
<rng:ref name="field"/>
</rng:zeroOrMore>
<rng:zeroOrMore>
<rng:ref name="button"/>
</rng:zeroOrMore>
</rng:element>
</rng:define>
<rng:define name="tree">
<rng:element name="tree">
<rng:ref name="overload"/>
@@ -61,7 +75,7 @@
<rng:ref name="field"/>
<rng:ref name="separator"/>
<rng:ref name="tree"/>
<rng:ref name="group"/>
<rng:ref name="groupby"/>
<rng:ref name="button"/>
<rng:ref name="filter"/>
<rng:ref name="html"/>
+58
View File
@@ -1609,6 +1609,64 @@ class TestViews(ViewCase):
'arch': arch % ('', '<field name="model"/>'),
})
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_tree_groupby(self):
arch = """
<tree>
<field name="name"/>
<groupby name="%s">
<button type="object" name="method1"/>
</groupby>
</tree>
"""
self.View.create({
'name': 'valid groupby',
'model': 'ir.ui.view',
'arch': arch % ('model_data_id'),
})
with self.assertRaises(ValidationError):
self.View.create({
'name': 'invalid groupby',
'model': 'ir.ui.view',
'arch': arch % ('type'),
})
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_tree_groupby_many2one(self):
arch = """
<tree>
<field name="name"/>
%s
<groupby name="model_data_id">
%s
<button type="object" name="method" attrs="{'invisible': [('noupdate', '=', True)]}" string="Button1"/>
</groupby>
</tree>
"""
self.View.create({
'name': 'valid groupby',
'model': 'ir.ui.view',
'arch': arch % ('', '<field name="noupdate"/>'),
})
with self.assertRaises(ValidationError):
self.View.create({
'name': 'invalid groupby',
'model': 'ir.ui.view',
'arch': arch % ('', ''),
})
with self.assertRaises(ValidationError):
self.View.create({
'name': 'invalid groupby',
'model': 'ir.ui.view',
'arch': arch % ('<field name="noupdate"/>', ''),
})
with self.assertRaises(ValidationError):
self.View.create({
'name': 'invalid groupby',
'model': 'ir.ui.view',
'arch': arch % ('', '<field name="noupdate"/><field name="fake_field"/>'),
})
class ViewModeField(ViewCase):
"""
+2 -2
View File
@@ -77,9 +77,9 @@ def valid_field_in_graph(arch):
@validate('tree')
def valid_field_in_tree(arch):
""" Children of ``tree`` view must be ``field`` or ``button`` or ``control``."""
""" Children of ``tree`` view must be ``field`` or ``button`` or ``control`` or ``groupby``."""
return all(
child.tag in ('field', 'button', 'control')
child.tag in ('field', 'button', 'control', 'groupby')
for child in arch.xpath('/tree/*')
)