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 04fbb987c12..fc026a332f0 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -58,6 +58,7 @@ var ListRenderer = BasicRenderer.extend({
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
+ this._preprocessColumns();
this.columnInvisibleFields = params.columnInvisibleFields;
this.rowDecorations = this._extractDecorationAttrs(this.arch);
this.fieldDecorations = {};
@@ -283,7 +284,62 @@ var ListRenderer = BasicRenderer.extend({
};
},
/**
- * Removes the columns which should be invisible.
+ * Adjacent buttons (in the arch) are displayed in a single column. This
+ * function iterates over the arch's nodes and replaces "button" nodes by
+ * "button_group" nodes, with a single "button_group" node for adjacent
+ * "button" nodes. A "button_group" node has a "children" attribute
+ * containing all "button" nodes in the group.
+ *
+ * @private
+ */
+ _groupAdjacentButtons: function () {
+ const children = [];
+ let groupId = 0;
+ let buttonGroupNode = null;
+ for (const c of this.arch.children) {
+ if (c.tag === 'button') {
+ if (!buttonGroupNode) {
+ buttonGroupNode = {
+ tag: 'button_group',
+ children: [c],
+ attrs: {
+ name: `button_group_${groupId++}`,
+ modifiers: {},
+ },
+ };
+ children.push(buttonGroupNode);
+ } else {
+ buttonGroupNode.children.push(c);
+ }
+ } else {
+ buttonGroupNode = null;
+ children.push(c);
+ }
+ }
+ this.arch.children = children;
+ },
+ /**
+ * Processes arch's child nodes for the needs of the list view:
+ * - detects oe_read_only/oe_edit_only classnames
+ * - groups adjacent buttons in a single column.
+ * This function is executed only once, at initialization.
+ *
+ * @private
+ */
+ _preprocessColumns: function () {
+ this._processModeClassNames();
+ this._groupAdjacentButtons();
+
+ // set as readOnly (resp. editOnly) button groups containing only
+ // readOnly (resp. editOnly) buttons, s.t. no column is rendered
+ this.arch.children.filter(c => c.tag === 'button_group').forEach(c => {
+ c.attrs.editOnly = c.children.every(n => n.attrs.editOnly);
+ c.attrs.readOnly = c.children.every(n => n.attrs.readOnly);
+ });
+ },
+ /**
+ * Removes the columns which should be invisible. This function is executed
+ * at each (re-)rendering of the list.
*
* @param {Object} columnInvisibleFields contains the column invisible modifier values
*/
@@ -308,14 +364,6 @@ var ListRenderer = BasicRenderer.extend({
if (c.attrs.name in columnInvisibleFields) {
reject = columnInvisibleFields[c.attrs.name];
}
- if (c.attrs.class) {
- if (c.attrs.class.match(/\boe_edit_only\b/)) {
- c.attrs.editOnly = true;
- }
- if (c.attrs.class.match(/\boe_read_only\b/)) {
- c.attrs.readOnly = true;
- }
- }
if (!reject && c.attrs.widget === 'handle') {
self.handleField = c.attrs.name;
if (self.isGrouped) {
@@ -343,6 +391,22 @@ var ListRenderer = BasicRenderer.extend({
}
});
},
+ /**
+ * Classnames "oe_edit_only" and "oe_read_only" aim to only display the cell
+ * in the corresponding mode. This only concerns lists inside form views
+ * (for x2many fields). This function detects the className and stores a
+ * flag on the node's attrs accordingly, to ease further computations.
+ *
+ * @private
+ */
+ _processModeClassNames: function () {
+ this.arch.children.forEach(c => {
+ if (c.attrs.class) {
+ c.attrs.editOnly = /\boe_edit_only\b/.test(c.attrs.class);
+ c.attrs.readOnly = /\boe_read_only\b/.test(c.attrs.class);
+ }
+ });
+ },
/**
* Render a list of
, with aggregates if available. It can be displayed
* in the footer, or for each open groups.
@@ -419,7 +483,7 @@ var ListRenderer = BasicRenderer.extend({
*/
_renderBodyCell: function (record, node, colIndex, options) {
var tdClassName = 'o_data_cell';
- if (node.tag === 'button') {
+ if (node.tag === 'button_group') {
tdClassName += ' o_list_button';
} else if (node.tag === 'field') {
tdClassName += ' o_field_cell';
@@ -449,8 +513,11 @@ var ListRenderer = BasicRenderer.extend({
return $td;
}
- if (node.tag === 'button') {
- return $td.append(this._renderButton(record, node));
+ if (node.tag === 'button_group') {
+ for (const buttonNode of node.children) {
+ $td.append(this._renderButton(record, buttonNode));
+ }
+ return $td;
} else if (node.tag === 'widget') {
return $td.append(this._renderWidget(record, node));
}
diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss
index 42749503829..35c0cf95ca3 100644
--- a/addons/web/static/src/scss/list_view.scss
+++ b/addons/web/static/src/scss/list_view.scss
@@ -42,8 +42,11 @@
width: 17px;
height: 17px;
}
- &.o_list_button .o_button_icon {
- margin-right: 3px;
+ &.o_list_button > button {
+ padding: 0 5px;
+ .o_button_icon {
+ margin-right: 3px;
+ }
}
}
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index a8de3aa523c..98b85b2270e 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -277,6 +277,129 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('list view with adjacent buttons', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+
+
+
+
+
+
+ `,
+ });
+
+ assert.containsN(list, 'th', 4,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and invisible field', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+
+
+
+
+
+
+ `,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and invisible field (modifier)', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+
+
+
+
+
+
+ `,
+ });
+
+ assert.containsN(list, 'th', 4,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and optional field', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+
+
+
+
+
+
+ `,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons with invisible modifier', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+
+
+
+
+
+ `,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsOnce(list.$('.o_data_row:first'), 'td.o_list_button');
+ assert.strictEqual(list.$('.o_field_cell').text(), 'yopblipgnapblip');
+ assert.containsN(list, 'td button i.fa-star:visible', 2);
+ assert.containsN(list, 'td button i.fa-refresh:visible', 3);
+ assert.containsN(list, 'td button i.fa-exclamation:visible', 3);
+
+ list.destroy();
+ });
+
QUnit.test('list view with icon buttons', async function (assert) {
assert.expect(5);
@@ -1884,7 +2007,6 @@ QUnit.module('Views', {
{ field: 'date', expected: 92, type: 'Date' },
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
- { field: 'the_button', expected: 25, type: 'with custom width' },
];
assert.expect(9);
@@ -1901,7 +2023,7 @@ QUnit.module('Views', {
'' +
'' +
'' +
- '' +
+ '' +
'',
});
@@ -1912,6 +2034,8 @@ QUnit.module('Views', {
});
assert.strictEqual(list.$('th[data-name="foo"]')[0].style.width, '100%',
"Char field should occupy the remaining space");
+ assert.strictEqual(list.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ 'Currency field should have a fixed width of 25px (see arch)');
list.destroy();
});
@@ -1924,7 +2048,6 @@ QUnit.module('Views', {
{ field: 'date', expected: 92, type: 'Date' },
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
- { field: 'the_button', expected: 25, type: 'with custom width' },
];
assert.expect(12);
@@ -1949,7 +2072,7 @@ QUnit.module('Views', {
-
+
@@ -1972,7 +2095,8 @@ QUnit.module('Views', {
});
assert.strictEqual(form.$('.o_field_one2many th[data-name="foo"]')[0].style.width, '100%',
"Char field should occupy the remaining space");
-
+ assert.strictEqual(form.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ 'Currency field should have a fixed width of 25px (see arch)');
assert.strictEqual(form.el.querySelector('.o_list_record_remove_header').style.width, '32px');
form.destroy();
@@ -2285,14 +2409,13 @@ QUnit.module('Views', {
});
QUnit.test('empty list: state with nameless and stringless buttons', async function (assert) {
- assert.expect(3);
+ assert.expect(2);
this.data.foo.records = [];
const list = await createView({
arch: `
- `,
@@ -2301,12 +2424,10 @@ QUnit.module('Views', {
View: ListView,
});
- assert.strictEqual(list.el.querySelector('th[data-name="foo"]').style.width, '25%',
+ assert.strictEqual(list.el.querySelector('th[data-name="foo"]').style.width, '50%',
"Field column should be frozen");
- assert.strictEqual(list.el.querySelector('th[data-string="choucroute"]').style.width, '25%',
- "Nameless button column should be frozen");
- assert.strictEqual(list.el.querySelector('th[data-icon="fa-heart"]').style.width, '25%',
- "Nameless and stringless button should be frozen");
+ assert.strictEqual(list.el.querySelector('th:last-child').style.width, '50%',
+ "Buttons column should be frozen");
list.destroy();
});
@@ -2354,7 +2475,6 @@ QUnit.module('Views', {
{ field: 'date', expected: 92, type: 'Date' },
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
- { field: 'the_button', expected: 25, type: 'with custom width' },
];
assert.expect(9);
@@ -2370,7 +2490,7 @@ QUnit.module('Views', {
'' +
'' +
'' +
- '' +
+ '' +
'',
groupBy: ['int_field'],
});
@@ -2382,6 +2502,8 @@ QUnit.module('Views', {
});
assert.strictEqual(list.$('th[data-name="foo"]')[0].style.width, '100%',
"Char field should occupy the remaining space");
+ assert.strictEqual(list.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ "Currency field should have a fixed width of 25px (see arch)");
list.destroy();
});
@@ -4050,15 +4172,15 @@ QUnit.module('Views', {
assert.equal(list.$('tbody tr:nth(0) td:nth(4)').html(), "",
"td that contains an invisible field should be empty");
- assert.equal(list.$('tbody tr:nth(0) td:nth(1)').html(), "",
- "td that contains an invisible button should be empty");
+ assert.hasClass(list.$('tbody tr:nth(0) td:nth(1) button'), "o_invisible_modifier",
+ "button with invisible attrs should be properly hidden");
// edit first row
await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2)'));
assert.strictEqual(list.$('tbody tr:nth(0) td:nth(4) input.o_invisible_modifier').length, 1,
"td that contains an invisible field should not be empty in edition");
- assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > button.o_invisible_modifier').length, 1,
- "td that contains an invisible button should not be empty in edition");
+ assert.hasClass(list.$('tbody tr:nth(0) td:nth(1) button'), "o_invisible_modifier",
+ "button with invisible attrs should be properly hidden");
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
// click on the invisible field's cell to edit first row