From 9fbb4fb44ff92cdee53454ab328c10ae66a11aa0 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Mon, 18 Mar 2019 14:51:04 +0000 Subject: [PATCH] [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 --- .../web/static/src/js/fields/basic_fields.js | 3 +- .../static/src/js/fields/relational_fields.js | 1 + .../js/views/list/list_editable_renderer.js | 87 +++++++++++++++++++ .../static/src/js/views/list/list_renderer.js | 13 +-- addons/web/static/src/scss/list_view.scss | 24 +++-- .../relational_fields/field_many2one_tests.js | 14 +-- .../relational_fields/field_one2many_tests.js | 20 ++--- .../tests/fields/relational_fields_tests.js | 21 ++--- addons/web/static/tests/views/form_tests.js | 8 +- addons/web/static/tests/views/list_tests.js | 21 +++++ doc/reference/views.rst | 4 + odoo/addons/base/rng/common.rng | 1 + 12 files changed, 172 insertions(+), 45 deletions(-) diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 4940839e612..1c5c19ebcf8 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -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'], diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 7be77cedca0..0f247ba3a8d 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -766,6 +766,7 @@ var FieldMany2One = AbstractField.extend({ }); var ListFieldMany2One = FieldMany2One.extend({ + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 9e31a188922..687f046b7e6 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -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($('', {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. 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 670f311c195..7a46e3b3cf2 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -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); }, diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 0882560cb10..fbd12c7e8a4 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -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; diff --git a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js index 4f2a3d03bed..419d7c5a4c7 100644 --- a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js @@ -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(); }); diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js index fa83e91bfbe..85975d99231 100644 --- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js @@ -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(); }); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 96bd4a4d633..406370b0a2b 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -2758,7 +2758,7 @@ QUnit.module('relational_fields', { View: FormView, model: 'partner', data: this.data, - arch:'
' + + arch: '' + '' + '' + '' + @@ -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:'' + + arch: '' + '' + '' + '' + @@ -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:'' + + arch: '' + '' + '' + '' + @@ -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(); }); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index da095a8ac4b..d0f5035e1f1 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -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(); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 83b5ebd25c5..2584c047508 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -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: '' + + '' + + '' + + '' + + '', + }); + 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); diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 51f06877da9..56dca21bf1d 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -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 ` is also valid and will diff --git a/odoo/addons/base/rng/common.rng b/odoo/addons/base/rng/common.rng index 3ec673c183e..8a2b9f6fbc1 100644 --- a/odoo/addons/base/rng/common.rng +++ b/odoo/addons/base/rng/common.rng @@ -215,6 +215,7 @@ +