changeset 263:d3c734669577

browser_side: improvements for lists and contact groups manager: - use DockPanel to deal with UI problems - fixed issues with the autocomplete list - avoid duplicate contacts in a contact list - signal invalid contacts with a red border - check for invalid contacts in the form before saving - better genericity for the class DragAutoCompleteTextBox
author souliane <souliane@mailoo.org>
date Mon, 11 Nov 2013 12:48:33 +0100
parents 30c01671e338
children 2a93f8e6f989
files browser_side/contact_group.py browser_side/list_manager.py browser_side/richtext.py public/libervia.css
diffstat 4 files changed, 359 insertions(+), 237 deletions(-) [+]
line wrap: on
line diff
--- a/browser_side/contact_group.py	Mon Nov 11 10:44:44 2013 +0100
+++ b/browser_side/contact_group.py	Mon Nov 11 12:48:33 2013 +0100
@@ -20,15 +20,17 @@
 """
 
 from pyjamas.ui.FlexTable import FlexTable
-from browser_side.dialog import ConfirmDialog
-from list_manager import ListManager
-import contact
+from pyjamas.ui.DockPanel import DockPanel
 from pyjamas.Timer import Timer
 from pyjamas.ui.Button import Button
 from pyjamas.ui.HorizontalPanel import HorizontalPanel
 from pyjamas.ui.VerticalPanel import VerticalPanel
 from pyjamas.ui.DialogBox import DialogBox
+from pyjamas.ui import HasAlignment
+from browser_side.dialog import ConfirmDialog, InfoDialog
+from list_manager import ListManager
 import dialog
+import contact
 
 
 class ContactGroupManager(ListManager):
@@ -44,52 +46,77 @@
 
         def confirm_cb(answer):
             if answer:
-                (y, x) = self._parent.getIndex(self.__children[key]["button"])
-                self._parent.removeCell(y, x + 1)
-                self._parent.removeCell(y, x)
-                del self.__keys_dict[key]
-                del self.__children[key]
-                self._parent.add_group_panel.groups.remove(key)
+                ListManager.removeContactKey(self, key)
+                self._parent.removeKeyFromAddGroupPanel(key)
 
         _dialog = ConfirmDialog(confirm_cb, text="Do you really want to delete the group '%s'?" % key)
         _dialog.show()
 
-    def removeFromRemainingList(self, contact_):
-        ListManager.removeFromRemainingList(self, contact_)
-        self._parent.updateContactList(contact_=contact_)
+    def removeFromRemainingList(self, contacts):
+        ListManager.removeFromRemainingList(self, contacts)
+        self._parent.updateContactList(contacts=contacts)
 
-    def addToRemainingList(self, contact_):
-        ListManager.addToRemainingList(self, contact_)
-        self._parent.updateContactList(contact_=contact_)
+    def addToRemainingList(self, contacts, ignore_key=None):
+        ListManager.addToRemainingList(self, contacts, ignore_key)
+        self._parent.updateContactList(contacts=contacts)
 
 
-class ContactGroupEditor(FlexTable):
+class ContactGroupEditor(DockPanel):
     """Panel for the contact groups manager."""
 
     def __init__(self, host, parent=None, onCloseCallback=None):
-        # This must be done before FlexTable.__init__ because it is used by setVisible
+        DockPanel.__init__(self)
         self.host = host
+
+        # eventually display in a popup
         if parent is None:
             parent = DialogBox(autoHide=False, centered=True)
             parent.setHTML("Manage contact groups")
-
         self._parent = parent
         self._on_close_callback = onCloseCallback
+        self.all_contacts = self.host.contact_panel.getContacts()
 
         groups_list = self.host.contact_panel.groups.keys()
         groups_list.sort()
-        FlexTable.__init__(self, len(groups_list) + 2, 3)
-        self.addStyleName('contactGroupEditor')
+
+        self.add_group_panel = self.getAddGroupPanel(groups_list)
+        south_panel = self.getCloseSaveButtons()
+        center_panel = self.getContactGroupManager(groups_list)
+        east_panel = self.getContactList()
+
+        self.add(self.add_group_panel, DockPanel.CENTER)
+        self.add(east_panel, DockPanel.EAST)
+        self.add(center_panel, DockPanel.NORTH)
+        self.add(south_panel, DockPanel.SOUTH)
+
+        self.setCellHorizontalAlignment(center_panel, HasAlignment.ALIGN_LEFT)
+        self.setCellVerticalAlignment(center_panel, HasAlignment.ALIGN_TOP)
+        self.setCellHorizontalAlignment(east_panel, HasAlignment.ALIGN_RIGHT)
+        self.setCellVerticalAlignment(east_panel, HasAlignment.ALIGN_TOP)
+        self.setCellVerticalAlignment(self.add_group_panel, HasAlignment.ALIGN_BOTTOM)
+        self.setCellHorizontalAlignment(self.add_group_panel, HasAlignment.ALIGN_LEFT)
+        self.setCellVerticalAlignment(south_panel, HasAlignment.ALIGN_BOTTOM)
+        self.setCellHorizontalAlignment(south_panel, HasAlignment.ALIGN_CENTER)
 
-        def cb(text):
-            nb_keys = len(self.groups.keys)
-            self.getFlexCellFormatter().setColSpan(nb_keys + 1, 0, 1)
-            self.getFlexCellFormatter().setColSpan(nb_keys + 2, 0, 1)
-            self.remove(self.add_group_panel)
-            self.remove(self.command)
-            self.groups.addContactKey(text)
-            refresh()
+        # need to be done after the contact list has been initialized
+        self.groups.setContacts(self.host.contact_panel.groups)
+        self.toggleContacts(showAll=True)
+
+        # Hide the contacts list from the main panel to not confuse the user
+        self.restore_contact_panel = False
+        if self.host.contact_panel.getVisible():
+            self.restore_contact_panel = True
+            self.host.panel._contactsSwitch()
 
+        parent.add(self)
+        parent.setVisible(True)
+        if isinstance(parent, DialogBox):
+            parent.center()
+
+    def getContactGroupManager(self, groups_list):
+        """Set the list manager for the groups"""
+        flex_table = FlexTable(len(groups_list), 2)
+        flex_table.addStyleName('contactGroupEditor')
         # overwrite the default style which has been set for rich text editor
         style = {
            "keyItem": "group",
@@ -98,81 +125,76 @@
            "buttonCell": "contactGroupButtonCell",
            "keyPanel": "contactGroupPanel"
         }
-        self.all_contacts = self.host.contact_panel.getContacts()
-        self.groups = ContactGroupManager(self, groups_list, self.all_contacts, style=style)
-        self.groups.createWidgets()
-
-        self.add_group_panel = dialog.AddGroupPanel(groups_list, cb)
-        self.add_group_panel.addStyleName("addContactGroupPanel")
-
-        self.command = HorizontalPanel()
-        self.command.addStyleName("marginAuto")
-        self.command.add(Button("Cancel", listener=self.cancelWithoutSaving))
-        self.command.add(Button("Save", listener=self.closeAndSave))
+        self.groups = ContactGroupManager(flex_table, groups_list, self.all_contacts, style=style)
+        self.groups.createWidgets()  # widgets are automatically added to FlexTable
+        # FIXME: clean that part which is dangerous
+        flex_table.updateContactList = self.updateContactList
+        flex_table.removeKeyFromAddGroupPanel = self.add_group_panel.groups.remove
+        return flex_table
 
-        contact_panel = VerticalPanel()
+    def getAddGroupPanel(self, groups_list):
+        """Add the 'Add group' panel to the FlexTable"""
 
-        # checkbox has been replaced by a button
-        self.checkbox = Button("", self.toggleContacts)
-        self.checkbox.getChecked = lambda: self.checkbox.checked if hasattr(self.checkbox, "checked") else None
-        self.checkbox.addStyleName("toggleAssignedContacts")
-        contact_panel.add(self.checkbox)
-        self.contacts = contact.GenericContactList(host)
-        contact_panel.add(self.contacts)
-        for contact in self.all_contacts:
-            self.contacts.add(contact)
-        self.setWidget(0, 2, contact_panel)
+        def add_group_cb(text):
+            self.groups.addContactKey(text)
+            self.add_group_panel.textbox.setFocus(True)
+
+        add_group_panel = dialog.AddGroupPanel(groups_list, add_group_cb)
+        add_group_panel.addStyleName("addContactGroupPanel")
+        return add_group_panel
 
-        def refresh():
-            nb_keys = len(self.groups.keys)
-            self.getFlexCellFormatter().setColSpan(nb_keys + 1, 0, 2)  # add group panel
-            self.setWidget(nb_keys + 1, 0, self.add_group_panel)
-            self.getFlexCellFormatter().setColSpan(nb_keys + 2, 0, 3)  # buttons panel
-            self.setWidget(nb_keys + 2, 0, self.command)
-            self.getFlexCellFormatter().setRowSpan(0, 2, nb_keys + 2)  # contact list
+    def getCloseSaveButtons(self):
+        """Add the buttons to close the dialog / save the groups"""
+        buttons = HorizontalPanel()
+        buttons.addStyleName("marginAuto")
+        buttons.add(Button("Cancel", listener=self.cancelWithoutSaving))
+        buttons.add(Button("Save", listener=self.closeAndSave))
+        return buttons
 
-        self.groups.setContacts(self.host.contact_panel.groups)
-        refresh()
-        self.restore_contact_panel = False
-        if self.host.contact_panel.getVisible():
-            self.restore_contact_panel = True
-            self.host.panel._contactsSwitch()
-        self.toggleContacts()
-        parent.add(self)
-        parent.setVisible(True)
-        if isinstance(parent, DialogBox):
-            parent.center()
+    def getContactList(self):
+        """Add the contact list to the DockPanel"""
+        self.toggle = Button("", self.toggleContacts)
+        self.toggle.addStyleName("toggleAssignedContacts")
+        self.contacts = contact.GenericContactList(self.host)
+        for contact_ in self.all_contacts:
+            self.contacts.add(contact_)
+        contact_panel = VerticalPanel()
+        contact_panel.add(self.toggle)
+        contact_panel.add(self.contacts)
+        return contact_panel
 
-    def toggleContacts(self, sender=None):
+    def toggleContacts(self, sender=None, showAll=None):
+        """Callback for the toggle button"""
         if sender is None:
-            sender = self.checkbox
-        if sender.getChecked():
-            sender.checked = False
+            sender = self.toggle
+        sender.showAll = showAll if showAll is not None else not sender.showAll
+        if sender.showAll:
             sender.setText("Hide assigned")
         else:
-            sender.checked = True
             sender.setText("Show assigned")
         self.updateContactList(sender)
 
-    def updateContactList(self, sender=None, contact_=None):
-        sender = self.checkbox
-        if sender.getChecked() is None:
-            # do not update during initialization
+    def updateContactList(self, sender=None, contacts=None):
+        """Update the contact list regarding the toggle button"""
+        if not hasattr(self, "toggle") or not hasattr(self.toggle, "showAll"):
             return
-        if contact_ is not None:
-            if contact_ not in self.all_contacts or not sender.getChecked():
-                return
-            all_contacts = [contact_]
+        sender = self.toggle
+        if contacts is not None:
+            if not isinstance(contacts, list):
+                contacts = [contacts]
+            for contact_ in contacts:
+                if contact_ not in self.all_contacts:
+                    contacts.remove(contact_)
         else:
-            all_contacts = self.all_contacts
-        for contact_ in all_contacts:
-            if sender.getChecked():
+            contacts = self.all_contacts
+        for contact_ in contacts:
+            if sender.showAll:
+                self.contacts.getContactLabel(contact_).setVisible(True)
+            else:
                 if contact_ in self.groups.remaining_list:
                     self.contacts.getContactLabel(contact_).setVisible(True)
                 else:
                     self.contacts.getContactLabel(contact_).setVisible(False)
-            else:
-                self.contacts.getContactLabel(contact_).setVisible(True)
 
     def __close(self):
         """Remove the widget from parent or close the popup."""
@@ -194,13 +216,20 @@
         _dialog.show()
 
     def closeAndSave(self):
+        """Call bridge methods to save the changes and close the dialog"""
         map_ = {}
         for contact_ in self.all_contacts:
             map_[contact_] = set()
         contacts = self.groups.getContacts()
         for group in contacts.keys():
             for contact_ in contacts[group]:
-                map_[contact_].add(group)
+                try:
+                    map_[contact_].add(group)
+                except KeyError:
+                    InfoDialog("Invalid contact",
+                           "The contact '%s' is not your contact list but it has been assigned to the group '%s'." % (contact_, group) +
+                           "Your changes could not be saved: please check your assignments and save again.", Width="400px").center()
+                    return
         for contact_ in map_.keys():
             groups = map_[contact_]
             current_groups = self.host.contact_panel.getContactGroups(contact_)
--- a/browser_side/list_manager.py	Mon Nov 11 10:44:44 2013 +0100
+++ b/browser_side/list_manager.py	Mon Nov 11 12:48:33 2013 +0100
@@ -28,17 +28,15 @@
 from pyjamas.ui.HorizontalPanel import HorizontalPanel
 from pyjamas.ui.VerticalPanel import VerticalPanel
 from pyjamas.ui.DialogBox import DialogBox
-from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler
+from pyjamas.ui.KeyboardListener import KEY_ENTER
 from pyjamas.ui.MouseListener import MouseHandler
 from pyjamas.ui.FocusListener import FocusHandler
 from pyjamas.ui.DropWidget import DropWidget
 from pyjamas.ui.DragWidget import DragWidget
-
 from pyjamas.Timer import Timer
 from pyjamas import DOM
+import panels
 
-import panels
-from pyjamas.ui import FocusListener, KeyboardListener, MouseListener, Event
 
 # HTML content for the removal button (image or text)
 REMOVE_BUTTON = '<span class="richTextRemoveIcon">x</span>'
@@ -61,7 +59,6 @@
         - "x": the x offset for all widgets rows, except the first one if "x_first" is defined
         - "y": the y offset for all widgets columns on the grid
         """
-        self.host = parent.host
         self._parent = parent
         if isinstance(keys_dict, set) or isinstance(keys_dict, list):
             tmp = {}
@@ -91,6 +88,7 @@
            "dragoverPanel": "dragover-recipientPanel",
            "keyPanel": "recipientPanel",
            "textBox": "recipientTextBox",
+           "textBox-invalid": "recipientTextBox-invalid",
            "removeButton": "recipientRemoveButton",
         }
         self.style.update(style)
@@ -99,7 +97,7 @@
         """Fill the parent grid with all the widgets (some may be hidden during the initialization)."""
         self.__children = {}
         for key in self.__keys_dict:
-            self.addContactKey(key, title_format)
+            self.addContactKey(key, title_format=title_format)
 
     def addContactKey(self, key, dict_={}, title_format="%s"):
         if key not in self.__keys_dict:
@@ -108,6 +106,15 @@
         self.__keys_dict[key]["title"] = key
         self._addChild(self.__keys_dict[key], title_format)
 
+    def removeContactKey(self, key):
+        """Remove a list panel and all its associated data."""
+        contacts = self.__children[key]["panel"].getContacts()
+        (y, x) = self._parent.getIndex(self.__children[key]["button"])
+        self._parent.removeRow(y)
+        del self.__children[key]
+        del self.__keys_dict[key]
+        self.addToRemainingList(contacts)
+
     def _addChild(self, entry, title_format):
         """Add a button and FlowPanel for the corresponding map entry."""
         button = Button(title_format % entry["title"])
@@ -120,6 +127,7 @@
         y = len(self.__children) + self.offsets["y"]
         x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"]
 
+        self._parent.insertRow(y)
         self._parent.setWidget(y, x, button)
         self._parent.getCellFormatter().setStyleName(y, x, self.style["buttonCell"])
 
@@ -173,17 +181,31 @@
         """Mark a change (deletion) so the list will be sorted before it's used."""
         self.__remaining_list_sorted = False
 
-    def removeFromRemainingList(self, contact_):
-        """Remove an available contact after it has been added to a sub-panel."""
-        if contact_ in self.__remaining_list:
-            self.__remaining_list.remove(contact_)
+    def removeFromRemainingList(self, contacts):
+        """Remove contacts after they have been added to a sub-panel."""
+        if not isinstance(contacts, list):
+            contacts = [contacts]
+        for contact_ in contacts:
+            if contact_ in self.__remaining_list:
+                self.__remaining_list.remove(contact_)
 
-    def addToRemainingList(self, contact_):
-        """Add a contact after it has been removed from a sub-panel."""
-        if contact_ not in self.__list or contact_ in self.__remaining_list:
-            return
-        self.__remaining_list.append(contact_)
-        self.__sort_remaining_list = True
+    def addToRemainingList(self, contacts, ignore_key=None):
+        """Add contacts after they have been removed from a sub-panel."""
+        if not isinstance(contacts, list):
+            contacts = [contacts]
+        assigned_contacts = set()
+        assigned_map = self.getContacts()
+        for key_ in assigned_map.keys():
+            if ignore_key is not None and key_ == ignore_key:
+                continue
+            assigned_contacts.update(assigned_map[key_])
+        for contact_ in contacts:
+            if contact_ not in self.__list or contact_ in self.__remaining_list:
+                continue
+            if contact_ in assigned_contacts:
+                continue  # the contact is assigned somewhere else
+            self.__remaining_list.append(contact_)
+            self.setRemainingListUnsorted()
 
     def setContacts(self, _map={}):
         """Set the contacts for each contact key."""
@@ -202,55 +224,81 @@
             _map[key] = self.__children[key]["panel"].getContacts()
         return _map
 
-    def setTargetDropCell(self, panel):
-        """Used to drag and drop the contacts from one panel to another."""
-        self._target_drop_cell = panel
+    @property
+    def target_drop_cell(self):
+        """@return: the panel where something has been dropped."""
+        return self.target_drop_cell
 
-    def getTargetDropCell(self):
-        """Used to drag and drop the contacts from one panel to another."""
-        return self._target_drop_cell
+    @target_drop_cell.setter
+    def target_drop_cell(self, target_drop_cell):
+        """@param: target_drop_cell: the panel where something has been dropped."""
+        self.target_drop_cell = target_drop_cell
 
     def registerPopupMenuPanel(self, entries, hide, callback):
         "Register a popup menu panel that will be bound to all contact keys elements."
         self.popup_menu = panels.PopupMenuPanel(entries=entries, hide=hide, callback=callback, item_style=self.style["popupMenuItem"])
 
 
-class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget, MouseHandler):
+class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget, MouseHandler, FocusHandler):
     """A draggable AutoCompleteTextBox which is used for representing a contact.
     This class is NOT generic because of the onDragEnd method which call methods
     from ListPanel. It's probably not reusable for another scenario.
     """
 
-    def __init__(self):
+    def __init__(self, parent, event_cbs, style):
         AutoCompleteTextBox.__init__(self)
         DragWidget.__init__(self)
+        self._parent = parent
+        self.event_cbs = event_cbs
+        self.style = style
         self.addMouseListener(self)
+        self.addFocusListener(self)
+        self.addChangeListener(self)
+        self.addStyleName(style["textBox"])
+        self.reset()
+
+    def reset(self):
+        self.setText("")
+        self.setValid()
+
+    def setValid(self, valid=True):
+        if self.getText() == "":
+            valid = True
+        if valid:
+            self.removeStyleName(self.style["textBox-invalid"])
+        else:
+            self.addStyleName(self.style["textBox-invalid"])
+        self.valid = valid
 
     def onDragStart(self, event):
         dt = event.dataTransfer
         # The group prefix "@" is already in text so we use only the "CONTACT" type
         dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX"))
+        self.setSelectionRange(len(self.getText()), 0)
 
     def onDragEnd(self, event):
         if self.getText() == "":
             return
-        # get the ListPanel containing self
-        parent = self.getParent()
-        while parent is not None and not isinstance(parent, ListPanel):
-            parent = parent.getParent()
-        if parent is None:
-            return
-        # it will return parent again or another ListPanel
-        target = parent.getTargetDropCell()
-        if target == parent:
-            return
-        target.addContact(self.getText())
+        target = self._parent.target_drop_cell  # parent or another ListPanel
+        self.event_cbs["drop"](self, target)
+
+    def setRemoveButton(self):
+
+        def remove_cb(sender):
+            """Callback for the button to remove this contact."""
+            self._parent.remove(self)
+            self._parent.remove(self.remove_btn)
+            self.event_cbs["remove"](self)
+
+        self.remove_btn = Button(REMOVE_BUTTON, remove_cb, Visible=False)
+        self.remove_btn.setStyleName(self.style["removeButton"])
+        self._parent.add(self.remove_btn)
+
+    def removeOrReset(self):
         if hasattr(self, "remove_btn"):
-            # self is not the last textbox, just remove it
             self.remove_btn.click()
         else:
-            # reset the value of the last textbox
-            self.setText("")
+            self.reset()
 
     def onMouseMove(self, sender):
         """Mouse enters the area of a DragAutoCompleteTextBox."""
@@ -262,6 +310,31 @@
         if hasattr(sender, "remove_btn"):
             Timer(1500, lambda: sender.remove_btn.setVisible(False))
 
+    def onFocus(self, sender):
+        sender.setSelectionRange(0, len(self.getText()))
+        self.event_cbs["focus"](sender)
+
+    def validate(self):
+        self.setSelectionRange(len(self.getText()), 0)
+        self.event_cbs["validate"](self)
+
+    def onChange(self, sender):
+        """The textbox or list selection is changed"""
+        if isinstance(sender, ListBox):
+            AutoCompleteTextBox.onChange(self, sender)
+        self.validate()
+
+    def onClick(self, sender):
+        """The list is clicked"""
+        AutoCompleteTextBox.onClick(self, sender)
+        self.validate()
+
+    def onKeyUp(self, sender, keycode, modifiers):
+        """Listen for ENTER key stroke"""
+        AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers)
+        if keycode == KEY_ENTER:
+            self.validate()
+
 
 class DropCell(DropWidget):
     """A cell where you can drop widgets. This class is NOT generic because of
@@ -270,8 +343,9 @@
     lisibility, but it's probably not reusable for another scenario.
     """
 
-    def __init__(self, host):
+    def __init__(self, drop_cbs):
         DropWidget.__init__(self)
+        self.drop_cbs = drop_cbs
 
     def onDragEnter(self, event):
         self.addStyleName(self.style["dragoverPanel"])
@@ -295,135 +369,138 @@
         item, item_type = dt.getData("text/plain").split('\n')  # Workaround for webkit, only text/plain seems to be managed
         if item_type and item_type[-1] == '\0':  # Workaround for what looks like a pyjamas bug: the \0 should not be there, and
             item_type = item_type[:-1]           # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report
-        if item_type == "GROUP":
-            item = "@%s" % item
-            self.addContact(item)
-        elif item_type == "CONTACT":
-            self.addContact(item)
-        elif item_type == "CONTACT_TEXTBOX":
-            self._parent.setTargetDropCell(self)
-            pass
-        else:
-            return
+        if item_type in self.drop_cbs.keys():
+            self.drop_cbs[item_type](self, item)
         self.removeStyleName(self.style["dragoverPanel"])
 
 
-class ListPanel(FlowPanel, DropCell, FocusHandler, KeyboardHandler):
+VALID = 1
+INVALID = 2
+DELETE = 3
+
+
+class ListPanel(FlowPanel, DropCell):
     """Sub-panel used for each contact key. Beware that pyjamas.ui.FlowPanel
     is not fully implemented yet and can not be used with pyjamas.ui.Label."""
 
     def __init__(self, parent, entry, style={}):
         """Initialization with a button and a DragAutoCompleteTextBox."""
         FlowPanel.__init__(self, Visible=(False if entry["optional"] else True))
-        DropCell.__init__(self)
+        drop_cbs = {"GROUP": lambda panel, item: self.addContact("@%s" % item),
+                    "CONTACT": lambda panel, item: self.addContact(item),
+                    "CONTACT_TEXTBOX": lambda panel, item: self.setTargetDropCell(panel)
+                    }
+        DropCell.__init__(self, drop_cbs)
         self.style = style
         self.addStyleName(self.style["keyPanel"])
         self._parent = parent
-        self.host = parent.host
-
-        self._last_textbox = None
-        self.__remove_cbs = []
-
-        self.__resetLastTextBox()
-
-    def __resetLastTextBox(self, setFocus=True):
-        """Reset the last input text box with KeyboardListener."""
-        if self._last_textbox is None:
-            self._last_textbox = DragAutoCompleteTextBox()
-            self._last_textbox.addStyleName(self.style["textBox"])
-            self._last_textbox.addKeyboardListener(self)
-            self._last_textbox.addFocusListener(self)
-        else:
-            # ensure we move it to the last position
-            self.remove(self._last_textbox)
-        self._last_textbox.setText("")
-        self.add(self._last_textbox)
-        self._last_textbox.setFocus(setFocus)
-
-    def onKeyUp(self, sender, keycode, modifiers):
-        """This is called after DragAutoCompleteTextBox.onKeyDown,
-        so the completion is done before we reset the text box."""
-        if not isinstance(sender, DragAutoCompleteTextBox):
-            return
-        if keycode == KEY_ENTER:
-            self.onLostFocus(sender)
-            self._last_textbox.setFocus(True)
-
-    def onFocus(self, sender):
-        """A DragAutoCompleteTextBox has the focus."""
-        if not isinstance(sender, DragAutoCompleteTextBox):
-            return
-        if sender != self._last_textbox:
-            # save the current value before it's being modified
-            text = sender.getText()
-            self._focused_textbox_previous_value = text
-            self._parent.addToRemainingList(text)
-        sender.setCompletionItems(self._parent.remaining_list)
+        self.key = entry["title"]
+        self._addTextBox()
 
-    def onLostFocus(self, sender):
-        """A DragAutoCompleteTextBox has lost the focus."""
-        if not isinstance(sender, DragAutoCompleteTextBox):
-            return
-        self.changeContact(sender)
+    def _addTextBox(self, switchPrevious=False):
+        """Add a text box to the last position. If switchPrevious is True, simulate
+        an insertion before the current last textbox by copying the text and valid state.
+        @return: the created textbox or the previous one if switchPrevious is True.
+        """
+        if hasattr(self, "_last_textbox"):
+            if self._last_textbox.getText() == "":
+                return
+            self._last_textbox.setRemoveButton()
+        else:
+            switchPrevious = False
 
-    def changeContact(self, sender):
-        """Modify the value of a DragAutoCompleteTextBox."""
-        text = sender.getText()
-        if sender == self._last_textbox:
-            if text != "":
-                # a new box is added and the last textbox is reinitialized
-                self.addContact(text, setFocusToLastTextBox=False)
-            return
-        if text == "":
-            sender.remove_btn.click()
-            return
-        # text = new value needs to be removed 1. if the value is unchanged, because we
-        # added it when we took the focus, or 2. if the value is changed (obvious!)
-        self._parent.removeFromRemainingList(text)
-        if text == self._focused_textbox_previous_value:
-            return
-        sender.setVisibleLength(len(text))
-        self._parent.addToRemainingList(self._focused_textbox_previous_value)
-
-    def addContact(self, contact, resetLastTextBox=True, setFocusToLastTextBox=True):
-        """Add a contact and signal it to self._parent panel."""
-        if contact is None or contact == "":
-            return
-        textbox = DragAutoCompleteTextBox()
-        textbox.addStyleName(self.style["textBox"])
-        textbox.setText(contact)
-        self.add(textbox)
-        try:
-            textbox.setVisibleLength(len(str(contact)))
-        except:
-            #FIXME: . how come could this happen?! len(contact) is sometimes 0 but contact is not empty
-            print "len(contact) returns %d where contact == %s..." % (len(str(contact)), str(contact))
-        self._parent.removeFromRemainingList(contact)
-
-        remove_btn = Button(REMOVE_BUTTON, Visible=False)
-        remove_btn.setStyleName(self.style["removeButton"])
+        def focus_cb(sender):
+            if sender != self._last_textbox:
+                # save the current value before it's being modified
+                self._parent.addToRemainingList(sender.getText(), ignore_key=self.key)
+            sender.setCompletionItems(self._parent.remaining_list)
 
         def remove_cb(sender):
             """Callback for the button to remove this contact."""
-            self.remove(textbox)
-            self.remove(remove_btn)
-            self._parent.addToRemainingList(contact)
+            self._parent.addToRemainingList(sender.getText())
             self._parent.setRemainingListUnsorted()
+            self._last_textbox.setFocus(True)
+
+        def drop_cb(sender, target):
+            """Callback when the textbox is drag-n-dropped."""
+            parent = sender._parent
+            if target != parent and target.addContact(sender.getText()):
+                sender.removeOrReset()
+            else:
+                parent._parent.removeFromRemainingList(sender.getText())
+
+        events_cbs = {"focus": focus_cb, "validate": self.addContact, "remove": remove_cb, "drop": drop_cb}
+        textbox = DragAutoCompleteTextBox(self, events_cbs, self.style)
+        self.add(textbox)
+        if switchPrevious:
+            textbox.setText(self._last_textbox.getText())
+            textbox.setValid(self._last_textbox.valid)
+            self._last_textbox.reset()
+            previous = self._last_textbox
+        self._last_textbox = textbox
+        return previous if switchPrevious else textbox
 
-        remove_btn.addClickListener(remove_cb)
-        self.__remove_cbs.append(remove_cb)
-        self.add(remove_btn)
-        self.__resetLastTextBox(setFocus=setFocusToLastTextBox)
+    def _checkContact(self, contact, modify):
+        """
+        @param contact: the contact to check
+        @param modify: True if the contact is being modified
+        @return:
+        - VALID if the contact is valid
+        - INVALID if the contact is not valid but can be displayed
+        - DELETE if the contact should not be displayed at all
+        """
+        def countItemInList(list_, item):
+            """For some reason the built-in count function doesn't work..."""
+            count = 0
+            for elem in list_:
+                if elem == item:
+                    count += 1
+            return count
+        if contact is None or contact == "":
+            return DELETE
+        if countItemInList(self.getContacts(), contact) > (1 if modify else 0):
+            return DELETE
+        return VALID if contact in self._parent.list else INVALID
 
-        textbox.remove_btn = remove_btn
-        textbox.addFocusListener(self)
-        textbox.addKeyboardListener(self)
+    def addContact(self, contact, sender=None):
+        """The first parameter type is checked, so it is also possible to call addContact(sender).
+        If contact is not defined, sender.getText() is used. If sender is not defined, contact will
+        be written to the last textbox and a new textbox is added afterward.
+        @param contact: unicode
+        @param sender: DragAutoCompleteTextBox instance
+        """
+        if isinstance(contact, DragAutoCompleteTextBox):
+            sender = contact
+            contact = sender.getText()
+        valid = self._checkContact(contact, sender is not None)
+        if sender is None:
+            # method has been called to modify but to add a contact
+            if valid == VALID:
+                # eventually insert before the last textbox if it's not empty
+                sender = self._addTextBox(True) if self._last_textbox.getText() != "" else self._last_textbox
+                sender.setText(contact)
+        else:
+            sender.setValid(valid == VALID)
+        if valid != VALID:
+            if sender is not None and valid == DELETE:
+                sender.removeOrReset()
+            return False
+        if sender == self._last_textbox:
+            self._addTextBox()
+        try:
+            sender.setVisibleLength(len(contact))
+        except:
+            # IndexSizeError: Index or size is negative or greater than the allowed amount
+            print "FIXME: len(%s) returns %d... javascript bug?" % (contact, len(contact))
+        self._parent.removeFromRemainingList(contact)
+        self._last_textbox.setFocus(True)
+        return True
 
     def emptyContacts(self):
         """Empty the list of contacts."""
-        for remove_cb in self.__remove_cbs:
-            remove_cb()
-        self.__remove_cbs = []
+        for child in self.getChildren():
+            if hasattr(child, "remove_btn"):
+                child.remove_btn.click()
 
     def setContacts(self, tab):
         """Set the contacts."""
@@ -432,8 +509,7 @@
             tab = list(tab)
         tab.sort()
         for contact in tab:
-            self.addContact(contact, resetLastTextBox=False)
-        self.__resetLastTextBox()
+            self.addContact(contact)
 
     def getContacts(self):
         """Get the contacts
@@ -446,9 +522,18 @@
                     tab.append(widget.getText())
         return tab
 
-    def getTargetDropCell(self):
-        """Returns self or another panel where something has been dropped."""
-        return self._parent.getTargetDropCell()
+    @property
+    def target_drop_cell(self):
+        """@return: the panel where something has been dropped."""
+        return self._parent.target_drop_cell
+
+    @target_drop_cell.setter
+    def target_drop_cell(self, target_drop_cell):
+        """@param target_drop_cell: the panel where something has been dropped."""
+        self.setTargetDropCell(target_drop_cell)
+
+    def setTargetDropCell(self, target_drop_cell):
+        self._parent.target_drop_cell = target_drop_cell
 
 
 class ContactChooserPanel(DialogBox):
--- a/browser_side/richtext.py	Mon Nov 11 10:44:44 2013 +0100
+++ b/browser_side/richtext.py	Mon Nov 11 12:48:33 2013 +0100
@@ -259,6 +259,7 @@
         # TODO: be sure we also display empty groups and disconnected contacts + their groups
         # store the full list of potential recipients (groups and contacts)
         list_ = []
+        list_.append("@@")
         list_.extend("@%s" % group for group in parent.host.contact_panel.getGroups())
         list_.extend(contact for contact in parent.host.contact_panel.getContacts())
         ListManager.__init__(self, parent, composition.RECIPIENT_TYPES, list_)
--- a/public/libervia.css	Mon Nov 11 10:44:44 2013 +0100
+++ b/public/libervia.css	Mon Nov 11 12:48:33 2013 +0100
@@ -1231,6 +1231,13 @@
     font-size: 1em;
 }
 
+.recipientTextBox-invalid {
+    box-shadow: inset 0px 1px 4px rgba(255, 0, 0, 0.6);
+    -webkit-box-shadow:inset 0 1px 4px rgba(255, 0, 0, 0.6);
+    -moz-box-shadow:inset 0 1px 4px rgba(255, 0, 0, 0.6);
+    border: 1px solid rgb(255, 0, 0);
+}
+
 .recipientRemoveButton {
 	margin: 0px 10px 0px 0px;
 	padding: 0px;