[IMP] web: editable list: table-layout: fixed

This rev. changes the layout of *editable* list views to a fixed
layout. This means that we are now responsible of the width of
each column. To do that, we associate with each field type a
factor, and the higher the factor is, the larger the column will
be (w.r.t. the others). This default value can be overriden in the
arch.

The fixed layout allows to remove the absolute positionning of
widgets inside editable lists (done in the next commit).

Part of task 1915702

Co-authored-by: Martin Geubelle <mge@odoo.com>
This commit is contained in:
Aaron Bohy
2019-04-02 11:55:57 +00:00
co-authored by Martin Geubelle
parent a75fa7e377
commit 9fbb4fb44f
12 changed files with 172 additions and 45 deletions
@@ -444,7 +444,6 @@ var FieldChar = InputField.extend(TranslatableFieldMixin, {
},
});
var LinkButton = AbstractField.extend({
events: _.extend({}, AbstractField.prototype.events, {
'click': '_onClick'
@@ -483,7 +482,6 @@ var LinkButton = AbstractField.extend({
event.stopPropagation();
window.open(this.value, '_blank');
},
});
var FieldDate = InputField.extend({
@@ -1164,6 +1162,7 @@ var FieldText = InputField.extend(TranslatableFieldMixin, {
*/
var HandleWidget = AbstractField.extend({
className: 'o_row_handle fa fa-arrows ui-sortable-handle',
widthFactor: 0,
tagName: 'span',
description: "",
supportedFieldTypes: ['integer'],
@@ -766,6 +766,7 @@ var FieldMany2One = AbstractField.extend({
});
var ListFieldMany2One = FieldMany2One.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -446,6 +446,41 @@ ListRenderer.include({
delete this.allFieldWidgets[recordID];
}
},
/**
* Returns the relative width according to the widget or the field type.
* @see _renderHeader
*
* @param {Object} column a `field` arch node
*/
_getColumnWidthFactor: function (column) {
if (column.attrs.width) {
// the width attribute has precedence on the width factor
return 0;
}
var fieldType = this.state.fields[column.attrs.name].type;
var widget = this.state.fieldsInfo.list[column.attrs.name].Widget.prototype;
if ('widthFactor' in widget) {
return widget.widthFactor;
}
switch (fieldType) {
case 'binary': return 1;
case 'boolean': return 0.4;
case 'char': return 1;
case 'date': return 1;
case 'datetime': return 1.5;
case 'float': return 1;
case 'html': return 3;
case 'integer': return 0.8;
case 'many2many': return 2.2;
case 'many2one': return 1.5;
case 'monetary': return 1.2;
case 'one2many': return 2.2;
case 'reference': return 1.5;
case 'selection': return 1.5;
case 'text': return 3;
default: return 1;
}
},
/**
*
* @returns {integer}
@@ -654,6 +689,30 @@ ListRenderer.include({
}
});
},
/**
* Overridden to set weights on columns for the fixed layout.
*
* @override
* @private
*/
_processColumns: function () {
this._super.apply(this, arguments);
if (this.editable) {
var self = this;
this.columns.forEach(function (column) {
if (column.attrs.width_factor) {
column.attrs.widthFactor = parseFloat(column.attrs.width_factor, 10);
} else {
if (column.tag === 'field') {
column.attrs.widthFactor = self._getColumnWidthFactor(column);
} else {
column.attrs.widthFactor = 1;
}
}
});
}
},
/**
* @override
* @returns {Promise}
@@ -708,6 +767,34 @@ ListRenderer.include({
}
return $body;
},
/**
* Override to optionally add a th in the header for the remove icon column.
*
* @override
* @private
*/
_renderHeader: function () {
var $thead = this._super.apply(this, arguments);
if (this.editable) {
var totalWidth = this.columns.reduce(function (acc, column) {
return acc + column.attrs.widthFactor;
}, 0);
this.columns.forEach(function (column) {
var $cell = $thead.find('th[data-name=' + column.attrs.name + ']');
if (column.attrs.width) {
$cell.css('width', column.attrs.width);
} else if (column.attrs.widthFactor) {
$cell.css('width', (column.attrs.widthFactor / totalWidth * 100) + '%');
}
});
}
if (this.addTrashIcon) {
$thead.find('tr').append($('<th>', {class: 'o_list_record_remove_header'}));
}
return $thead;
},
/**
* Editable rows are possibly extended with a trash icon on their right, to
* allow deleting the corresponding record.
@@ -54,7 +54,6 @@ var ListRenderer = BasicRenderer.extend({
// This attribute lets us know if there is a handle widget on a field,
// and on which field it is set.
this.handleField = null;
this._processColumns(params.columnInvisibleFields || {});
this.rowDecorations = _.chain(this.arch.attrs)
.pick(function (value, key) {
return DECORATIONS.indexOf(key) >= 0;
@@ -67,6 +66,7 @@ var ListRenderer = BasicRenderer.extend({
this.editable = params.editable;
this.isGrouped = this.state.groupedBy.length > 0;
this.groupbys = params.groupbys;
this._processColumns(params.columnInvisibleFields || {});
},
//--------------------------------------------------------------------------
@@ -188,7 +188,7 @@ var ListRenderer = BasicRenderer.extend({
*/
_processColumns: function (columnInvisibleFields) {
var self = this;
self.handleField = null;
this.handleField = null;
this.columns = _.reject(this.arch.children, function (c) {
if (c.tag === 'control' || c.tag === 'groupby') {
return true;
@@ -313,7 +313,7 @@ var ListRenderer = BasicRenderer.extend({
isPassword: 'password' in node.attrs,
});
this._handleAttributes($td, node);
return $td.html(formattedValue);
return $td.html(formattedValue).attr('title', formattedValue);
},
/**
* Renders the button element associated to the given node and record.
@@ -641,13 +641,14 @@ var ListRenderer = BasicRenderer.extend({
}
var description;
if (node.attrs.widget) {
$th.addClass(' o_' + node.attrs.widget + '_cell');
description = this.state.fieldsInfo.list[name].Widget.prototype.description;
}
if (description === undefined) {
description = node.attrs.string || field.string;
}
$th.text(description)
.data('name', name)
.attr('data-name', name)
.toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false)
.toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false)
.addClass(field.sortable && 'o_column_sortable');
@@ -669,6 +670,8 @@ var ListRenderer = BasicRenderer.extend({
attrs: node.attrs,
};
this._addFieldTooltip(fieldDescr, $th);
} else {
$th.attr('title', description);
}
return $th;
},
@@ -722,7 +725,7 @@ var ListRenderer = BasicRenderer.extend({
if (disableInput) {
$content.find("input[type='checkbox']").prop('disabled', disableInput);
}
return $('<' + tag + ' width="1">')
return $('<' + tag + '>')
.addClass('o_list_record_selector')
.append($content);
},
+19 -5
View File
@@ -21,16 +21,18 @@
thead {
color: $o-main-text-color;
border-bottom: 1px solid #cacaca;
> tr > th {
> tr > th:not(.o_list_record_selector) {
border-left: 1px solid #dfdfdf;
@include media-breakpoint-down(sm) {
white-space: nowrap;
}
@include o-text-overflow(table-cell);
&:not(.o_column_sortable):hover {
cursor: default;
}
}
}
tbody > tr > td:not(.o_list_record_selector) {
@include o-text-overflow(table-cell);
white-space: normal;
}
tfoot {
cursor: default;
@@ -76,7 +78,7 @@
}
}
.o_list_record_selector, .o_list_record_remove, .o_handle_cell {
.o_list_record_selector {
width: 1px; // to prevent the column to expand
}
@@ -161,6 +163,18 @@
//--------------------------------------------------------------------------
// Edition
//--------------------------------------------------------------------------
&.o_editable_list {
table-layout: fixed;
.o_list_record_selector {
width: 40px;
}
.o_list_record_remove_header, .o_handle_cell {
width: 32px;
}
}
.o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) {
position: relative;
@@ -2915,14 +2915,14 @@ QUnit.module('fields', {}, function () {
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_many2one[name="product_id"] input'));
await testUtils.dom.click($('li.ui-menu-item a:contains(xpad)').trigger('mouseenter'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column when the product_id is set");
await testUtils.fields.editAndTrigger(form.$('.o_field_many2one[name="product_id"] input'),
'', 'keyup');
assert.containsN(form, 'th', 2,
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns in the one2many when product_id is not set");
await testUtils.dom.click(form.$('.o_field_boolean[name="bar"] input'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column after the value change");
form.destroy();
});
@@ -2969,7 +2969,7 @@ QUnit.module('fields', {}, function () {
await testUtils.fields.editInput(form.$('.o_field_one2many input:first'), 'New line');
await testUtils.dom.click(form.$el);
assert.containsN(form, 'th', 2, "should be 2 columns('foo' + 'int_field')");
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2, "should be 2 columns('foo' + 'int_field')");
form.destroy();
});
@@ -3008,14 +3008,14 @@ QUnit.module('fields', {}, function () {
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_many2one[name="product_id"] input'));
await testUtils.dom.click($('li.ui-menu-item a:contains(xpad)').trigger('mouseenter'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column when the product_id is set");
await testUtils.fields.editAndTrigger(form.$('.o_field_many2one[name="product_id"] input'),
'', 'keyup');
assert.containsN(form, 'th', 2,
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns in the one2many when product_id is not set");
await testUtils.dom.click(form.$('.o_field_boolean[name="bar"] input'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column after the value change");
form.destroy();
});
@@ -831,10 +831,10 @@ QUnit.module('fields', {}, function () {
'The right values should be written');
}
return this._super.apply(this, arguments);
}
},
});
assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim(), "second record",
assert.deepEqual(form.$('.o_data_cell.o_many2many_tags_cell').text().trim(), "second record",
"the partner_ids should be as specified at initialization");
await testUtils.form.clickEdit(form);
@@ -843,7 +843,7 @@ QUnit.module('fields', {}, function () {
await testUtils.fields.editSelect($cell, "hop");
await testUtils.form.clickSave(form);
assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim().split(/\s+/),
assert.deepEqual(form.$('.o_data_cell.o_many2many_tags_cell').text().trim().split(/\s+/),
["second", "record", "aaa"],
'The partner_ids should have been updated');
@@ -8153,14 +8153,14 @@ QUnit.module('fields', {}, function () {
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_many2one[name="product_id"] input'));
await testUtils.dom.click($('li.ui-menu-item a:contains(xpad)').trigger('mouseenter'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column when the product_id is set");
await testUtils.fields.editAndTrigger(form.$('.o_field_many2one[name="product_id"] input'),
'', 'keyup');
assert.containsN(form, 'th', 2,
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns in the one2many when product_id is not set");
await testUtils.dom.click(form.$('.o_field_boolean[name="bar"] input'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column after the value change");
form.destroy();
});
@@ -8207,7 +8207,7 @@ QUnit.module('fields', {}, function () {
await testUtils.fields.editInput(form.$('.o_field_one2many input:first'), 'New line');
await testUtils.dom.click(form.$el);
assert.containsN(form, 'th', 2, "should be 2 columns('foo' + 'int_field')");
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2, "should be 2 columns('foo' + 'int_field')");
form.destroy();
});
@@ -8246,14 +8246,14 @@ QUnit.module('fields', {}, function () {
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_many2one[name="product_id"] input'));
await testUtils.dom.click($('li.ui-menu-item a:contains(xpad)').trigger('mouseenter'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column when the product_id is set");
await testUtils.fields.editAndTrigger(form.$('.o_field_many2one[name="product_id"] input'),
'', 'keyup');
assert.containsN(form, 'th', 2,
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns in the one2many when product_id is not set");
await testUtils.dom.click(form.$('.o_field_boolean[name="bar"] input'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column after the value change");
form.destroy();
});
@@ -2758,7 +2758,7 @@ QUnit.module('relational_fields', {
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="product_id"/>' +
@@ -2783,14 +2783,14 @@ QUnit.module('relational_fields', {
await testUtils.form.clickEdit(form);
await testUtils.fields.many2one.clickOpenDropdown("product_id");
await testUtils.fields.many2one.clickHighlightedItem("product_id");
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column when the product_id is set");
await testUtils.fields.editAndTrigger(form.$('.o_field_many2one[name="product_id"] input'),
'', 'keyup');
assert.containsN(form, 'th', 2,
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns in the one2many when product_id is not set");
await testUtils.dom.click(form.$('.o_field_boolean[name="bar"] input'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column after the value change");
form.destroy();
});
@@ -2812,7 +2812,7 @@ QUnit.module('relational_fields', {
View: FormView,
model: 'partner',
data: this.data,
arch:'<form>' +
arch: '<form>' +
'<field name="bar"/>' +
'<field name="p">' +
'<tree editable="bottom">' +
@@ -2837,7 +2837,8 @@ QUnit.module('relational_fields', {
await testUtils.fields.editInput(form.$('.o_field_one2many input:first'), 'New line');
await testUtils.dom.click(form.$el);
assert.containsN(form, 'th', 2, "should be 2 columns('foo' + 'int_field')");
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns('foo' + 'int_field')");
form.destroy();
});
@@ -2850,7 +2851,7 @@ QUnit.module('relational_fields', {
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="product_id"/>' +
@@ -2876,14 +2877,14 @@ QUnit.module('relational_fields', {
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_many2one[name="product_id"] input'));
await testUtils.fields.many2one.clickHighlightedItem("product_id");
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column when the product_id is set");
await testUtils.fields.editAndTrigger(form.$('.o_field_many2one[name="product_id"] input'),
'', 'keyup');
assert.containsN(form, 'th', 2,
assert.containsN(form, 'th:not(.o_list_record_remove_header)', 2,
"should be 2 columns in the one2many when product_id is not set");
await testUtils.dom.click(form.$('.o_field_boolean[name="bar"] input'));
assert.containsOnce(form, 'th',
assert.containsOnce(form, 'th:not(.o_list_record_remove_header)',
"should be 1 column after the value change");
form.destroy();
});
+2 -6
View File
@@ -5413,7 +5413,7 @@ QUnit.module('Views', {
});
QUnit.test('form rendering with groups with col/colspan', async function (assert) {
assert.expect(46);
assert.expect(45);
var form = await createView({
View: FormView,
@@ -5530,10 +5530,6 @@ QUnit.module('Views', {
assert.hasClass($fieldGroupFifthRowTds.eq(1),'o_td_label', "the second td should be a label td");
assert.strictEqual($fieldGroupFifthRowTds.eq(2).attr('style').substr(0, 9), "width: 50", "the third td should 50% width");
// Verify that one2many list table hasn't been impacted
assert.hasAttrValue(form.$('.o_field_one2many th:first'), 'style', undefined,
"o2m list columns should have no width harcoded");
form.destroy();
});
@@ -6744,7 +6740,7 @@ QUnit.module('Views', {
assert.strictEqual($('.modal-body .o_form_view .o_list_view .o_data_cell').text(), "yopblip",
"table has some initial order");
await testUtils.dom.click($('.modal-body .o_form_view .o_list_view th'));
await testUtils.dom.click($('.modal-body .o_form_view .o_list_view th.o_column_sortable'));
assert.strictEqual($('.modal-body .o_form_view .o_list_view .o_data_cell').text(), "blipyop",
"table is now sorted");
form.destroy();
@@ -207,6 +207,27 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('editable rendering with handle', async function (assert) {
assert.expect(5);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom">' +
'<field name="int_field" widget="handle"/>' +
'<field name="currency_id"/>' +
'<field name="m2o"/>' +
'</tree>',
});
assert.containsN(list, 'thead th', 4, "there should be 4 th");
assert.hasClass(list.$('thead th:eq(0)'), 'o_list_record_selector');
assert.hasClass(list.$('thead th:eq(1)'), 'o_handle_cell');
assert.strictEqual(list.$('thead th:eq(2)').attr('style'), "width: 50%;");
assert.strictEqual(list.$('thead th:eq(3)').attr('style'), "width: 50%;");
list.destroy();
});
QUnit.test('invisible columns are not displayed', async function (assert) {
assert.expect(1);
+4
View File
@@ -328,6 +328,10 @@ Possible children elements of the list view are:
dynamic attributes based on record values. Only effects the current
field, so e.g. ``invisible`` will hide the field but leave the same
field of other records visible, it will not hide the column itself
``width_factor`` (for ``editable``)
the column relative width (as the layout is fixed)
``width`` (for ``editable``)
the column width (as the layout is fixed)
.. note:: if the list view is ``editable``, any field attribute from the
:ref:`form view <reference/views/form>` is also valid and will
+1
View File
@@ -215,6 +215,7 @@
<rng:optional><rng:attribute name="string"/></rng:optional>
<rng:optional><rng:attribute name="completion"/></rng:optional>
<rng:optional><rng:attribute name="width"/></rng:optional>
<rng:optional><rng:attribute name="width_factor"/></rng:optional>
<rng:optional><rng:attribute name="type"/></rng:optional>
<rng:optional><rng:attribute name="ref"/></rng:optional>
<rng:optional><rng:attribute name="eval"/></rng:optional>