[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:
committed by
Aaron Bohy
parent
2be28d4d03
commit
015958dc17
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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/*')
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user