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':