Mercurial > libervia-web
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):