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,

Reply via email to