diff --git a/web_x2many_list_scroll/README.rst b/web_x2many_list_scroll/README.rst new file mode 100644 index 000000000000..c606e15e46f5 --- /dev/null +++ b/web_x2many_list_scroll/README.rst @@ -0,0 +1,154 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Scrollable Lists in Forms +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c7a24ce62f390f72d4c04a665b5337ede9447ab97677cb82c134966de269e480 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/19.0/web_x2many_list_scroll + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-19-0/web-19-0-web_x2many_list_scroll + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +A list of lines inside a form (order lines, invoice lines, a catalogue +of requirements...) stretches the page as it grows: past a screen of +rows the column titles and the row of totals are out of sight, and the +user scrolls the whole page up and down to read them. + +With this module the list scrolls by itself within a set number of rows, +in both directions, and its header and its totals stay in sight. The +pager of the list keeps working as before: each page scrolls within that +height. + +The number of rows is set once for the whole database, and any form can +set its own number, or switch the behaviour off, on the field. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +For the whole database +---------------------- + +Go to *Settings ▸ General Settings ▸ Lists in Forms ▸ Scrollable Lists* +and set *Visible rows*: the rows a list inside a form shows before it +scrolls. ``0`` (the default) leaves every list as it is. + +The value is kept in the system parameter +``web_x2many_list_scroll.rows`` (*Settings ▸ Technical ▸ System +Parameters*, developer mode). It is read once per session: after +changing it, reload the page. Anything that is not a positive integer +counts as ``0``. + +For one form +------------ + +On the field of the lines in the form view, add the option +``scroll_rows``: + +.. code:: xml + + + +It wins over the database setting. ``'scroll_rows': 0`` leaves that list +as it is even when the setting is on. The option only applies when the +field is shown as a list. + +Row height +---------- + +The height of the box is the configured number of rows times the height +of a row as rendered, plus the header, both measured on the list itself, +so it follows the font, the density and the device (rows are taller on a +touch screen). They are exposed as the CSS custom properties +``--o-x2many-list-scroll-row-height`` and +``--o-x2many-list-scroll-header-height`` on the field. Rows with wrapped +text take more room than the first one, so a page with such rows shows +fewer of them. + +Header above the rows +--------------------- + +Inside a form Odoo flattens the header of a list, so the tags, badges +and toggles of the rows rolling under it would paint over it. The module +lifts the header and the footer to the level of the CSS custom property +``--o-x2many-list-scroll-zindex`` (``2``). Native list widgets do not +stack above that; a custom widget that does can be tuned through that +property. + +Usage +===== + +Open a form whose lines exceed the configured rows, for instance a +purchase order or a vendor bill with many lines: the list shows the set +number of rows and scrolls within them, the column titles stay at the +top and the totals at the bottom while the rows roll. With more columns +than fit, the list also scrolls sideways within the same box. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Jarsa + +Contributors +------------ + +- Jarsa + + - Alan Ramos + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_x2many_list_scroll/__init__.py b/web_x2many_list_scroll/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/web_x2many_list_scroll/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/web_x2many_list_scroll/__manifest__.py b/web_x2many_list_scroll/__manifest__.py new file mode 100644 index 000000000000..bb5184dd2cf0 --- /dev/null +++ b/web_x2many_list_scroll/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Jarsa +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +{ + "name": "Scrollable Lists in Forms", + "summary": "Lists inside forms scroll within a set number of rows, " + "header and totals staying in sight", + "version": "19.0.1.0.0", + "author": "Jarsa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "LGPL-3", + "category": "Web", + "depends": ["web", "base_setup"], + "data": ["views/res_config_settings_views.xml"], + "assets": { + "web.assets_backend": [ + "web_x2many_list_scroll/static/src/js/x2many_field.esm.js", + "web_x2many_list_scroll/static/src/xml/x2many_field.xml", + "web_x2many_list_scroll/static/src/scss/x2many_field.scss", + ], + "web.assets_unit_tests": [ + "web_x2many_list_scroll/static/tests/*", + ], + }, + "installable": True, +} diff --git a/web_x2many_list_scroll/i18n/es.po b/web_x2many_list_scroll/i18n/es.po new file mode 100644 index 000000000000..c07622ce5672 --- /dev/null +++ b/web_x2many_list_scroll/i18n/es.po @@ -0,0 +1,83 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_x2many_list_scroll +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-17 20:02+0000\n" +"PO-Revision-Date: 2026-09-17 20:02+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: web_x2many_list_scroll +#: model:ir.model,name:web_x2many_list_scroll.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: web_x2many_list_scroll +#: model:ir.model.fields,field_description:web_x2many_list_scroll.field_ir_http__display_name +#: model:ir.model.fields,field_description:web_x2many_list_scroll.field_res_config_settings__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: web_x2many_list_scroll +#: model:ir.model,name:web_x2many_list_scroll.model_ir_http +msgid "HTTP Routing" +msgstr "Enrutamiento HTTP" + +#. module: web_x2many_list_scroll +#: model:ir.model.fields,field_description:web_x2many_list_scroll.field_ir_http__id +#: model:ir.model.fields,field_description:web_x2many_list_scroll.field_res_config_settings__id +msgid "ID" +msgstr "ID" + +#. module: web_x2many_list_scroll +#: model_terms:ir.ui.view,arch_db:web_x2many_list_scroll.res_config_settings_view_form +msgid "Lists in Forms" +msgstr "Listas en formularios" + +#. module: web_x2many_list_scroll +#: model_terms:ir.ui.view,arch_db:web_x2many_list_scroll.res_config_settings_view_form +msgid "" +"Lists of lines inside forms (order lines, invoice lines...) scroll within " +"this number of rows instead of stretching the page, so their header and " +"totals stay in sight. 0 leaves them as they are." +msgstr "" +"Las listas de líneas dentro de los formularios (líneas de pedido, de " +"factura...) hacen scroll dentro de este número de filas en vez de estirar la" +" página, así su encabezado y sus totales quedan a la vista. 0 las deja como " +"están." + +#. module: web_x2many_list_scroll +#: model:ir.model.fields,help:web_x2many_list_scroll.field_res_config_settings__web_x2many_list_scroll_rows +msgid "" +"Rows a list inside a form shows before it scrolls, with its header and its " +"totals staying in sight. 0 leaves the lists as they are. A form can set its " +"own number, or 0, on the field with options=\"{'scroll_rows': 15}\"." +msgstr "" +"Filas que muestra una lista dentro de un formulario antes de hacer scroll, " +"con su encabezado y sus totales a la vista. 0 deja las listas como están. Un" +" formulario puede fijar su propio número, o 0, en el campo con " +"options=\"{'scroll_rows': 15}\"." + +#. module: web_x2many_list_scroll +#: model_terms:ir.ui.view,arch_db:web_x2many_list_scroll.res_config_settings_view_form +msgid "Scrollable Lists" +msgstr "Listas con scroll" + +#. module: web_x2many_list_scroll +#: model:ir.model.fields,field_description:web_x2many_list_scroll.field_res_config_settings__web_x2many_list_scroll_rows +msgid "Visible Rows in Form Lists" +msgstr "Filas visibles en listas de formularios" + +#. module: web_x2many_list_scroll +#: model_terms:ir.ui.view,arch_db:web_x2many_list_scroll.res_config_settings_view_form +msgid "Visible rows" +msgstr "Filas visibles" diff --git a/web_x2many_list_scroll/models/__init__.py b/web_x2many_list_scroll/models/__init__.py new file mode 100644 index 000000000000..a054953f78d7 --- /dev/null +++ b/web_x2many_list_scroll/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_http +from . import res_config_settings diff --git a/web_x2many_list_scroll/models/ir_http.py b/web_x2many_list_scroll/models/ir_http.py new file mode 100644 index 000000000000..5ed1f6a7e951 --- /dev/null +++ b/web_x2many_list_scroll/models/ir_http.py @@ -0,0 +1,25 @@ +# Copyright 2026 Jarsa +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from odoo import models + +PARAM_ROWS = "web_x2many_list_scroll.rows" + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): + session = super().session_info() + session["web_x2many_list_scroll_rows"] = self._web_x2many_list_scroll_rows() + return session + + def _web_x2many_list_scroll_rows(self): + """Rows a list inside a form shows before it scrolls. + + 0 leaves the lists as they are. + """ + value = self.env["ir.config_parameter"].sudo().get_param(PARAM_ROWS, "0") + try: + return max(int(value), 0) + except ValueError: + return 0 diff --git a/web_x2many_list_scroll/models/res_config_settings.py b/web_x2many_list_scroll/models/res_config_settings.py new file mode 100644 index 000000000000..71b9e1790666 --- /dev/null +++ b/web_x2many_list_scroll/models/res_config_settings.py @@ -0,0 +1,18 @@ +# Copyright 2026 Jarsa +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from odoo import fields, models + +from .ir_http import PARAM_ROWS + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + web_x2many_list_scroll_rows = fields.Integer( + string="Visible Rows in Form Lists", + config_parameter=PARAM_ROWS, + help="Rows a list inside a form shows before it scrolls, with its header " + "and its totals staying in sight. 0 leaves the lists as they are. A " + "form can set its own number, or 0, on the field with " + "options=\"{'scroll_rows': 15}\".", + ) diff --git a/web_x2many_list_scroll/pyproject.toml b/web_x2many_list_scroll/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_x2many_list_scroll/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_x2many_list_scroll/readme/CONFIGURE.md b/web_x2many_list_scroll/readme/CONFIGURE.md new file mode 100644 index 000000000000..0405f090fb3f --- /dev/null +++ b/web_x2many_list_scroll/readme/CONFIGURE.md @@ -0,0 +1,40 @@ +## For the whole database + +Go to *Settings ▸ General Settings ▸ Lists in Forms ▸ Scrollable Lists* and +set *Visible rows*: the rows a list inside a form shows before it scrolls. +`0` (the default) leaves every list as it is. + +The value is kept in the system parameter `web_x2many_list_scroll.rows` +(*Settings ▸ Technical ▸ System Parameters*, developer mode). It is read once +per session: after changing it, reload the page. Anything that is not a +positive integer counts as `0`. + +## For one form + +On the field of the lines in the form view, add the option `scroll_rows`: + +```xml + +``` + +It wins over the database setting. `'scroll_rows': 0` leaves that list as it +is even when the setting is on. The option only applies when the field is +shown as a list. + +## Row height + +The height of the box is the configured number of rows times the height of a +row as rendered, plus the header, both measured on the list itself, so it +follows the font, the density and the device (rows are taller on a touch +screen). They are exposed as the CSS custom properties +`--o-x2many-list-scroll-row-height` and `--o-x2many-list-scroll-header-height` +on the field. Rows with wrapped text take more room than the first one, so a +page with such rows shows fewer of them. + +## Header above the rows + +Inside a form Odoo flattens the header of a list, so the tags, badges and +toggles of the rows rolling under it would paint over it. The module lifts the +header and the footer to the level of the CSS custom property +`--o-x2many-list-scroll-zindex` (`2`). Native list widgets do not stack above +that; a custom widget that does can be tuned through that property. diff --git a/web_x2many_list_scroll/readme/CONTRIBUTORS.md b/web_x2many_list_scroll/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..5501ed298f7c --- /dev/null +++ b/web_x2many_list_scroll/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Jarsa + - Alan Ramos \ diff --git a/web_x2many_list_scroll/readme/DESCRIPTION.md b/web_x2many_list_scroll/readme/DESCRIPTION.md new file mode 100644 index 000000000000..1bca6dd9b6e1 --- /dev/null +++ b/web_x2many_list_scroll/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +A list of lines inside a form (order lines, invoice lines, a catalogue of +requirements...) stretches the page as it grows: past a screen of rows the +column titles and the row of totals are out of sight, and the user scrolls the +whole page up and down to read them. + +With this module the list scrolls by itself within a set number of rows, in +both directions, and its header and its totals stay in sight. The pager of +the list keeps working as before: each page scrolls within that height. + +The number of rows is set once for the whole database, and any form can set +its own number, or switch the behaviour off, on the field. diff --git a/web_x2many_list_scroll/readme/USAGE.md b/web_x2many_list_scroll/readme/USAGE.md new file mode 100644 index 000000000000..5ca92a66427d --- /dev/null +++ b/web_x2many_list_scroll/readme/USAGE.md @@ -0,0 +1,5 @@ +Open a form whose lines exceed the configured rows, for instance a purchase +order or a vendor bill with many lines: the list shows the set number of rows +and scrolls within them, the column titles stay at the top and the totals at +the bottom while the rows roll. With more columns than fit, the list also +scrolls sideways within the same box. diff --git a/web_x2many_list_scroll/static/description/index.html b/web_x2many_list_scroll/static/description/index.html new file mode 100644 index 000000000000..33c261fc08d1 --- /dev/null +++ b/web_x2many_list_scroll/static/description/index.html @@ -0,0 +1,502 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Scrollable Lists in Forms

+ +

Beta License: LGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

A list of lines inside a form (order lines, invoice lines, a catalogue +of requirements…) stretches the page as it grows: past a screen of +rows the column titles and the row of totals are out of sight, and the +user scrolls the whole page up and down to read them.

+

With this module the list scrolls by itself within a set number of rows, +in both directions, and its header and its totals stay in sight. The +pager of the list keeps working as before: each page scrolls within that +height.

+

The number of rows is set once for the whole database, and any form can +set its own number, or switch the behaviour off, on the field.

+

Table of contents

+ +
+

Configuration

+
+

For the whole database

+

Go to Settings ▸ General Settings ▸ Lists in Forms ▸ Scrollable Lists +and set Visible rows: the rows a list inside a form shows before it +scrolls. 0 (the default) leaves every list as it is.

+

The value is kept in the system parameter +web_x2many_list_scroll.rows (Settings ▸ Technical ▸ System +Parameters, developer mode). It is read once per session: after +changing it, reload the page. Anything that is not a positive integer +counts as 0.

+
+
+

For one form

+

On the field of the lines in the form view, add the option +scroll_rows:

+
+<field name="order_line" options="{'scroll_rows': 15}">
+
+

It wins over the database setting. 'scroll_rows': 0 leaves that list +as it is even when the setting is on. The option only applies when the +field is shown as a list.

+
+
+

Row height

+

The height of the box is the configured number of rows times the height +of a row as rendered, plus the header, both measured on the list itself, +so it follows the font, the density and the device (rows are taller on a +touch screen). They are exposed as the CSS custom properties +--o-x2many-list-scroll-row-height and +--o-x2many-list-scroll-header-height on the field. Rows with wrapped +text take more room than the first one, so a page with such rows shows +fewer of them.

+
+
+

Header above the rows

+

Inside a form Odoo flattens the header of a list, so the tags, badges +and toggles of the rows rolling under it would paint over it. The module +lifts the header and the footer to the level of the CSS custom property +--o-x2many-list-scroll-zindex (2). Native list widgets do not +stack above that; a custom widget that does can be tuned through that +property.

+
+
+
+

Usage

+

Open a form whose lines exceed the configured rows, for instance a +purchase order or a vendor bill with many lines: the list shows the set +number of rows and scrolls within them, the column titles stay at the +top and the totals at the bottom while the rows roll. With more columns +than fit, the list also scrolls sideways within the same box.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Jarsa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/web_x2many_list_scroll/static/src/js/x2many_field.esm.js b/web_x2many_list_scroll/static/src/js/x2many_field.esm.js new file mode 100644 index 000000000000..bd48319a9a97 --- /dev/null +++ b/web_x2many_list_scroll/static/src/js/x2many_field.esm.js @@ -0,0 +1,75 @@ +/* Copyright 2026 Jarsa + * License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). */ +import {X2ManyField, x2ManyField} from "@web/views/fields/x2many/x2many_field"; +import {useEffect, useRef} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {session} from "@web/session"; + +export const SCROLL_CLASS = "o_web_x2many_list_scroll"; + +X2ManyField.props = { + ...X2ManyField.props, + scrollRows: {type: Number, optional: true}, +}; + +patch(X2ManyField.prototype, { + setup() { + super.setup(); + if (this.scrollRows) { + this.className = `${this.className} ${SCROLL_CLASS}`; + this.scrollRootRef = useRef("scrollRoot"); + // Rows are as tall as their content, their font and the device + // (a touch screen pads them more), so the height of the box comes + // from the first row and the header as rendered, not from a guess. + useEffect(() => this.measureScrollRows()); + } + }, + + measureScrollRows() { + const root = this.scrollRootRef?.el; + const row = root?.querySelector(".o_list_table tbody tr.o_data_row"); + const header = root?.querySelector(".o_list_table thead"); + if (!row || !header) { + return; + } + root.style.setProperty( + "--o-x2many-list-scroll-row-height", + `${row.offsetHeight}px` + ); + root.style.setProperty( + "--o-x2many-list-scroll-header-height", + `${header.offsetHeight}px` + ); + }, + + /** + * Rows the list shows before it scrolls: the number the view sets on the + * field, else the one of the settings. 0 means the list is left alone. + * + * @returns {Number} + */ + get scrollRows() { + if (this.props.viewMode !== "list") { + return 0; + } + const rows = + this.props.scrollRows === undefined + ? session.web_x2many_list_scroll_rows + : this.props.scrollRows; + return Number(rows) > 0 ? Number(rows) : 0; + }, + + get scrollStyle() { + return this.scrollRows ? `--o-x2many-list-scroll-rows: ${this.scrollRows}` : ""; + }, +}); + +const extractProps = x2ManyField.extractProps; +x2ManyField.extractProps = (params, dynamicInfo) => { + const props = extractProps(params, dynamicInfo); + const rows = params.options?.scroll_rows; + if (rows !== undefined) { + props.scrollRows = Number(rows); + } + return props; +}; diff --git a/web_x2many_list_scroll/static/src/scss/x2many_field.scss b/web_x2many_list_scroll/static/src/scss/x2many_field.scss new file mode 100644 index 000000000000..41577301596e --- /dev/null +++ b/web_x2many_list_scroll/static/src/scss/x2many_field.scss @@ -0,0 +1,47 @@ +// Copyright 2026 Jarsa +// License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +// The list renderer is the one element that scrolls, in both directions, so +// the header Odoo already sticks to it and the row of totals stay in sight +// while the rows roll. The height is a number of rows: the row and header +// heights are measured on the rendered list and set as custom properties on +// the field; the values here only cover the instant before that measure. +.o_field_x2many.o_web_x2many_list_scroll { + --o-x2many-list-scroll-row-height: 2.5rem; + --o-x2many-list-scroll-header-height: 2.5rem; + + .o_list_renderer { + max-height: calc( + var(--o-x2many-list-scroll-rows) * var(--o-x2many-list-scroll-row-height) + + var(--o-x2many-list-scroll-header-height) + ); + overflow: auto; + + // Sticky parts on their own compositing layer: on a touch screen the + // fling of the rows otherwise redraws the header a frame late and a + // sliver of rows shows above it. + .o_list_table thead, + .o_list_table tfoot { + will-change: transform; + } + + .o_list_table tfoot { + position: sticky; + bottom: 0; + z-index: var(--o-x2many-list-scroll-zindex); + } + } +} + +// Inside a form Odoo flattens the header of a list to z-index 0, because +// there the list never scrolls. Here it does: the rows roll under the header +// and the widgets they carry (tags, badges, toggles) paint over it, being +// later in the flow. The header and the footer go one level above any row +// content. Same specificity as the rule of Odoo, plus the class of the module. +.o_form_view .o_field_x2many.o_web_x2many_list_scroll .o_list_renderer .o_list_table { + --o-x2many-list-scroll-zindex: 2; + + thead { + z-index: var(--o-x2many-list-scroll-zindex); + } +} diff --git a/web_x2many_list_scroll/static/src/xml/x2many_field.xml b/web_x2many_list_scroll/static/src/xml/x2many_field.xml new file mode 100644 index 000000000000..e1b300bd8afc --- /dev/null +++ b/web_x2many_list_scroll/static/src/xml/x2many_field.xml @@ -0,0 +1,15 @@ + + + + + + scrollStyle + scrollRoot + + + diff --git a/web_x2many_list_scroll/static/tests/web_x2many_list_scroll.test.js b/web_x2many_list_scroll/static/tests/web_x2many_list_scroll.test.js new file mode 100644 index 000000000000..d55d68972aba --- /dev/null +++ b/web_x2many_list_scroll/static/tests/web_x2many_list_scroll.test.js @@ -0,0 +1,86 @@ +/* Copyright 2026 Jarsa + * License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). */ +import { + defineModels, + fields, + models, + mountView, + patchWithCleanup, +} from "@web/../tests/web_test_helpers"; +import {describe, expect, test} from "@odoo/hoot"; +import {queryOne} from "@odoo/hoot-dom"; +import {session} from "@web/session"; + +describe.current.tags("desktop"); + +class Order extends models.Model { + line_ids = fields.One2many({ + string: "Lines", + relation: "line", + relation_field: "order_id", + }); + _records = [{id: 1, line_ids: [1, 2, 3]}]; +} +class Line extends models.Model { + order_id = fields.Many2one({relation: "order"}); + name = fields.Char(); + _records = [ + {id: 1, order_id: 1, name: "a"}, + {id: 2, order_id: 1, name: "b"}, + {id: 3, order_id: 1, name: "c"}, + ]; +} +defineModels([Order, Line]); + +const LINES = ``; + +async function mountOrder(fieldAttrs = "") { + await mountView({ + type: "form", + resModel: "order", + resId: 1, + arch: `
${LINES}
`, + }); + return queryOne(".o_field_x2many"); +} + +test("the setting makes the list scroll within its rows", async () => { + patchWithCleanup(session, {web_x2many_list_scroll_rows: 12}); + const field = await mountOrder(); + expect(field).toHaveClass("o_web_x2many_list_scroll"); + expect(field.style.getPropertyValue("--o-x2many-list-scroll-rows")).toBe("12"); + const header = field.querySelector(".o_list_table thead"); + expect(getComputedStyle(header).position).toBe("sticky"); + expect(field.style.getPropertyValue("--o-x2many-list-scroll-row-height")).toMatch( + /^\d+px$/, + { + message: "the row height is measured on the rendered list", + } + ); + expect( + field.style.getPropertyValue("--o-x2many-list-scroll-header-height") + ).toMatch(/^\d+px$/); + expect(getComputedStyle(header).zIndex).toBe("2", { + message: "the header paints over the widgets of the rows rolling under it", + }); +}); + +test("without the setting the list is left alone", async () => { + patchWithCleanup(session, {web_x2many_list_scroll_rows: 0}); + const field = await mountOrder(); + expect(field).not.toHaveClass("o_web_x2many_list_scroll"); + expect(field.style.getPropertyValue("--o-x2many-list-scroll-rows")).toBe(""); +}); + +test("the view sets its own number of rows", async () => { + patchWithCleanup(session, {web_x2many_list_scroll_rows: 12}); + const field = await mountOrder(`options="{'scroll_rows': 5}"`); + expect(field).toHaveClass("o_web_x2many_list_scroll"); + expect(field.style.getPropertyValue("--o-x2many-list-scroll-rows")).toBe("5"); +}); + +test("the view turns it off with zero", async () => { + patchWithCleanup(session, {web_x2many_list_scroll_rows: 12}); + const field = await mountOrder(`options="{'scroll_rows': 0}"`); + expect(field).not.toHaveClass("o_web_x2many_list_scroll"); +}); diff --git a/web_x2many_list_scroll/tests/__init__.py b/web_x2many_list_scroll/tests/__init__.py new file mode 100644 index 000000000000..20ebb4a89431 --- /dev/null +++ b/web_x2many_list_scroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_settings diff --git a/web_x2many_list_scroll/tests/test_settings.py b/web_x2many_list_scroll/tests/test_settings.py new file mode 100644 index 000000000000..0e3d1628c0b5 --- /dev/null +++ b/web_x2many_list_scroll/tests/test_settings.py @@ -0,0 +1,24 @@ +# Copyright 2026 Jarsa +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from odoo.tests.common import TransactionCase + + +class TestSettings(TransactionCase): + def test_the_setting_reaches_the_session(self): + http = self.env["ir.http"] + self.assertEqual(http._web_x2many_list_scroll_rows(), 0, "off until set") + settings = self.env["res.config.settings"].create( + {"web_x2many_list_scroll_rows": 12} + ) + settings.execute() + self.assertEqual(http._web_x2many_list_scroll_rows(), 12) + self.env["ir.config_parameter"].sudo().set_param( + "web_x2many_list_scroll.rows", "-3" + ) + self.assertEqual( + http._web_x2many_list_scroll_rows(), 0, "a negative value is off" + ) + self.env["ir.config_parameter"].sudo().set_param( + "web_x2many_list_scroll.rows", "many" + ) + self.assertEqual(http._web_x2many_list_scroll_rows(), 0, "garbage is off") diff --git a/web_x2many_list_scroll/views/res_config_settings_views.xml b/web_x2many_list_scroll/views/res_config_settings_views.xml new file mode 100644 index 000000000000..0d8af6c74cf2 --- /dev/null +++ b/web_x2many_list_scroll/views/res_config_settings_views.xml @@ -0,0 +1,40 @@ + + + + + res.config.settings.view.form.inherit.web_x2many_list_scroll + res.config.settings + + + + + +
+
+
+
+
+
+
+
+
+