Thursday, May 28, 2009

An Add / Edit ListBox for GWT

Update -- turns out that using Hyperlink is not the best solution: one is better off with Anchor widgets.
The change to the code is really trivial: simply change the constructor of the two Hyperlink widgets to look like:
Anchor addLnk = new Anchor("add");
that's it.
---
A relatively common 'widget' that one needs to use in several places all across a GWT application is what I call the "Editable ListBox", as shown in the picture below:



where one can both add one or more items to available choices, as well as edit the currently selected option.

While doing so by using standard GWT widgets is relatively straightforward, the code is 'repetitive' enough that soon as you start doing for the second time, the "smell" is so strong, one cannot help but reach out for the "refactoring medikit."



What I ended up writing is a simple AddEditListBox that displays a PopupPanel (more specifically, a DialogBox widget) that allows one to either edit the item, or add several in one go.

Interestingly enough, the class for the two dialogs is the same class (this allows for modifications to be propagated to both instances with minimal overhead, and it makes it easier to keep the same "look & feel," eliminating at the same time code duplication, with a very minimal increase in API complexity).






The additional benefit is that, in 'edit' mode, an empty entry is equivalent to a 'delete' command (and not to inserting an empty String - which actually the ListBox would allow) thus simplifying the API and saving screen real estate, thus avoiding a 'crowded' UI.

Starting from the UI component for the 'widget' we have the AddEditListBox:


/**
* Encapsulates the behaviour of a ListBox with add/edit functionality.
* This class 'decorates' the AddEditListener that it gets passed at creation to
* keep the 'backing model' (ie a Collection of Strings that will be presented in the ListBox)
* up-to-date with the user's actions.
*
* @author Marco Massenzio (m.massenzio@gmail.com)
*/
public class AddEditListBox implements AddEditListener {

protected Collection model;
protected AddEditListener listener;
protected DialogAddEdit box;
protected Panel container;
protected final ListBox list = new ListBox();

public AddEditListBox(Panel container,
Collection backingModel, AddEditListener listener) {
this.model = backingModel;
this.listener = listener;
this.container = container;
}

public AddEditListBox(Panel panel, Collection backingModel) {
this(panel, backingModel, null);
}

public void render() {
HorizontalPanel panel = new HorizontalPanel();
refreshBox();
list.setPixelSize(100, 20);
panel.add(list);
panel.add(Spacer.getXSpacer(10));
Hyperlink addLnk = new Hyperlink("add", "");
addLnk.addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
linkClicked(false);
}
});
panel.add(addLnk);
panel.add(Spacer.getXSpacer(3));
Hyperlink editLnk = new Hyperlink("edit", "");
editLnk.addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
linkClicked(true);
}
});
panel.add(editLnk);
container.add(panel);
}

public String getSelectedItem() {
return list.getItemText(list.getSelectedIndex());
}

private void refreshBox() {
list.clear();
for (String subject : model) {
list.addItem(subject);
}
}

public void linkClicked(boolean wasEdit) {
DialogAddEdit dlg = new DialogAddEdit(wasEdit ? getSelectedItem() : null, this, wasEdit);
dlg.show();
}

// implementation of the AddEditListener interface, follows here - see below for details
// ...
}

A few (important, in a real-life web application) details have been removed here to keep matters simple (i18n, styling of both the list box and the hyperlinks) but this is pretty much all there is to it.

A few notable points:

  • the 'backing model' is the Collection of items that is kept in sync with the UI elements;

  • the AddEditListBox does not extends the Widget class, it merely wraps it;

  • the UI components are added to a 'container' Panel, and are lined up in their very own HorizontalPanel (this has implications for CSS styling -- probably a subject for its very own blog entry...)

  • while the add / edit components are Hyperlink widgets, they do not generate History events (they actually do, but it's an empty string that the overall application can safely ignore) -- more about this later: for now simply note they implement their very own ClickHandler
And this is really all there is to the UI element, as far as the AddEditListBox goes -- next is the DialogAddEdit component, that gets created in response to the user clicking either of the add / edit anchors, and will display either of the two popups shown earlier:

/**
* A dialog box that allows the user to either add a number of items to a list
* that will be returned to the listener registered at construction) or
* to edit a value that is equally passed at construction (both the old and
* new values will be returned to the listener).
*
* This is typically used in conjunction with a {@link AddEditListBox} that
* uses this dialog to manage the add / edit commands; however, this is not
* required and this class can be used on its own.
*
* @author Marco Massenzio (m.massenzio@gmail.com)
* @see AddEditListener
*/
public class DialogAddEdit {

private static final String MSG_ADD = "Enter the value to be added in the box and " +
"hit 'Add & More' to continue adding values, 'Add & Close' when done. " +
"Blank (empty) values will be ignored:";

private static final String MSG_EDIT_ONLY = "Edit the value below, leave blank to delete it:";

/**
* When the dialog is cancelled or dismissed (with the 'Done' (or 'Save') button the {@code
* listener} gets notified
*
* @see AddEditListener
*/
protected AddEditListener listener;

/**
* the item originally passed in to be edited (if any) can be either {@code null} or empty
*/
protected String oldItem;

protected List items = new ArrayList();

/**
* Flag that, if {@code true} only allows the user to edit the one item passed in at construction.
*/
protected boolean allowEditOnly;

/**
* This dialog does not 'auto-hide' (must be dismissed via its Ok/Cancel buttons) and is 'Modal'
* (ie, all other mouse/keyboard events for any other widget are ignored).
*/
protected DialogBox dialog;

/**
* Contains the value currently being added/edited by the user
*/
protected TextBox nameBox;

protected Label statusLbl;

protected Button cancel;

protected Button okAndMore;

protected Button ok;

/**
* This constructor does not actually create the UI elements and it thus 'cheap' to call: only
* when the DialogBox is actually shown (by calling {@link #show()}) all the expensive UI widgets
* are created and initialised.
*
*


* If {@code allowEditOnly} is {@code false} the listener will be called via the
* {@link AddEditListener#itemsAdded(List)} with the list of items that have been added
* by the user.
*
* @param itemToEdit
* the original item to edit (can be {@code null} or empty)
* @param listener
* will be notified when the dialog is dismissed or canceled
* @param allowEditOnly
* if {@code true} will only allow the one item (if any) passed in as {@code itemToEdit}
* to be edited. Otherwise, a "Add & More" button will be available and the user will be
* allowed to enter multiple items.
*/
public DialogAddEdit(String itemToEdit, AddEditListener listener, boolean allowEditOnly) {
this.listener = listener;
this.oldItem = itemToEdit;
this.allowEditOnly = allowEditOnly;
}

protected void initUiElements() {
dialog = new DialogBox(false, true);
statusLbl = new Label();
statusLbl.setStylePrimaryName(Styles.MESSAGE_RED_SMALL);
nameBox = new TextBox();
nameBox.addKeyUpHandler(new KeyUpHandler() {
public void onKeyUp(KeyUpEvent event) {
if ((event.getSource().equals(nameBox)) && (event.getNativeKeyCode() == KeyCodes.KEY_ENTER)) {
if (!allowEditOnly)
okAndMore.click();
else
ok.click();
}
statusLbl.setText("");
}
});

okAndMore = new Button("Add & More");
okAndMore.addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
String name = nameBox.getText();
if (name.length() > 0) {
items.add(name);
statusLbl.setText(name + " added");
}
nameBox.setText("");
nameBox.setFocus(true);
}
});

ok = new Button(allowEditOnly ? "Save" : "Add & Close");
ok.addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
String name = nameBox.getText();
dialog.hide();
History.newItem("");
if (allowEditOnly) {
listener.itemEdited(oldItem, name);
} else {
if (name.length() > 0)
items.add(name);
listener.itemsAdded(items);
}
}
});

cancel = new Button("Cancel");
cancel.addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
dialog.hide();
History.newItem("");
listener.editCancelled();
}
});
}

/**
* Position the popup 1/4th of the way down and across the screen, and shows the popup dialog.
*/
public void show() {
// lazy initialization only gets performed here
createDialog();
// Since the position calculation is based on the offsetWidth and offsetHeight of the popup, you
// have to use the setPopupPositionAndShow(callback) method.
//
// The alternative would be to call
// show(), calculate the left and top positions, and call setPopupPosition(left, top). This
// would
// have the ugly side effect of the popup jumping from its original position to its new
// position.
dialog.setPopupPositionAndShow(new PopupPanel.PositionCallback() {
public void setPosition(int offsetWidth, int offsetHeight) {
int left = (Window.getClientWidth() - offsetWidth) / 4;
int top = (Window.getClientHeight() - offsetHeight) / 4;
dialog.setPopupPosition(left, top);
}
});
// we have to wait until the popup is shown, or the setFocus will have no effect
nameBox.setFocus(true);
nameBox.selectAll();
}

protected void createDialog() {
initUiElements();
dialog.setText("Edit or Add items");
Panel contents = new VerticalPanel();
contents.add(new Label(createContents()));
contents.add(Spacer.getYSpacer(3));
if ((oldItem != null) && (oldItem.length() > 0)) {
nameBox.setText(oldItem);
nameBox.setSelectionRange(0, oldItem.length());
}
contents.add(nameBox);
contents.add(Spacer.getYSpacer(2));
contents.add(statusLbl);
contents.add(Spacer.getYSpacer(3));
HorizontalPanel buttons = new HorizontalPanel();
buttons.setHorizontalAlignment(HorizontalPanel.ALIGN_RIGHT);
buttons.add(Spacer.getXSpacer(15));
buttons.add(cancel);
if (!allowEditOnly) {
buttons.add(Spacer.getXSpacer(5));
buttons.add(okAndMore);
}
buttons.add(Spacer.getXSpacer(5));
buttons.add(ok);
VerticalPanel p = new VerticalPanel();
p.setHorizontalAlignment(HorizontalPanel.ALIGN_CENTER);
p.add(contents);
p.add(buttons);
dialog.setWidget(p);
}

protected String createContents() {
if (allowEditOnly) {
return MSG_EDIT_ONLY;
}
return MSG_ADD;
}

}


Again, i18n and styling largely omitted, but all the main elements are here: as you can see, the UI manipulation is heavier here, but nothing too complex either (after all, we're talking a TextBox and three buttons at most).

From a user's perspective, hitting 'Enter' saves the current value and positions the cursor for a new entry, and when done, hitting the 'Add & Close' button triggers the listener to be called -- in an 'edit only' box, hitting 'Enter' triggers the box to be dismissed and the listener to be called with the old/new value pairs.

As a small concession to usability, a 'status' message pops up when the user hits the 'Add & More' (or hits Enter) confirming the previous value has been saved: soon as the user starts typing (pedantically, soon as they hit KeyUp on the first char) the status message is dismissed.

Ok -- time to show the Listener interface (although, it should be pretty clear from the code above):
public interface AddEditListener {
/**
* Called after the user has clicked the Done (or Save) button
*
* @param items the items that have been added by the user (using the 'Add & More' button)
* Can be empty, containing only one item or many.
*/
public abstract void itemsAdded(List items);

/**
* One of the items the dialog was called to edit, has been deleted
*
* @param item the one that was selected by the user to be deleted
*/
public abstract void itemDeleted(String item);

/**
* The user has dismissed the dialog box, clicking on its Done (or Save) button
*
* @param oldValue the value that was originally passed in to be edited
* @param newValue the new value, as accepted by the user
*/
public abstract void itemEdited(String oldValue, String newValue);

/**
* The dialog was dismissed by the user by clicking on 'Cancel' button.
*/
public abstract void editCancelled();
}


Nothing too fancy here, the twist being in the AddEditListBox in that it "Decorates" the Listener it gets passed at construction, so as to keep the backing model in sync (and thus removing the burden from the API client):

  // implementation of the AddEditListener interface,
// "decorates" the listener passed in at construction

public void editCancelled() {
// nothing to do here
if (listener != null)
listener.editCancelled();
refreshBox();
}

public void itemDeleted(String item) {
model.remove(item);
if (listener != null)
listener.itemDeleted(item);
refreshBox();
}

public void itemEdited(String oldValue, String newValue) {
model.remove(oldValue);
if ((newValue != null) && (newValue.length() > 0))
model.add(newValue);
if (listener != null)
listener.itemEdited(oldValue, newValue);
refreshBox();
}

public void itemsAdded(List items) {
model.addAll(items);
if (listener != null)
listener.itemsAdded(items);
refreshBox();
}

In fact, unless the client has some specialised need, it needs not even implement the Listener interface itself, and can just leave it to the AddEditListBox to deal with it -- for that reason, I have the two constructors, one which takes an AddEditListener, the other who doesn't:


public AddEditListBox(Panel container, Collection backingModel, AddEditListener listener) {
// ...
}

public AddEditListBox(Panel panel, Collection backingModel) {
this(panel, backingModel, null);
}

So, for a generic client, adding an "add/edit list box" is pretty straightforward:

// ...
VerticalPanel acctMgtPanel = new VerticalPanel();
acctMgtPanel.add(Spacer.getXSpacer(15));
Label subjectsHeading = new Label();
subjectsHeading.setText("School subjects");
subjectsHeading.setStylePrimaryName(Styles.HEADING);
acctMgtPanel.add(subjectsHeading);
AddEditListBox subjectsBox = new AddEditListBox(acctMgtPanel, getModel().getSubjects());
subjectsBox.render();
// ...

and it does not even need to implement the Listener interface (I still think there is value in providing maximum flexibility when implementing "general utility" classes, and there may definitely be cases in which one may want to add a listener ability -- for example, to filter unwanted or disallowed entries, react to others, warn the user of invalid ones, etc.).

A couple of points worth of note:
  • the classes above follow Joshua's "Favor Composition over Inheritance" principle -- so instead of extending the Widget or DialogBox classes, they wrap them instead;

  • implementing, as well as having a private member of type AddEditListener, follows the well-known Decorator pattern, the most famous example(s) of which are the InputStream family: if you are not familiar with it, I strongly suggest you read more about it (the Head First book on Design Patterns is an excellent introductory text -- and if you are into refactoring and patterns, Martin Fowler's Enterprise Patterns book is a great read)

I'm afraid I cannot really post the code for download (at least, not for now) as it's part of a (hopefully, commercial one day) application I'm writing, but if you have any questions, please feel free to post them here.

No comments:

Post a Comment