diff --git a/addons/account/static/src/js/section_and_note_fields_backend.js b/addons/account/static/src/js/section_and_note_fields_backend.js index 83c51d6fa14..267050fa07c 100644 --- a/addons/account/static/src/js/section_and_note_fields_backend.js +++ b/addons/account/static/src/js/section_and_note_fields_backend.js @@ -67,7 +67,7 @@ var SectionAndNoteListRenderer = ListRenderer.extend({ _renderView: function () { var self = this; return this._super.apply(this, arguments).then(function () { - self.$('> table').addClass('o_section_and_note_list_view'); + self.$('.o_list_table').addClass('o_section_and_note_list_view'); }); } }); diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 0248d0cec7e..ad34f7b40a8 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -1041,7 +1041,7 @@ QUnit.module('account', { $('.ui-autocomplete .ui-menu-item a:contains(Search More):eq(1)').trigger('mouseenter').trigger('click'); await testUtils.nextTick(); assert.strictEqual($('.modal').length, 1, "should open a SelectCreateDialog"); - await testUtils.dom.click($('.modal table.o_list_view td:contains(Camptocamp)')); + await testUtils.dom.click($('.modal table.o_list_table td:contains(Camptocamp)')); assert.strictEqual(widget.$('.o_input_dropdown input').val(), "Camptocamp", "the partner many2one should display Camptocamp"); widget = clientAction.widgets[2]; diff --git a/addons/hr_skills/static/src/js/resume_widget.js b/addons/hr_skills/static/src/js/resume_widget.js index 10cbfbfc874..b7a67c4ff80 100644 --- a/addons/hr_skills/static/src/js/resume_widget.js +++ b/addons/hr_skills/static/src/js/resume_widget.js @@ -159,7 +159,7 @@ var ResumeLineRenderer = AbstractGroupedOne2ManyRenderer.extend({ _render: function () { var self = this; return this._super().then(function () { - self.$el.find('table').removeClass('table-striped o_list_view_ungrouped'); + self.$el.find('table').removeClass('table-striped o_list_table_ungrouped'); self.$el.find('table').addClass('o_resume_table'); }); }, 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 aaf073eeea0..a6615cbf38c 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 @@ -574,7 +574,7 @@ ListRenderer.include({ * @returns {string} record dataPoint id */ _getRecordID: function (rowIndex) { - var $tr = this.$('table.o_list_view > tbody tr').eq(rowIndex); + var $tr = this.$('table.o_list_table > tbody tr').eq(rowIndex); return $tr.data('id'); }, /** @@ -996,6 +996,15 @@ ListRenderer.include({ // Handlers //-------------------------------------------------------------------------- + /** + * Unselect the row before adding the optional column to the listview + * + * @override + * @private + */ + _onToggleOptionalColumnDropdown: function (ev) { + this.unselectRow().then(this._super.bind(this, ev)); + }, /** * This method is called when we click on the 'Add a line' button in a groupby * list view. 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 cc1384f03be..f4468b38695 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -32,7 +32,10 @@ var FIELD_CLASSES = { }; var ListRenderer = BasicRenderer.extend({ + className: 'o_list_view', events: { + "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn", + "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown", 'click tbody tr': '_onRowClicked', 'click tbody .o_list_record_selector': '_onSelectRecord', 'click thead th.o_column_sortable': '_onSortColumn', @@ -51,6 +54,7 @@ var ListRenderer = BasicRenderer.extend({ */ init: function (parent, state, params) { this._super.apply(this, arguments); + this.columnInvisibleFields = params.columnInvisibleFields; this.rowDecorations = _.chain(this.arch.attrs) .pick(function (value, key) { return DECORATIONS.indexOf(key) >= 0; @@ -63,7 +67,7 @@ var ListRenderer = BasicRenderer.extend({ this.editable = params.editable; this.isGrouped = this.state.groupedBy.length > 0; this.groupbys = params.groupbys; - this._processColumns(params.columnInvisibleFields || {}); + this._processColumns(this.columnInvisibleFields || {}); }, //-------------------------------------------------------------------------- @@ -228,6 +232,21 @@ var ListRenderer = BasicRenderer.extend({ var n = this.columns.length; return this.hasSelectors ? n + 1 : n; }, + /** + * Returns the local storage key for stored enabled optional columns + * + * @private + * @returns {string} + */ + _getOptionalColumnsStorageKey: function () { + var self = this; + var fields = []; + _.each(this.state.fieldsInfo[this.viewType], function (field, name) { + fields.push(name + ":" + self.state.fields[name].type); + }); + fields.sort(); + return "list_optional_fields," + this.state.model + "," + this.viewType + "," + fields.join(','); + }, /** * Removes the columns which should be invisible. * @@ -236,23 +255,45 @@ var ListRenderer = BasicRenderer.extend({ _processColumns: function (columnInvisibleFields) { var self = this; this.handleField = null; - this.columns = _.reject(this.arch.children, function (c) { - if (c.tag === 'control' || c.tag === 'groupby') { - return true; - } - var reject = c.attrs.modifiers.column_invisible; - // If there is an evaluated domain for the field we override the node - // attribute to have the evaluated modifier value. - if (c.attrs.name in columnInvisibleFields) { - reject = columnInvisibleFields[c.attrs.name]; - } - if (!reject && c.attrs.widget === 'handle') { - self.handleField = c.attrs.name; - if (self.isGrouped) { - return true; + this.columns = []; + this.optionalColumns = []; + this.optionalColumnsEnabled = []; + var localStorageKey = this._getOptionalColumnsStorageKey(); + var storedOptionalColumns = this.call('local_storage', 'getItem', localStorageKey); + _.each(this.arch.children, function (c) { + if (c.tag !== 'control' && c.tag !== 'groupby') { + var reject = c.attrs.modifiers.column_invisible; + // If there is an evaluated domain for the field we override the node + // attribute to have the evaluated modifier value. + if (c.attrs.name in columnInvisibleFields) { + reject = columnInvisibleFields[c.attrs.name]; + } + + if (!reject && c.attrs.widget === 'handle') { + self.handleField = c.attrs.name; + if (self.isGrouped) { + reject = true; + } + } + + if (!reject && c.attrs.optional) { + self.optionalColumns.push(c); + var enabled; + if (storedOptionalColumns === undefined) { + enabled = c.attrs.optional === 'show' + } else { + enabled = _.contains(storedOptionalColumns, c.attrs.name); + } + if (enabled) { + self.optionalColumnsEnabled.push(c.attrs.name); + } + reject = !enabled; + } + + if (!reject) { + self.columns.push(c); } } - return reject; }); }, /** @@ -747,6 +788,46 @@ var ListRenderer = BasicRenderer.extend({ _renderRows: function () { return this.state.data.map(this._renderRow.bind(this)); }, + /** + * Render a single with dropdown menu to display optional columns of view. + * + * @private + * @returns {jQueryElement} a element + */ + _renderOptionalColumnsDropdown: function () { + var self = this; + var $optionalColumnsDropdown = $('
', { + class: 'o_optional_columns text-center dropdown', + }); + var $a = $("", { + class: "dropdown-toggle text-dark o-no-caret", + href: "#", + role: "button", + 'data-toggle': "dropdown", + 'aria-expanded': false, + }); + $a.appendTo($optionalColumnsDropdown); + var $dropdown = $("
", { + class: 'dropdown-menu dropdown-menu-right o_optional_columns_dropdown', + role: 'menu', + }); + this.optionalColumns.forEach(function (col) { + var txt = (col.attrs.string || self.state.fields[col.attrs.name].string) + + (config.debug ? (' (' + col.attrs.name + ')') : ''); + var $checkbox = dom.renderCheckbox({ + text: txt, + prop: { + name: col.attrs.name, + checked: _.contains(self.optionalColumnsEnabled, col.attrs.name), + } + }) + $dropdown.append($("
", { + class: "dropdown-item", + }).append($checkbox)); + }); + $dropdown.appendTo($optionalColumnsDropdown); + return $optionalColumnsDropdown; + }, /** * A 'selector' is the small checkbox on the left of a record in a list * view. This is rendered as an input inside a div, so we can properly @@ -798,9 +879,9 @@ var ListRenderer = BasicRenderer.extend({ this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField; this._computeAggregates(); - var $table = $('').addClass('o_list_view table table-sm table-hover table-striped'); - $table.toggleClass('o_list_view_grouped', this.isGrouped); - $table.toggleClass('o_list_view_ungrouped', !this.isGrouped); + var $table = $('
').addClass('o_list_table table table-sm table-hover table-striped'); + $table.toggleClass('o_list_table_grouped', this.isGrouped); + $table.toggleClass('o_list_table_ungrouped', !this.isGrouped); var defs = []; this.defs = defs; if (this.isGrouped) { @@ -819,7 +900,16 @@ var ListRenderer = BasicRenderer.extend({ // destroy the previously instantiated pagers, if any _.invoke(oldPagers, 'destroy'); - self.$el.addClass('table-responsive').html($table); + self.$el.html($('
', { + class: 'table-responsive', + html: $table + })); + + if (self.optionalColumns.length) { + self.$el.addClass('o_list_optional_columns') + self.$('table').append($('')); + self.$el.append(self._renderOptionalColumnsDropdown()); + } if (self.selection.length) { var $checked_rows = self.$('tr').filter(function (index, el) { @@ -914,6 +1004,45 @@ var ListRenderer = BasicRenderer.extend({ ev.stopPropagation(); } }, + /** + * When the user clicks on the checkbox in optional fields dropdown the + * column is added to listview and displayed + * + * @private + * @param {MouseEvent} ev + */ + _onToggleOptionalColumn: function (ev) { + var self = this; + ev.stopPropagation(); + var input = ev.currentTarget.querySelector('input'); + var fieldIndex = this.optionalColumnsEnabled.indexOf(input.name); + if (fieldIndex >= 0) { + this.optionalColumnsEnabled.splice(fieldIndex, 1); + } else { + this.optionalColumnsEnabled.push(input.name); + } + this.call('local_storage', 'setItem', this._getOptionalColumnsStorageKey(), this.optionalColumnsEnabled); + this._processColumns(this.columnInvisibleFields || {}); + this._renderView().then(function() { + self._onToggleOptionalColumnDropdown(ev); + }) + }, + /** + * When the user clicks on the three dots (ellipsis), toggle the optional + * fields dropdown. + * + * @private + */ + _onToggleOptionalColumnDropdown: function (ev) { + // The dropdown toggle is inside the overflow hidden container because + // the ellipsis is always in the last column, but we want the actual + // dropdown to be outside of the overflow hidden container since it + // could easily have a higher height than the table. However, separating + // the toggle and the dropdown itself is not supported by popper.js by + // default, which is why we need to toggle the dropdown manually. + ev.stopPropagation(); + this.$('.o_optional_columns .dropdown-toggle').dropdown('toggle'); + }, /** * Manages the keyboard events on the list. If the list is not editable, when the user navigates to * a cell using the keyboard, if he presses enter, enter the model represented by the line diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 45744a7a1f8..3d46aeef890 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -1,233 +1,266 @@ .o_list_view { position: relative; - cursor: pointer; - background-color: $o-view-background-color; - margin-bottom: 0px; - // Don't understand why but without those, the list view appears broken - // in both form views and standalone list views. - border-collapse: initial; - border-spacing: 0; - - thead { - color: $o-main-text-color; - border-bottom: 1px solid #cacaca; - > tr > th:not(.o_list_record_selector) { - border-left: 1px solid #dfdfdf; - @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); - - &.o_list_number { - text-align: right; - } - &.o_list_text { - white-space: pre-wrap; - } - } - - tfoot { - cursor: default; - color: $o-list-footer-color; - background-color: $o-list-footer-bg-color; - font-weight: $o-list-footer-font-weight; - border-top: 2px solid #cacaca; - border-bottom: 1px solid #cacaca; - } - - .o_column_sortable { + .o_list_table { position: relative; - user-select: none; // Prevent unwanted selection while sorting - - &::after { - font-family: FontAwesome; - content: "\f0d7"; - opacity: 0; - } - &:not(:empty)::after { - margin-left: 6px; - } - &.o-sort-up { - cursor: n-resize; - &::after { - content: "\f0d7"; - } - } - &.o-sort-down { - cursor: s-resize; - &::after { - content: "\f0d8"; - } - } - &:hover::after { - opacity: 0.3; - } - &.o-sort-up, &.o-sort-down { - color: $headings-color; - &::after { - opacity: 1; - } - } - } - - .o_list_record_selector, .o_list_record_remove, .o_handle_cell { - width: 1px; // to prevent the column to expand - } - - .o_list_record_remove button { - padding: 0px; - background: none; - border-style: none; - display: table-cell; cursor: pointer; - } + background-color: $o-view-background-color; + margin-bottom: 0px; - // Contextual classes - @each $color, $value in $theme-colors { - $safe: saturate(theme-color-level($color, 2), 10%); - @include text-emphasis-variant(".text-#{$color}", $safe); - } + // Don't understand why but without those, the list view appears broken + // in both form views and standalone list views. + border-collapse: initial; + border-spacing: 0; - // Grouped list views - tbody > tr.o_group_header { - > th, > td { - vertical-align: middle; - padding-top: ($table-cell-padding-sm*2); - padding-bottom: ($table-cell-padding-sm*2); - } - .o_group_name { - white-space: nowrap; - } - .o_group_buttons { - display: inline-block; - margin-left: 10px; - - > button { - @include o-hover-text-color($o-brand-primary, darken($o-brand-primary, 20%)); - background-color: transparent; - border: none; - padding-top: 0; - padding-bottom: 0; + thead { + color: $o-main-text-color; + border-bottom: 1px solid #cacaca; + > tr > th:not(.o_list_record_selector) { + border-left: 1px solid #dfdfdf; + @include o-text-overflow(table-cell); + &:not(.o_column_sortable):hover { + cursor: default; + } } } - .o_group_pager { + tbody > tr > td:not(.o_list_record_selector) { + @include o-text-overflow(table-cell); + + &.o_list_number { + text-align: right; + } + &.o_list_text { + white-space: pre-wrap; + } + } + + tfoot { + cursor: default; + color: $o-list-footer-color; + background-color: $o-list-footer-bg-color; + font-weight: $o-list-footer-font-weight; + border-top: 2px solid #cacaca; + border-bottom: 1px solid #cacaca; + } + + .o_column_sortable { position: relative; + user-select: none; // Prevent unwanted selection while sorting - .o_pager { - @include o-position-absolute(0, 16px); - cursor: text; - user-select: none; - padding-top: 3px !important; - padding-bottom: 0 !important; + &::after { + font-family: FontAwesome; + content: "\f0d7"; + opacity: 0; } - .o_pager_previous, .o_pager_next { - max-height: 30px; - background-color: $o-list-group-header-color; - &:hover { - background-color: darken($o-list-group-header-color, 10%); + &:not(:empty)::after { + margin-left: 6px; + } + &.o-sort-up { + cursor: n-resize; + &::after { + content: "\f0d7"; + } + } + &.o-sort-down { + cursor: s-resize; + &::after { + content: "\f0d8"; + } + } + &:hover::after { + opacity: 0.3; + } + &.o-sort-up, &.o-sort-down { + color: $headings-color; + &::after { + opacity: 1; } } } - } - tbody + tbody { - border-top: none; // Override bootstrap for grouped list views - } - &, &.table-striped { - tr:focus-within { - background-color: lighten($o-form-lightsecondary, 10%); - } - } - thead th:focus-within, tbody td:focus-within { - background-color: $o-form-lightsecondary; - outline: none; - } - - .o_data_cell.o_text_overflow, - .o_data_cell.o_invisible_modifier { - // Override display: none to keep table cells aligned. Note: cannot use - // 'initial' as it will force them to 'inline', not 'table-cell'. - display: table-cell!important; - } - - //-------------------------------------------------------------------------- - // Edition - //-------------------------------------------------------------------------- - - &.o_editable_list { - table-layout: fixed; - - .o_list_record_selector { - width: 40px; - } - .o_list_record_remove_header, .o_handle_cell { - width: 32px; + .o_list_record_selector, .o_list_record_remove, .o_handle_cell { + width: 1px; // to prevent the column to expand } - // checkboxes in editable lists should be clickable and activate the row - .o_data_row:not(.o_selected_row) .o_data_cell { - .custom-checkbox:not(.o_readonly_modifier) { - pointer-events: none; + .o_list_record_remove button { + padding: 0px; + background: none; + border-style: none; + display: table-cell; + cursor: pointer; + } + + // Contextual classes + @each $color, $value in $theme-colors { + $safe: saturate(theme-color-level($color, 2), 10%); + @include text-emphasis-variant(".text-#{$color}", $safe); + } + + // Grouped list views + tbody > tr.o_group_header { + > th, > td { + vertical-align: middle; + padding-top: ($table-cell-padding-sm*2); + padding-bottom: ($table-cell-padding-sm*2); } - } - - .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) { - position: relative; // for o_field_translate - background-color: white; - - .o_input { - border: none; - padding: 0; + .o_group_name { + white-space: nowrap; } - > .o_field_text { - vertical-align: top; - } - > .o_field_widget { - width: 100%; - > .o_external_button { - padding: 0; + .o_group_buttons { + display: inline-block; + margin-left: 10px; + + > button { + @include o-hover-text-color($o-brand-primary, darken($o-brand-primary, 20%)); + background-color: transparent; border: none; - background-color: inherit; - margin-left: 5px; - font-size: 12px; + padding-top: 0; + padding-bottom: 0; } } - > .o_field_monetary input { - width: 0; // override width: 100px and let flex makes this input grow - margin: 0 4px; - } - > .o_field_many2manytags > .o_field_many2one { - // reduce basis to prevent many2one input from going to the next line to often - flex-basis: 40px; - } - .o_input_dropdown, .o_datepicker { - > .o_input { - padding-right: 15px; // to prevent caret overlapping + .o_group_pager { + position: relative; + + .o_pager { + @include o-position-absolute(0, 16px); + cursor: text; + user-select: none; + padding-top: 3px !important; + padding-bottom: 0 !important; } - > .o_dropdown_button, .o_datepicker_button { - @include o-position-absolute(0, 0); + .o_pager_previous, .o_pager_next { + max-height: 30px; + background-color: $o-list-group-header-color; + &:hover { + background-color: darken($o-list-group-header-color, 10%); + } } } - > .o_field_translate { - @include o-position-absolute(0, 5px); - width: 16px; // to have some space on the right + } + tbody + tbody { + border-top: none; // Override bootstrap for grouped list views + } + + &, &.table-striped { + tr:focus-within { + background-color: lighten($o-form-lightsecondary, 10%); } - > .o_row_handle { - visibility: hidden; // hide sequence when editing + } + thead th:focus-within, tbody td:focus-within { + background-color: $o-form-lightsecondary; + outline: none; + } + + .o_data_cell.o_text_overflow, + .o_data_cell.o_invisible_modifier { + // Override display: none to keep table cells aligned. Note: cannot use + // 'initial' as it will force them to 'inline', not 'table-cell'. + display: table-cell!important; + } + + //-------------------------------------------------------------------------- + // Edition + //-------------------------------------------------------------------------- + + &.o_editable_list { + table-layout: fixed; + + .o_list_record_selector { + width: 40px; + } + .o_list_record_remove_header, .o_handle_cell { + width: 32px; + } + + // checkboxes in editable lists should be clickable and activate the row + .o_data_row:not(.o_selected_row) .o_data_cell { + .custom-checkbox:not(.o_readonly_modifier) { + pointer-events: none; + } + } + + .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) { + position: relative; // for o_field_translate + background-color: white; + + .o_input { + border: none; + padding: 0; + } + > .o_field_text { + vertical-align: top; + } + > .o_field_widget { + width: 100%; + > .o_external_button { + padding: 0; + border: none; + background-color: inherit; + margin-left: 5px; + font-size: 12px; + } + } + > .o_field_monetary input { + width: 0; // override width: 100px and let flex makes this input grow + margin: 0 4px; + } + > .o_field_many2manytags > .o_field_many2one { + // reduce basis to prevent many2one input from going to the next line to often + flex-basis: 40px; + } + .o_input_dropdown, .o_datepicker { + > .o_input { + padding-right: 15px; // to prevent caret overlapping + } + > .o_dropdown_button, .o_datepicker_button { + @include o-position-absolute(0, 0); + } + } + > .o_field_translate { + @include o-position-absolute(0, 5px); + width: 16px; // to have some space on the right + } + > .o_row_handle { + visibility: hidden; // hide sequence when editing + } + } + } + + .o_field_x2many_list_row_add, .o_group_field_row_add { + a:focus, a:active { + color: $link-hover-color; + outline: none; } } } - .o_field_x2many_list_row_add, .o_group_field_row_add { - a:focus, a:active { - color: $link-hover-color; - outline: none; + // Optional fields + &.o_list_optional_columns { + th:last-child { + padding-right: 15px; + } + } + + .o_optional_columns_dropdown_toggle,.o_optional_columns { + @include o-position-absolute($top: 0, $right: 0); + } + + .o_optional_columns_dropdown_toggle { + width: 10px; + text-align: center; + line-height: 30px; + } + + .o_optional_columns { + .o_optional_columns_dropdown { + margin-top: 30px; + user-select: none; + .dropdown-item { + label { + padding-left: 10px; + } + } } } } diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index b14ccefb447..7ac817af30c 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -2889,7 +2889,7 @@ QUnit.module('ActionManager', { }); await actionManager.doAction(3); - assert.doesNotHaveClass(actionManager.$('.o_list_view'), 'o_list_view_grouped', + assert.doesNotHaveClass(actionManager.$('.o_list_table'), 'o_list_table_grouped', "list view is not grouped"); // open group by dropdown @@ -2898,7 +2898,7 @@ QUnit.module('ActionManager', { // click on first link await testUtils.dom.click($('.o_control_panel .o_group_by_menu a:first')); - assert.hasClass(actionManager.$('.o_list_view'),'o_list_view_grouped', + assert.hasClass(actionManager.$('.o_list_table'),'o_list_table_grouped', 'list view is now grouped'); actionManager.destroy(); @@ -3110,7 +3110,7 @@ QUnit.module('ActionManager', { }); await actionManager.doAction(3); - assert.containsOnce(actionManager, '.o_list_view_grouped', + assert.containsOnce(actionManager, '.o_list_table_grouped', "should be grouped"); assert.containsN(actionManager, '.o_group_header', 2, "should be grouped by 'bar' (two groups) at first load"); @@ -3125,7 +3125,7 @@ QUnit.module('ActionManager', { // remove the groupby in the searchview await testUtils.dom.click($('.o_control_panel .o_searchview .o_facet_remove')); - assert.containsOnce(actionManager, '.o_list_view_grouped', + assert.containsOnce(actionManager, '.o_list_table_grouped', "should still be grouped"); assert.containsN(actionManager, '.o_group_header', 2, "should be grouped by 'bar' (two groups) at reload"); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 6f72b827cae..aec23c150bc 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1,8 +1,10 @@ odoo.define('web.relational_fields_tests', function (require) { "use strict"; +var AbstractStorageService = require('web.AbstractStorageService'); var FormView = require('web.FormView'); var ListView = require('web.ListView'); +var RamStorage = require('web.RamStorage'); var relationalFields = require('web.relational_fields'); var testUtils = require('web.test_utils'); @@ -2888,6 +2890,81 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many field in edit mode with optional fields and trash icon', async function (assert) { + assert.expect(13); + + var RamStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), + }); + + this.data.partner.records[0].p = [2]; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '', + res_id: 1, + archs: { + 'partner,false,list': '' + + '' + + '' + + '', + }, + services: { + local_storage: RamStorageService, + }, + }); + + // should have 2 columns 1 for foo and 1 for advanced dropdown + assert.containsN(form.$('.o_field_one2many'), 'th', 1, + "should be 1 th in the one2many in readonly mode"); + assert.containsOnce(form.$('.o_field_one2many table'), '.o_optional_columns_dropdown_toggle', + "should have the optional columns dropdown toggle inside the table"); + await testUtils.form.clickEdit(form); + // should have 2 columns 1 for foo and 1 for trash icon, dropdown is displayed + // on trash icon cell, no separate cell created for trash icon and advanced field dropdown + assert.containsN(form.$('.o_field_one2many'), 'th', 2, + "should be 2 th in the one2many edit mode"); + assert.containsN(form.$('.o_field_one2many'), '.o_data_row:first > td', 2, + "should be 2 cells in the one2many in edit mode"); + + await testUtils.dom.click(form.$('.o_field_one2many table .o_optional_columns_dropdown_toggle')); + assert.containsN(form.$('.o_field_one2many'), 'div.o_optional_columns div.dropdown-item:visible', 2, + "dropdown have 2 advanced field foo with checked and bar with unchecked"); + await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item:eq(1) input')); + assert.containsN(form.$('.o_field_one2many'), 'th', 3, + "should be 3 th in the one2many after enabling bar column from advanced dropdown"); + + await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item:first input')); + assert.containsN(form.$('.o_field_one2many'), 'th', 2, + "should be 2 th in the one2many after disabling foo column from advanced dropdown"); + + assert.containsN(form.$('.o_field_one2many'), 'div.o_optional_columns div.dropdown-item:visible', 2, + "dropdown is still open"); + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + assert.containsN(form.$('.o_field_one2many'), 'div.o_optional_columns div.dropdown-item:visible', 0, + "dropdown is closed"); + var $selectedRow = form.$('.o_field_one2many tr.o_selected_row'); + assert.strictEqual($selectedRow.length, 1, "should have selected row i.e. edition mode"); + + await testUtils.dom.click(form.$('.o_field_one2many table .o_optional_columns_dropdown_toggle')); + await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item:first input')); + $selectedRow = form.$('.o_field_one2many tr.o_selected_row'); + assert.strictEqual($selectedRow.length, 0, + "current edition mode discarded when selecting advanced field"); + assert.containsN(form.$('.o_field_one2many'), 'th', 3, + "should be 3 th in the one2many after re-enabling foo column from advanced dropdown"); + + // check after form reload advanced column hidden or shown are still preserved + await form.reload(); + assert.containsN(form.$('.o_field_one2many .o_list_view'), 'th', 3, + "should still have 3 th in the one2many after reloading whole form view"); + + form.destroy(); + }); + QUnit.module('TabNavigation'); QUnit.test('when Navigating to a many2one with tabs, it receives the focus and adds a new line', async function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 02121d01c5e..7a357d9b345 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -245,18 +245,18 @@ QUnit.module('Views', { }); assert.containsOnce(form, 'div.o_field_one2many:eq(0):not(.o_readonly_modifier)', "first one2many widget should not be readonly"); assert.hasClass(form.$('div.o_field_one2many:eq(1)'),'o_readonly_modifier', "second one2many widget should be readonly"); - await testUtils.dom.click(form.$('div.tab-content table.o_list_view:eq(0) tr.o_data_row td.o_data_cell:eq(0)')); - assert.strictEqual(form.$('div.tab-content table.o_list_view tr.o_selected_row input[name="foo"]').val(), "yop", + await testUtils.dom.click(form.$('div.tab-content table.o_list_table:eq(0) tr.o_data_row td.o_data_cell:eq(0)')); + assert.strictEqual(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]').val(), "yop", "first line in one2many of first tab contains yop"); - assert.strictEqual(form.$('div.tab-content table.o_list_view:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), + assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), "yop", "first line in one2many of second tab contains yop"); - await testUtils.fields.editInput(form.$('div.tab-content table.o_list_view tr.o_selected_row input[name="foo"]'), "hello"); - assert.strictEqual(form.$('div.tab-content table.o_list_view:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), "hello", + await testUtils.fields.editInput(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]'), "hello"); + assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), "hello", "first line in one2many of second tab contains hello"); - await testUtils.dom.click(form.$('div.tab-content table.o_list_view:eq(0) a:contains(Add a line)')); - assert.strictEqual(form.$('div.tab-content table.o_list_view tr.o_selected_row input[name="foo"]').val(), "My little Foo Value", + await testUtils.dom.click(form.$('div.tab-content table.o_list_table:eq(0) a:contains(Add a line)')); + assert.strictEqual(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]').val(), "My little Foo Value", "second line in one2many of first tab contains 'My little Foo Value'"); - assert.strictEqual(form.$('div.tab-content table.o_list_view:eq(1) tr.o_data_row:eq(1) td.o_data_cell:eq(0)').text(), + assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row:eq(1) td.o_data_cell:eq(0)').text(), "My little Foo Value", "first line in one2many of second tab contains hello"); form.destroy(); }); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index bbc6cf763cf..fa62c6f3a25 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -1,6 +1,7 @@ odoo.define('web.list_tests', function (require) { "use strict"; +var AbstractStorageService = require('web.AbstractStorageService'); var BasicModel = require('web.BasicModel'); var config = require('web.config'); var core = require('web.core'); @@ -9,6 +10,7 @@ var FormView = require('web.FormView'); var ListView = require('web.ListView'); var mixins = require('web.mixins'); var NotificationService = require('web.NotificationService'); +var RamStorage = require('web.RamStorage'); var testUtils = require('web.test_utils'); var widgetRegistry = require('web.widget_registry'); var Widget = require('web.Widget'); @@ -2295,7 +2297,7 @@ QUnit.module('Views', { }); await testUtils.dom.click(list.$buttons.find('.o_list_button_add')); - await testUtils.dom.click(list.$('div.table-responsive')); + await testUtils.dom.click(list.$('.o_list_view')); assert.strictEqual(createCount, 1, "should have created a record"); @@ -6134,6 +6136,302 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('list view with optional fields rendering', async function (assert) { + assert.expect(9); + + var RamStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), + }); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '' + + '' + + '', + services: { + local_storage: RamStorageService, + }, + }); + + assert.containsN(list, 'th', 3, + "should have 3 th, 1 for selector, 2 for columns"); + + assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle', + "should have the optional columns dropdown toggle inside the table"); + + // optional fields + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + assert.containsN(list, 'div.o_optional_columns div.dropdown-item', 2, + "dropdown have 2 optional field foo with checked and bar with unchecked"); + + // enable optional field + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input')); + // 5 th (1 for checkbox, 4 for columns) + assert.containsN(list, 'th', 4, "should have 4 th"); + assert.ok(list.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + + // disable optional field + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + assert.strictEqual(list.$('div.o_optional_columns div.dropdown-item:first input:checked')[0], + list.$('div.o_optional_columns div.dropdown-item [name="m2o"]')[0], + "m2o advanced field check box should be checked in dropdown"); + + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input')); + // 3 th (1 for checkbox, 2 for columns) + assert.containsN(list, 'th', 3, "should have 3 th"); + assert.notOk(list.$('th:contains(M2O field)').is(':visible'), + "should not have a visible m2o field"); //m2o field not displayed + + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + assert.notOk(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked")); + + list.destroy(); + }); + + QUnit.test('optinal fields do not disappear even after listview reload', async function (assert) { + assert.expect(7); + + var RamStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), + }); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '' + + '' + + '', + services: { + local_storage: RamStorageService, + }, + }); + + assert.containsN(list, 'th', 3, + "should have 3 th, 1 for selector, 2 for columns"); + + // enable optional field + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + assert.notOk(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked")); + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input')); + assert.containsN(list, 'th', 4, + "should have 4 th 1 for selector, 3 for columns"); + assert.ok(list.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + + // reload listview + await list.reload(); + assert.containsN(list, 'th', 4, + "should have 4 th 1 for selector, 3 for columns ever after listview reload"); + assert.ok(list.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field even after listview reload"); + + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + assert.ok(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked")); + + list.destroy(); + }); + + QUnit.test('change the viewType of the current action', async function (assert) { + assert.expect(25); + + this.actions = [{ + id: 1, + name: 'Partners Action 1', + res_model: 'foo', + type: 'ir.actions.act_window', + views: [[1, 'kanban']], + }, { + id: 2, + name: 'Partners', + res_model: 'foo', + type: 'ir.actions.act_window', + views: [[false, 'list'], [1, 'kanban']], + }]; + + this.archs = { + 'foo,1,kanban': '' + + '
' + + '
', + + 'foo,false,list': '' + + '' + + '' + + '', + + 'foo,false,search': '', + }; + + var RamStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), + }); + + var actionManager = await testUtils.createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: { + local_storage: RamStorageService, + }, + }); + await actionManager.doAction(2); + + assert.containsOnce(actionManager, '.o_list_view', + "should have rendered a list view"); + + assert.containsN(actionManager, 'th', 3, "should display 3 th (selector + 2 fields)"); + + // enable optional field + await testUtils.dom.click(actionManager.$('table .o_optional_columns_dropdown_toggle')); + assert.notOk(actionManager.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked")); + assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="o2m"]').is(":checked")); + await testUtils.dom.click(actionManager.$('div.o_optional_columns div.dropdown-item:first')); + assert.containsN(actionManager, 'th', 4, "should display 4 th (selector + 3 fields)"); + assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + + // switch to kanban view + await actionManager.loadState({ + action: 2, + view_type: 'kanban', + }); + + assert.containsNone(actionManager, '.o_list_view', + "should not display the list view anymore"); + assert.containsOnce(actionManager, '.o_kanban_view', + "should have switched to the kanban view"); + + // switch back to list view + await actionManager.loadState({ + action: 2, + view_type: 'list', + }); + + assert.containsNone(actionManager, '.o_kanban_view', + "should not display the kanban view anymoe"); + assert.containsOnce(actionManager, '.o_list_view', + "should display the list view"); + + assert.containsN(actionManager, 'th', 4, "should display 4 th"); + assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + assert.ok(actionManager.$('th:contains(O2M field)').is(':visible'), + "should have a visible o2m field"); //m2o field + + // disable optional field + await testUtils.dom.click(actionManager.$('table .o_optional_columns_dropdown_toggle')); + assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked")); + assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="o2m"]').is(":checked")); + await testUtils.dom.click(actionManager.$('div.o_optional_columns div.dropdown-item:last input')); + assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + assert.notOk(actionManager.$('th:contains(O2M field)').is(':visible'), + "should have a visible o2m field"); //m2o field + assert.containsN(actionManager, 'th', 3, "should display 3 th"); + + await actionManager.doAction(1); + + assert.containsNone(actionManager, '.o_list_view', + "should not display the list view anymore"); + assert.containsOnce(actionManager, '.o_kanban_view', + "should have switched to the kanban view"); + + await actionManager.doAction(2); + + assert.containsNone(actionManager, '.o_kanban_view', + "should not havethe kanban view anymoe"); + assert.containsOnce(actionManager, '.o_list_view', + "should display the list view"); + + assert.containsN(actionManager, 'th', 3, "should display 3 th"); + assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + assert.notOk(actionManager.$('th:contains(O2M field)').is(':visible'), + "should have a visible o2m field"); //m2o field + + actionManager.destroy(); + }); + + QUnit.test('list view with optional fields rendering and local storage mock', async function (assert) { + assert.expect(12); + + var forceLocalStorage = true; + + var Storage = RamStorage.extend({ + getItem: function (key) { + assert.step('getItem ' + key); + return forceLocalStorage ? '["m2o"]' : this._super.apply(this, arguments); + }, + setItem: function (key, value) { + assert.step('setItem ' + key + ' to ' + value); + return this._super.apply(this, arguments); + }, + }); + + var RamStorageService = AbstractStorageService.extend({ + storage: new Storage(), + }); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '' + + '', + services: { + local_storage: RamStorageService, + }, + }); + + assert.verifySteps(['getItem list_optional_fields,foo,list,foo:char,m2o:many2one,reference:reference']); + + assert.containsN(list, 'th', 3, + "should have 3 th, 1 for selector, 2 for columns"); + + assert.ok(list.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + + assert.notOk(list.$('th:contains(Reference Field)').is(':visible'), + "should not have a visible reference field"); + + // optional fields + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + assert.containsN(list, 'div.o_optional_columns div.dropdown-item', 2, + "dropdown have 2 optional fields"); + + forceLocalStorage = false; + // enable optional field + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:eq(1) input')); + + assert.verifySteps([ + 'setItem list_optional_fields,foo,list,foo:char,m2o:many2one,reference:reference to ["m2o","reference"]', + 'getItem list_optional_fields,foo,list,foo:char,m2o:many2one,reference:reference', + ]); + + // 4 th (1 for checkbox, 3 for columns) + assert.containsN(list, 'th', 4, "should have 4 th"); + + assert.ok(list.$('th:contains(M2O field)').is(':visible'), + "should have a visible m2o field"); //m2o field + + assert.ok(list.$('th:contains(Reference Field)').is(':visible'), + "should have a visible reference field"); + + list.destroy(); + }); + // TODO: write test on: // - default_get with a field not in view }); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 847bbd0f3be..558caeeaf37 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -350,7 +350,7 @@ QUnit.module('Views', { 'There should be two modals'); var $second_modal = $modals.not($modal); - await testUtils.dom.click($second_modal.find('.o_list_view.table.table-sm.table-striped.o_list_view_ungrouped .o_data_row input[type=checkbox]')); + await testUtils.dom.click($second_modal.find('.o_list_table.table.table-sm.table-striped.o_list_table_ungrouped .o_data_row input[type=checkbox]')); await testUtils.dom.click($second_modal.find('.o_select_button')); diff --git a/odoo/addons/base/rng/common.rng b/odoo/addons/base/rng/common.rng index 8a2b9f6fbc1..66ca9a6135b 100644 --- a/odoo/addons/base/rng/common.rng +++ b/odoo/addons/base/rng/common.rng @@ -261,6 +261,7 @@ + diff --git a/odoo/addons/test_main_flows/static/src/js/tour.js b/odoo/addons/test_main_flows/static/src/js/tour.js index 5113a81dc6e..f13cfb64e44 100644 --- a/odoo/addons/test_main_flows/static/src/js/tour.js +++ b/odoo/addons/test_main_flows/static/src/js/tour.js @@ -152,7 +152,7 @@ tour.register('main_flow_tour', { { // Add second component trigger: ".o_field_x2many_list_row_add > a", - extra_trigger: "body:not(:has(table.o_list_view div.o_field_widget[name='product_id'] input))", + extra_trigger: "body:not(:has(table.o_list_table div.o_field_widget[name='product_id'] input))", content: _t("Click here to add some lines."), position: "bottom", },