details:   https://code.tryton.org/tryton/commit/c614e3548afb
branch:    default
user:      Cédric Krier <[email protected]>
date:      Wed May 13 15:08:23 2026 +0200
description:
        Store column optional per occurrence

        Closes #14832
diffstat:

 sao/src/view/tree.js                                   |  34 ++---
 tryton/tryton/gui/window/view_form/view/list.py        |  33 +++---
 trytond/trytond/ir/message.xml                         |   4 +
 trytond/trytond/ir/ui/view.py                          |  92 ++++++++++++++---
 trytond/trytond/ir/view/ui_view_tree_optional_form.xml |   5 +-
 trytond/trytond/ir/view/ui_view_tree_optional_list.xml |   1 +
 trytond/trytond/model/modelview.py                     |  20 +--
 7 files changed, 124 insertions(+), 65 deletions(-)

diffs (426 lines):

diff -r b33a101ac883 -r c614e3548afb sao/src/view/tree.js
--- a/sao/src/view/tree.js      Mon May 11 16:01:30 2026 +0200
+++ b/sao/src/view/tree.js      Wed May 13 15:08:23 2026 +0200
@@ -72,8 +72,7 @@
             this.view.columns.push(column);
 
             if (attributes.optional && (name !== this.exclude_field)) {
-                Sao.setdefault(
-                    this.view.optionals, column.attributes.name, 
[]).push(column);
+                this.view.optionals.push(column);
             }
 
             if (parseInt(attributes.sum || '0', 10)) {
@@ -112,7 +111,7 @@
         display_size: null,
         init: function(view_id, screen, xml, children_field) {
             this.children_field = children_field;
-            this.optionals = {};
+            this.optionals = [];
             this.sum_widgets = new Map();
             this.columns = [];
             this.selection_mode = (screen.attributes.selection_mode ||
@@ -365,19 +364,18 @@
         },
         tree_menu: function(evt) {
             const toggle = evt => {
-                let columns = evt.data;
+                let column = evt.data;
                 let visible = jQuery(evt.delegateTarget).prop('checked');
-                columns.forEach(c => c.set_visible(visible));
+                column.set_visible(visible);
                 this.save_optional();
                 this.display();
                 this.update_visible();
             };
             var menu = evt.data;
             menu.empty();
-            for (let columns of Object.values(this.optionals)) {
-                let visible = columns.some(c => c.get_visible());
-                let string = [...new Set(columns.map(c => 
c.attributes.string))]
-                    .join(' / ');
+            for (let column of this.optionals) {
+                let visible = column.get_visible();
+                let string = column.attributes.string;
                 menu.append(jQuery('<li/>', {
                     'role': 'presentation',
                 }).append(jQuery('<a/>', {
@@ -388,7 +386,7 @@
                 }).append(jQuery('<input/>', {
                     'type': 'checkbox',
                     'checked': visible,
-                }).change(columns, toggle))
+                }).change(column, toggle))
                     .append(' ' + string)))));
             }
             if (!jQuery.isEmptyObject(this.optionals)) {
@@ -449,12 +447,9 @@
                 })));
         },
         save_optional: function(store=true) {
-            if (jQuery.isEmptyObject(this.optionals)) {
-                return;
-            }
-            var fields = {};
-            for (let [name, columns] of Object.entries(this.optionals)) {
-                fields[name] = columns.every(c => !c.get_visible());
+            let fields = {};
+            for (let column of this.optionals) {
+                Sao.setdefault(fields, column.attributes.name, 
[]).push(!column.get_visible());
             }
             if (store) {
                 var tree_optional_model = new Sao.Model(
@@ -1042,7 +1037,7 @@
             domain = inversion.simplify(domain);
             var decoder = new Sao.PYSON.Decoder(this.screen.context);
             var min_width = [];
-            var tree_column_optional = (
+            var tree_column_optional = structuredClone(
                 Sao.Screen.tree_column_optional[this.view_id] || {});
             for (const column of this.columns) {
                 visible_columns += 1;
@@ -1053,8 +1048,9 @@
                 var optional;
                 if ((column.attributes.optional) &&
                     Object.prototype.hasOwnProperty.call(
-                        tree_column_optional, name)) {
-                    optional = tree_column_optional[name];
+                        tree_column_optional, name) &&
+                    !jQuery.isEmptyObject(tree_column_optional[name])) {
+                    optional = tree_column_optional[name].shift();
                 } else {
                     optional = Boolean(parseInt(
                         column.attributes.optional || '0', 10));
diff -r b33a101ac883 -r c614e3548afb 
tryton/tryton/gui/window/view_form/view/list.py
--- a/tryton/tryton/gui/window/view_form/view/list.py   Mon May 11 16:01:30 
2026 +0200
+++ b/tryton/tryton/gui/window/view_form/view/list.py   Wed May 13 15:08:23 
2026 +0200
@@ -1,5 +1,7 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
+
+import copy
 import csv
 import gettext
 import json
@@ -369,7 +371,7 @@
         self.view.treeview.append_column(column)
 
         if 'optional' in attributes and name != self.exclude_field:
-            self.view.optionals[column.name].append(column)
+            self.view.optionals.append(column)
 
     def _parse_button(self, node, attributes):
         if self.view.screen.screen_readonly:
@@ -499,7 +501,7 @@
 
     def __init__(self, view_id, screen, xml, children_field):
         self.children_field = children_field
-        self.optionals = defaultdict(list)
+        self.optionals = []
         self.sum_widgets = []
         self.sum_box = Gtk.HBox()
         self.hbuttonbox = Gtk.HButtonBox()
@@ -590,20 +592,19 @@
             self.treeview.append_column(column)
 
     def optional_menu(self, column):
-        def toggle(menuitem, columns):
+        def toggle(menuitem, column):
             visible = menuitem.get_active()
-            for column in columns:
-                column.set_visible(visible)
+            column.set_visible(visible)
             self.save_optional()
 
         widget = column.get_widget()
         menu = Gtk.Menu()
-        for name, columns in self.optionals.items():
-            visible = any(c.get_visible() for c in columns)
-            title = ' / '.join({c.get_title() for c in columns})
+        for column in self.optionals:
+            visible = column.get_visible()
+            title = column.get_title()
             menuitem = Gtk.CheckMenuItem(label=title)
             menuitem.set_active(visible)
-            menuitem.connect('toggled', toggle, columns)
+            menuitem.connect('toggled', toggle, column)
             menu.add(menuitem)
         if self.optionals:
             menu.add(Gtk.SeparatorMenuItem())
@@ -613,9 +614,9 @@
         popup(menu, widget)
 
     def save_optional(self):
-        fields = {}
-        for name, columns in self.optionals.items():
-            fields[name] = all(not c.get_visible() for c in columns)
+        fields = defaultdict(list)
+        for column in self.optionals:
+            fields[column.name].append(not column.get_visible())
         try:
             RPCExecute(
                 'model', 'ir.ui.view_tree_optional', 'set_optional',
@@ -1287,8 +1288,8 @@
             domain.append(tab_domain)
         domain = simplify(domain)
         decoder = PYSONDecoder(self.screen.context)
-        tree_column_optional = self.screen.tree_column_optional.get(
-            self.view_id, {})
+        tree_column_optional = copy.deepcopy(
+            self.screen.tree_column_optional.get(self.view_id, {}))
         for column in self.treeview.get_columns():
             name = column.name
             if not name:
@@ -1296,8 +1297,8 @@
             widget = self.get_column_widget(column)
             widget.set_editable()
             if ('optional' in widget.attrs
-                    and column.name in tree_column_optional):
-                optional = tree_column_optional[column.name]
+                    and tree_column_optional.get(column.name)):
+                optional = tree_column_optional[column.name].pop(0)
             else:
                 optional = bool(int(widget.attrs.get('optional', '0')))
             invisible = decoder.decode(widget.attrs.get('tree_invisible', '0'))
diff -r b33a101ac883 -r c614e3548afb trytond/trytond/ir/message.xml
--- a/trytond/trytond/ir/message.xml    Mon May 11 16:01:30 2026 +0200
+++ b/trytond/trytond/ir/message.xml    Wed May 13 15:08:23 2026 +0200
@@ -294,6 +294,10 @@
             <field name="text">A user can set only one width per occurrence of 
field.</field>
         </record>
 
+        <record model="ir.message" 
id="msg_view_tree_optional_field_occurrence_user_unique">
+            <field name="text">A user can set only one optional per occurrence 
of field on the same view.</field>
+        </record>
+
         <record model="ir.message" id="msg_action_wrong_wizard_model">
             <field name="text">Wrong wizard model in keyword action 
"%(name)s".</field>
         </record>
diff -r b33a101ac883 -r c614e3548afb trytond/trytond/ir/ui/view.py
--- a/trytond/trytond/ir/ui/view.py     Mon May 11 16:01:30 2026 +0200
+++ b/trytond/trytond/ir/ui/view.py     Wed May 13 15:08:23 2026 +0200
@@ -16,7 +16,7 @@
 from trytond.cache import Cache, MemoryCache
 from trytond.i18n import gettext
 from trytond.model import (
-    Exclude, Index, ModelSQL, ModelView, fields, sequence_ordered)
+    Exclude, Index, ModelSQL, ModelView, Unique, fields, sequence_ordered)
 from trytond.model.exceptions import ValidationError
 from trytond.pool import Pool
 from trytond.pyson import PYSON, Bool, Eval, If, PYSONDecoder
@@ -466,7 +466,11 @@
     __name__ = 'ir.ui.view_tree_width'
     model = fields.Char('Model', required=True)
     field = fields.Char('Field', required=True)
-    occurrence = fields.Integer("Occurrence", required=True)
+    occurrence = fields.Integer(
+        "Occurrence", required=True,
+        domain=[
+            ('occurrence', '>', 0),
+            ])
     user = fields.Many2One('res.user', 'User', required=True,
         ondelete='CASCADE')
     screen_width = fields.Integer(
@@ -611,7 +615,7 @@
         for tree_width in records:
             if tree_width.screen_width == screen_width:
                 index = tree_width.occurrence - 1
-                if index <= len(fields[tree_width.field]):
+                if index < len(fields[tree_width.field]):
                     width = fields[tree_width.field][index]
                     fields[tree_width.field][index] = None
                     if width is not None:
@@ -671,6 +675,11 @@
         'res.user', "User", required=True, ondelete='CASCADE')
     model = fields.Char("Model", required=True)
     field = fields.Char("Field", required=True)
+    occurrence = fields.Integer(
+        "Occurrence", required=True,
+        domain=[
+            ('occurrence', '>', 0),
+            ])
     value = fields.Boolean("Value")
 
     @classmethod
@@ -680,6 +689,12 @@
                 'set_optional': RPC(readonly=False),
                 })
         table = cls.__table__()
+        cls._sql_constraints += [
+            ('model_field_occurrence_user_unique',
+                Unique(table,
+                    table.view, table.field, table.occurrence, table.user),
+                'ir.msg_view_tree_optional_field_occurrence_user_unique'),
+            ]
         cls._sql_indexes.add(
             Index(
                 table,
@@ -707,6 +722,10 @@
                 where=table.model == Null))
 
     @classmethod
+    def default_occurrence(cls):
+        return 1
+
+    @classmethod
     def validate_fields(cls, records, fields_names):
         super().validate_fields(records, fields_names)
         cls.check_view(records, fields_names)
@@ -727,8 +746,33 @@
         ModelView._fields_view_get_cache.clear()
 
     @classmethod
+    def get_optional(cls, view_id):
+        user = Transaction().user
+
+        records = cls.search([
+                ('view', '=', view_id),
+                ('user', '=', user),
+                ],
+            order=[('occurrence', 'ASC')])
+
+        optionals = defaultdict(list)
+        for optional in records:
+            if len(optionals[optional.field]) + 1 < optional.occurrence:
+                for _ in range(
+                        optional.occurrence
+                        - len(optionals[optional.field]) - 1):
+                    optionals[optional.field].append(None)
+            optionals[optional.field].insert(
+                optional.occurrence, optional.value)
+        return optionals
+
+    @classmethod
     def set_optional(cls, view_id, fields):
-        "Store optional field that must be displayed"
+        '''
+        Store optional fields that must be displayed.
+        fields is dictionary with field name as key and a list of boolean as
+        value.
+        '''
         pool = Pool()
         View = pool.get('ir.ui.view')
         user = Transaction().user
@@ -736,20 +780,32 @@
         records = cls.search([
                 ('view', '=', view.id),
                 ('user', '=', user),
-                ('field', 'in', list(fields)),
-                ])
-        cls.delete(records)
-        to_create = []
-        for field, value in fields.items():
-            to_create.append({
-                    'view': view,
-                    'user': user,
-                    'model': view.model,
-                    'field': field,
-                    'value': bool(value),
-                    })
-        if to_create:
-            cls.create(to_create)
+                ('field', 'in', list(fields.keys())),
+                ],
+            order=[('occurrence', 'DESC')])
+
+        fields = copy.deepcopy(fields)
+        to_save = []
+
+        for tree_optional in records:
+            index = tree_optional.occurrence - 1
+            if index < len(fields[tree_optional.field]):
+                tree_optional.value = fields[tree_optional.field][index]
+                fields[tree_optional.field][index] = None
+                to_save.append(tree_optional)
+
+        for name, optionals in fields.items():
+            for occurrence, optional in enumerate(optionals, start=1):
+                if optional is not None:
+                    to_save.append(cls(
+                            view=view,
+                            user=user,
+                            model=view.model,
+                            field=name,
+                            occurrence=occurrence,
+                            value=optional))
+        if to_save:
+            cls.save(to_save)
 
 
 class ViewTreeState(
diff -r b33a101ac883 -r c614e3548afb 
trytond/trytond/ir/view/ui_view_tree_optional_form.xml
--- a/trytond/trytond/ir/view/ui_view_tree_optional_form.xml    Mon May 11 
16:01:30 2026 +0200
+++ b/trytond/trytond/ir/view/ui_view_tree_optional_form.xml    Wed May 13 
15:08:23 2026 +0200
@@ -10,7 +10,10 @@
     <label name="model_ref"/>
     <field name="model_ref"/>
     <label name="field_ref"/>
-    <field name="field_ref"/>
+    <group id="field" col="-1">
+        <field name="field_ref"/>
+        <field name="occurrence"/>
+    </group>
 
     <label name="value"/>
     <field name="value"/>
diff -r b33a101ac883 -r c614e3548afb 
trytond/trytond/ir/view/ui_view_tree_optional_list.xml
--- a/trytond/trytond/ir/view/ui_view_tree_optional_list.xml    Mon May 11 
16:01:30 2026 +0200
+++ b/trytond/trytond/ir/view/ui_view_tree_optional_list.xml    Wed May 13 
15:08:23 2026 +0200
@@ -6,5 +6,6 @@
     <field name="user" expand="1"/>
     <field name="model_ref" expand="1"/>
     <field name="field_ref" expand="1"/>
+    <field name="occurrence" optional="1"/>
     <field name="value"/>
 </tree>
diff -r b33a101ac883 -r c614e3548afb trytond/trytond/model/modelview.py
--- a/trytond/trytond/model/modelview.py        Mon May 11 16:01:30 2026 +0200
+++ b/trytond/trytond/model/modelview.py        Wed May 13 15:08:23 2026 +0200
@@ -494,7 +494,6 @@
                     if field._type in {'one2many', 'many2many'}})
 
         if type == 'tree':
-            user = Transaction().user
             width, _ = Transaction().context.get('screen_size', (None, None))
             if Transaction().context.get('view_tree_width'):
                 ViewTreeWidth = pool.get('ir.ui.view_tree_width')
@@ -502,11 +501,7 @@
 
             if view_id:
                 ViewTreeOptional = pool.get('ir.ui.view_tree_optional')
-                viewtreeoptionals = ViewTreeOptional.search([
-                        ('view', '=', view_id),
-                        ('user', '=', user),
-                        ])
-                fields_optional = {o.field: o.value for o in viewtreeoptionals}
+                fields_optional = ViewTreeOptional.get_optional(view_id)
 
         fields_def = cls.__parse_fields(
             tree_root, type,
@@ -571,7 +566,7 @@
         if fields_width is None:
             fields_width = collections.defaultdict(list)
         if fields_optional is None:
-            fields_optional = {}
+            fields_optional = collections.defaultdict(list)
         if _fields_attrs is None:
             fields_attrs = {}
         else:
@@ -645,10 +640,13 @@
                     if width is not None:
                         element.set('width', str(width))
                 if element.get('optional'):
-                    if element.get('name') in fields_optional:
-                        optional = str(int(
-                                fields_optional[element.get('name')]))
-                        element.set('optional', optional)
+                    try:
+                        value = fields_optional[element.get('name')].pop(0)
+                    except IndexError:
+                        pass
+                    else:
+                        if value is not None:
+                            element.set('optional', str(int(value)))
 
         encoder = PYSONEncoder()
         if element.tag == 'button':

Reply via email to