diff browser_side/list_manager.py @ 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 0e7f3944bd27
children 2d6bd975a72d
line wrap: on
line diff
--- 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):