details: https://code.tryton.org/tryton/commit/280d7c937fa2
branch: default
user: Cédric Krier <[email protected]>
date: Fri Apr 17 18:59:58 2026 +0200
description:
Use search window to delete/remove records from xxx2Many widgets
Closes #14782
diffstat:
sao/CHANGELOG | 1 +
sao/src/view/form.js | 112 ++++++++-
sao/src/window.js | 40 +++-
tryton/CHANGELOG | 1 +
tryton/tryton/gui/window/view_form/view/form_gtk/many2many.py | 37 ++-
tryton/tryton/gui/window/view_form/view/form_gtk/one2many.py | 50 +++-
tryton/tryton/gui/window/win_search.py | 32 ++-
7 files changed, 235 insertions(+), 38 deletions(-)
diffs (528 lines):
diff -r 5e7086dc7cc4 -r 280d7c937fa2 sao/CHANGELOG
--- a/sao/CHANGELOG Tue Apr 21 13:40:21 2026 +0200
+++ b/sao/CHANGELOG Fri Apr 17 18:59:58 2026 +0200
@@ -1,3 +1,4 @@
+* Use search window to delete/remove records from xxx2Many widgets
Version 8.0.0 - 2026-04-20
--------------------------
diff -r 5e7086dc7cc4 -r 280d7c937fa2 sao/src/view/form.js
--- a/sao/src/view/form.js Tue Apr 21 13:40:21 2026 +0200
+++ b/sao/src/view/form.js Fri Apr 17 18:59:58 2026 +0200
@@ -3551,7 +3551,7 @@
}
var deletable =
this.screen.deletable &&
- this.screen.selected_records.some((r) => !r.deleted &&
!r.removed);
+ this.screen.group.some((r) => !r.deleted && !r.removed);
var undeletable =
this.screen.selected_records.some((r) => r.deleted ||
r.removed);
const view_type = this.screen.current_view.view_type;
@@ -3575,7 +3575,6 @@
this._readonly ||
!record ||
!this.delete_access ||
- !this._position ||
!deletable);
this.but_undel.prop(
'disabled',
@@ -3601,6 +3600,8 @@
!this._length ||
first);
if (this.attributes.add_remove) {
+ let removable =
+ this.screen.group.some((r) => !r.deleted && !r.removed);
this.but_add.prop(
'disabled',
this._readonly ||
@@ -3613,9 +3614,9 @@
'disabled',
this._readonly ||
!record ||
- (typeof this._position != 'number') ||
!this.write_access ||
- !this.read_access);
+ !this.read_access ||
+ !removable);
}
},
_sequence: function() {
@@ -3733,7 +3734,7 @@
this._popup = false;
};
var parser = new Sao.common.DomainParser();
- var order = this.field.get_search_order(this.record);
+ var order = this.field.description.order;
new Sao.Window.Search(this.attributes.relation,
callback, {
sel_multi: true,
@@ -3745,8 +3746,10 @@
views_preload: this.attributes.views || {},
new_: !this.but_new.prop('disabled'),
search_filter: parser.quote(text),
- title: this.attributes.string,
exclude_field: this.attributes.relation_field,
+ title: Sao.i18n.gettext(
+ "%s to add", this.attributes.string),
+ button: Sao.Window.Search.Button.ADD,
});
},
remove: function(event_) {
@@ -3754,7 +3757,56 @@
if (!this.write_access || !this.read_access || !writable) {
return;
}
- this.screen.remove(false, true, false);
+ this._remove(true);
+ },
+ _remove: function(remove=false) {
+ let selected_records = this.screen.selected_records;
+ if (selected_records.length <= 0 || selected_records.length >= 2) {
+ let domain = [['id', 'in', this.field.get_eval(this.record)]];
+ let context = this.field.get_search_context(this.record);
+ let order = this.field.description.order;
+ let callback = result => {
+ if (!jQuery.isEmptyObject(result)) {
+ let records = [];
+ for (let [id,] of result) {
+ records.push(this.screen.group.get(id));
+ }
+ this.screen.remove(false, remove, false, records);
+ }
+ };
+ let button, title;
+ if (remove) {
+ button = Sao.Window.Search.Button.REMOVE;
+ title = Sao.i18n.gettext(
+ "%1 to remove", this.attributes.string);
+ } else {
+ button = Sao.Window.Search.Button.DELETE;
+ title = Sao.i18n.gettext(
+ "%1 to delete", this.attributes.string);
+ }
+
+ let nodes;
+ if (this.screen.selected_records.length) {
+ nodes = this.screen.selected_records.map((r) => [r.id]);
+ }
+ new Sao.Window.Search(
+ this.attributes.relation, callback, {
+ sel_multi: true,
+ context: context,
+ domain: domain,
+ order: order,
+ view_ids: (this.attributes.view_ids ||
+ '').split(','),
+ views_preload: this.attributes.views || {},
+ new_: false,
+ title: title,
+ button: button,
+ search_filter: '',
+ selected_nodes: nodes,
+ });
+ } else {
+ this.screen.remove(false, remove, false);
+ }
},
new_: function(defaults=null) {
if (!Sao.common.MODELACCESS.get(this.screen.model_name).create) {
@@ -3892,7 +3944,7 @@
!this.screen.deletable) {
return;
}
- this.screen.remove(false, false, false);
+ this._remove(false);
},
undelete: function(event_) {
this.screen.unremove();
@@ -4167,7 +4219,7 @@
}
var removable =
- this.screen.selected_records.some((r) => !r.deleted &&
!r.removed);
+ this.screen.group.some((r) => !r.deleted && !r.removed);
var unremovable =
this.screen.selected_records.some((r) => r.deleted ||
r.removed);
@@ -4177,8 +4229,7 @@
'disabled',
this._readonly ||
!record ||
- !removable ||
- this._position === 0);
+ !removable);
this.but_unremove.prop(
'disabled',
this._readonly ||
@@ -4283,7 +4334,44 @@
});
},
remove: function() {
- this.screen.remove(false, true, false);
+ let selected_records = this.screen.selected_records;
+ if (selected_records.length <= 0 || selected_records.length >= 20)
{
+ let domain = [['id', 'in', this.field.get_eval(this.record)]];
+ let context = this.field.get_search_context(this.record);
+ let order = this.field.get_search_order(this.record);
+ let callback = result => {
+ if (!jQuery.isEmptyObject(result)) {
+ let records = [];
+ for (let [id,] of result) {
+ records.push(this.screen.group.get(id));
+ }
+ this.screen.remove(false, true, false, records);
+ }
+ };
+ let nodes;
+ if (this.screen.selected_records.length) {
+ nodes = this.screen.selected_records.map((r) => [r.id]);
+ }
+ new Sao.Window.Search(
+ this.attributes.relation, callback, {
+ sel_multi: true,
+ context: context,
+ domain: domain,
+ order: order,
+ view_ids: (this.attributes.view_ids ||
+ '').split(','),
+ views_preload: this.attributes.views || {},
+ new_: false,
+ title: Sao.i18n.gettext(
+ "%1 to remove", this.attributes.string),
+ button: Sao.Window.Search.Button.REMOVE,
+ search_filter: '',
+ selected_nodes: nodes,
+ });
+
+ } else {
+ this.screen.remove(false, true, false);
+ }
},
unremove: function() {
this.screen.unremove();
diff -r 5e7086dc7cc4 -r 280d7c937fa2 sao/src/window.js
--- a/sao/src/window.js Tue Apr 21 13:40:21 2026 +0200
+++ b/sao/src/window.js Fri Apr 17 18:59:58 2026 +0200
@@ -981,6 +981,7 @@
}
this.title = title;
this.exclude_field = kwargs.exclude_field || null;
+ let button = kwargs.button || Sao.Window.Search.Button.OK;
var dialog = new Sao.Dialog(Sao.i18n.gettext(
'Search %1', this.title), '', 'lg', false);
this.el = dialog.modal;
@@ -1015,11 +1016,26 @@
this.response('RESPONSE_ACCEPT');
}).appendTo(dialog.footer);
}
+ let label;
+ switch (button) {
+ case Sao.Window.Search.Button.OK:
+ label = Sao.i18n.gettext("OK");
+ break;
+ case Sao.Window.Search.Button.ADD:
+ label = Sao.i18n.gettext("Add");
+ break;
+ case Sao.Window.Search.Button.REMOVE:
+ label = Sao.i18n.gettext("Remove");
+ break;
+ case Sao.Window.Search.Button.DELETE:
+ label = Sao.i18n.gettext("Delete");
+ break;
+ }
jQuery('<button/>', {
'class': 'btn btn-primary',
'type': 'submit',
- 'title': Sao.i18n.gettext("OK"),
- }).text(Sao.i18n.gettext('OK')).appendTo(dialog.footer);
+ 'title': label,
+ }).text(label).appendTo(dialog.footer);
dialog.content.submit(e => {
e.preventDefault();
this.response('RESPONSE_OK');
@@ -1047,10 +1063,15 @@
}
dialog.body.append(this.screen.screen_container.el);
this.el.modal('show');
- this.screen.display();
- if (kwargs.search_filter !== undefined) {
- this.screen.search_filter(kwargs.search_filter);
- }
+ this.screen.display().then(() => {
+ if (kwargs.search_filter !== undefined) {
+ return
this.screen.search_filter(kwargs.search_filter);
+ }
+ }).then(() => {
+ if (kwargs.selected_nodes !== undefined) {
+ return
this.screen.current_view.display(kwargs.selected_nodes);
+ }
+ });
});
});
this.el.on('shown.bs.modal', () => {
@@ -1116,6 +1137,13 @@
}
});
+ Sao.Window.Search.Button = Object.freeze({
+ OK: 'ok',
+ ADD: 'add',
+ REMOVE: 'remove',
+ DELETE: 'delete',
+ });
+
Sao.Window.Preferences = Sao.class_(Object, {
init: function(callback) {
var dialog = new Sao.Dialog('Preferences', '', 'lg');
diff -r 5e7086dc7cc4 -r 280d7c937fa2 tryton/CHANGELOG
--- a/tryton/CHANGELOG Tue Apr 21 13:40:21 2026 +0200
+++ b/tryton/CHANGELOG Fri Apr 17 18:59:58 2026 +0200
@@ -1,3 +1,4 @@
+* Use search window to delete/remove records from xxx2Many widgets
Version 8.0.0 - 2026-04-20
--------------------------
diff -r 5e7086dc7cc4 -r 280d7c937fa2
tryton/tryton/gui/window/view_form/view/form_gtk/many2many.py
--- a/tryton/tryton/gui/window/view_form/view/form_gtk/many2many.py Tue Apr
21 13:40:21 2026 +0200
+++ b/tryton/tryton/gui/window/view_form/view/form_gtk/many2many.py Fri Apr
17 18:59:58 2026 +0200
@@ -10,7 +10,7 @@
from tryton.common.underline import set_underline
from tryton.gui.window.view_form.screen import Screen
from tryton.gui.window.win_form import WinForm
-from tryton.gui.window.win_search import WinSearch
+from tryton.gui.window.win_search import WinSearch, WinSearchButton
from .widget import Widget
@@ -205,12 +205,38 @@
view_ids=self.attrs.get('view_ids', '').split(','),
views_preload=self.attrs.get('views', {}),
new=self.create_access,
- title=self.attrs.get('string'))
+ title=_("%s to add") % self.attrs.get('string'),
+ button=WinSearchButton.ADD)
win.screen.search_filter(quote(value))
win.show()
def _sig_remove(self, *args):
- self.screen.remove(remove=True)
+ if not (0 < len(self.screen.selected_records) < 20):
+ domain = [('id', 'in', self.field.get_eval(self.record))]
+ context = self.field.get_search_context(self.record)
+ order = self.field.attrs.get('order')
+
+ def callback(result):
+ if result:
+ records = []
+ for id, _ in result:
+ records.append(self.screen.group.get(id))
+ self.screen.remove(remove=True, records=records)
+ win = WinSearch(
+ self.attrs['relation'], callback, sel_multi=True,
+ context=context, domain=domain, order=order,
+ view_ids=self.attrs.get('view_ids', '').split(','),
+ views_preload=self.attrs.get('views', {}),
+ title=_("%s to remove") % self.attrs.get('string'),
+ new=False,
+ button=WinSearchButton.REMOVE)
+ win.screen.search_filter()
+ if self.screen.selected_records:
+ nodes = [[r.id] for r in self.screen.selected_records]
+ win.screen.current_view.select_nodes(nodes)
+ win.show()
+ else:
+ self.screen.remove(remove=True)
def _sig_unremove(self, *args):
self.screen.unremove()
@@ -297,7 +323,7 @@
removable = any(
not r.deleted and not r.removed
- for r in self.screen.selected_records)
+ for r in self.screen.group)
unremovable = any(
r.deleted or r.removed for r in self.screen.selected_records)
@@ -306,8 +332,7 @@
and not size_limit))
self.but_remove.set_sensitive(bool(
not self._readonly
- and removable
- and self._position))
+ and removable))
self.but_unremove.set_sensitive(bool(
not self._readonly
and not size_limit
diff -r 5e7086dc7cc4 -r 280d7c937fa2
tryton/tryton/gui/window/view_form/view/form_gtk/one2many.py
--- a/tryton/tryton/gui/window/view_form/view/form_gtk/one2many.py Tue Apr
21 13:40:21 2026 +0200
+++ b/tryton/tryton/gui/window/view_form/view/form_gtk/one2many.py Fri Apr
17 18:59:58 2026 +0200
@@ -11,7 +11,7 @@
from tryton.common.underline import set_underline
from tryton.gui.window.view_form.screen import Screen
from tryton.gui.window.win_form import WinForm
-from tryton.gui.window.win_search import WinSearch
+from tryton.gui.window.win_search import WinSearch, WinSearchButton
from .widget import Widget
@@ -294,7 +294,7 @@
self.screen.deletable
and any(
not r.deleted and not r.removed
- for r in self.screen.selected_records))
+ for r in self.screen.group))
undeletable = any(
r.deleted or r.removed for r in self.screen.selected_records)
view_type = self.screen.current_view.view_type
@@ -310,8 +310,7 @@
self.but_del.set_sensitive(bool(
not self._readonly
and self.delete_access
- and deletable
- and self._position is not None))
+ and deletable))
self.but_undel.set_sensitive(bool(
not self._readonly
and not size_limit
@@ -328,6 +327,9 @@
self._length
and not first))
if self.attrs.get('add_remove'):
+ removable = any(
+ not r.deleted and not r.removed
+ for r in self.screen.group)
self.but_add.set_sensitive(bool(
not self._readonly
and not size_limit
@@ -335,9 +337,9 @@
and self.read_access))
self.but_remove.set_sensitive(bool(
not self._readonly
- and self._position is not None
and self.write_access
- and self.read_access))
+ and self.read_access
+ and removable))
self.wid_text.set_sensitive(self.but_add.get_sensitive())
self.wid_text.set_editable(self.but_add.get_sensitive())
@@ -491,7 +493,36 @@
else:
if not self.delete_access or not deletable:
return
- self.screen.remove(remove=remove)
+ if not (0 < len(self.screen.selected_records) < 20):
+ domain = [('id', 'in', self.field.get_eval(self.record))]
+ context = self.field.get_search_context(self.record)
+ order = self.field.attrs.get('order')
+
+ def callback(result):
+ if result:
+ records = []
+ for id, _ in result:
+ records.append(self.screen.group.get(id))
+ self.screen.remove(remove=True, records=records)
+ if remove:
+ button = WinSearchButton.REMOVE
+ title = _("%s to remove") % self.attrs.get('string')
+ else:
+ button = WinSearchButton.DELETE
+ title = _("%s to delete") % self.attrs.get('string')
+ win = WinSearch(
+ self.attrs['relation'], callback, sel_multi=True,
+ context=context, domain=domain, order=order,
+ view_ids=self.attrs.get('view_ids', '').split(','),
+ views_preload=self.attrs.get('views', {}),
+ title=title, new=False, button=button)
+ win.screen.search_filter()
+ if self.screen.selected_records:
+ nodes = [[r.id] for r in self.screen.selected_records]
+ win.screen.current_view.select_nodes(nodes)
+ win.show()
+ else:
+ self.screen.remove(remove=remove)
def _sig_undelete(self, button):
self.screen.unremove()
@@ -532,8 +563,9 @@
view_ids=self.attrs.get('view_ids', '').split(','),
views_preload=self.attrs.get('views', {}),
new=self.but_new.get_property('sensitive'),
- title=self.attrs.get('string'),
- exclude_field=self.attrs.get('relation_field'))
+ title=_("%s to add") % self.attrs.get('string'),
+ exclude_field=self.attrs.get('relation_field'),
+ button=WinSearchButton.ADD)
win.screen.search_filter(quote(text))
win.show()
diff -r 5e7086dc7cc4 -r 280d7c937fa2 tryton/tryton/gui/window/win_search.py
--- a/tryton/tryton/gui/window/win_search.py Tue Apr 21 13:40:21 2026 +0200
+++ b/tryton/tryton/gui/window/win_search.py Fri Apr 17 18:59:58 2026 +0200
@@ -1,6 +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 gettext
+from enum import Enum, auto
from gi.repository import Gdk, Gtk
@@ -15,11 +16,19 @@
_ = gettext.gettext
+class WinSearchButton(Enum):
+ OK = auto()
+ ADD = auto()
+ REMOVE = auto()
+ DELETE = auto()
+
+
class WinSearch(NoModal):
def __init__(self, model, callback, sel_multi=True, context=None,
domain=None, order=None, view_ids=None,
- views_preload=None, new=True, title='', exclude_field=None):
+ views_preload=None, new=True, title='', exclude_field=None,
+ button=WinSearchButton.OK):
NoModal.__init__(self)
if view_ids is None:
view_ids = []
@@ -38,8 +47,7 @@
self.exclude_field = exclude_field
self.win = Gtk.Dialog(
- title=_('Search'), transient_for=self.parent,
- destroy_with_parent=True)
+ transient_for=self.parent, destroy_with_parent=True)
Main().add_window(self.win)
self.win.set_icon(TRYTON_ICON)
self.win.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
@@ -69,10 +77,24 @@
self.but_new.set_always_show_image(True)
self.but_new.set_accel_path('<tryton>/Form/New', self.accel_group)
+ match button:
+ case WinSearchButton.OK:
+ label = _("OK")
+ icon = 'tryton-ok'
+ case WinSearchButton.ADD:
+ label = _("Add")
+ icon = 'tryton-add'
+ case WinSearchButton.REMOVE:
+ label = _("Remove")
+ icon = 'tryton-remove'
+ case WinSearchButton.DELETE:
+ label = _("Delete")
+ icon = 'tryton-delete'
+
self.but_ok = self.win.add_button(
- set_underline(_("OK")), Gtk.ResponseType.OK)
+ set_underline(label), Gtk.ResponseType.OK)
self.but_ok.set_image(common.IconFactory.get_image(
- 'tryton-ok', Gtk.IconSize.BUTTON))
+ icon, Gtk.IconSize.BUTTON))
self.but_ok.set_always_show_image(True)
self.but_ok.add_accelerator(
'clicked', self.accel_group, Gdk.KEY_Return,