According to the Model-View-Presenter approach to GWT development, presenters should not know about specific Widgets in views, but rather call methods on interfaces like HasValue and HasClickHandlers. In practice, this works well with relatively simple widgets like TextBox whose behavior can be described to the presenter in terms of a single interface such as HasValue. However, GWT doesn’t yet provide suitable interfaces for all Widgets. One such example is the ListBox, which implements only HasChangeHandlers and HasName. Wouldn’t it be nice if there were a HasValue equivalent for ListBox that would let you get and set the selected value as well as populate the list?
Here’s my idea for such an interface:
package com.turbomanage.gwt.client.ui.widget; import java.util.Collection; import com.google.gwt.user.client.ui.HasValue; /** * MVP-friendly interface for use with any widget that can be populated * with a Collection of items, one of which may be selected * * @author David Chandler * * @param <T> */ public interface HasSelectedValue<T> extends HasValue<T> { void setSelections(Collection<T> selections); void setSelectedValue(T selected); T getSelectedValue(); }
In retrospect, the method name setSelections may be a bit confusing. It refers to all available options (perhaps setOptions instead?), not the one selected value.
And here’s a SelectOneListBox widget that implements the interface (astute readers may note a nod to JSF in the name…). It extends GWT’s ListBox, which simplifies implementation of the methods related to HasValue.
package com.turbomanage.gwt.client.ui.widget; import java.util.ArrayList; import java.util.Collection; import com.google.gwt.event.dom.client.ChangeEvent; import com.google.gwt.event.dom.client.ChangeHandler; import com.google.gwt.event.logical.shared.ValueChangeEvent; import com.google.gwt.event.logical.shared.ValueChangeHandler; import com.google.gwt.event.shared.HandlerRegistration; import com.google.gwt.user.client.ui.ListBox; /** * A ListBox that can be populated with any Collection * * Published under Apache License v2 * * @author David Chandler * @param <T> */ public class SelectOneListBox<T> extends ListBox implements HasSelectedValue<T> { public interface OptionFormatter<T> { abstract String getLabel(T option); abstract String getValue(T option); } private boolean valueChangeHandlerInitialized; private T[] options; private OptionFormatter<T> formatter; public SelectOneListBox(Collection<T> selections, OptionFormatter<T> formatter) { setSelections(selections); setFormatter(formatter); } public SelectOneListBox(OptionFormatter<T> formatter) { this(new ArrayList<T>(), formatter); } public void setFormatter(OptionFormatter<T> formatter) { this.formatter = formatter; } @SuppressWarnings("unchecked") @Override public void setSelections(Collection<T> selections) { // Remove prior options if (options != null) { int numItems = this.getItemCount(); int firstOption = numItems - options.length; for (int i=firstOption; i<numItems; i++) this.removeItem(firstOption); } options = (T[]) selections.toArray(); for (T option : options) { String optionLabel = formatter.getLabel(option); String optionValue = formatter.getValue(option); this.addItem(optionLabel, optionValue); } } @Override public T getSelectedValue() { if (getSelectedIndex() >= 0) { String name = getValue(getSelectedIndex()); for (T option : options) { if (formatter.getValue(option).equals(name)) return option; } } return null; } @Override public void setSelectedValue(T value) { if (value == null) return; for (int i=0; i < this.getItemCount(); i++) { String thisLabel = this.getItemText(i); if (formatter.getLabel(value).equals(thisLabel)) { this.setSelectedIndex(i); return; } } throw new IllegalArgumentException("No index found for value " + value.toString()); } /* * Methods to implement HasValue */ @Override public T getValue() { return this.getSelectedValue(); } @Override public void setValue(T value) { this.setValue(value, false); } @Override public void setValue(T value, boolean fireEvents) { T oldValue = getValue(); this.setSelectedValue(value); if (fireEvents) { ValueChangeEvent.fireIfNotEqual(this, oldValue, value); } } @Override public HandlerRegistration addValueChangeHandler(ValueChangeHandler<T> handler) { // Initialization code if (!valueChangeHandlerInitialized) { valueChangeHandlerInitialized = true; super.addChangeHandler(new ChangeHandler() { public void onChange(ChangeEvent event) { ValueChangeEvent.fire(SelectOneListBox.this, getValue()); } }); } return addHandler(handler, ValueChangeEvent.getType()); } }
The SelectOneListBox constructor takes any collection and an OptionsFormatter which tells it how to get the label and value associated with each item in the collection. Here’s an example of its use:
selectPrayerList = new SelectOneListBox<PrayerList>(new OptionFormatter<PrayerList>() { @Override public String getLabel(PrayerList option) { return option.getName(); } @Override public String getValue(PrayerList option) { return option.getId().toString(); } });
And finally, an example of populating the SelectOneListBox in a presenter:
HasSelectedValue<PrayerList> getPrayerListFilter(); ... display.getPrayerListFilter().setSelections(result.getPrayerLists());
Because HasSelectedValue extends GWT’s HasValue, you can also add ValueChangeHandlers in the presenter like this:
display.getPrayerListsBox().addValueChangeHandler(new ValueChangeHandler<PrayerList>() { @Override public void onValueChange(ValueChangeEvent<PrayerList> event) { ... event.getValue() ... } });
Now, the problem always comes up (and this is probably the reason that GWT’s ListBox doesn’t implement a HasValue-like interface already), how do you add to the list things which are not model items, that is, not in the Collection? A common example is a “Select one” or “New…” prompt to appear in the list box. This is not a problem if you’re using ListBox directly, as you can add any String with addItem(). But it is a problem for SelectOneListBox. Because it implements HasSelectedValue, all options must be of type T. So you can do one of four things:
- Hard-code your presenter to ListBox instead of an interface (very unsatisfying)
- Register ValueChangeHandlers in the view and let the view call a method on the presenter (bi-directional MVP)
- Punt and make a fake model item representing your prompt
- Don’t put such items in list boxes. It confuses screen readers, anyway.
I think #4 is actually the most correct answer and fight for it wherever I can. But when screen real estate is precious, I’m willing to break the rules a little. Since I’m using gwt-presenter, #2 is not an option, which leaves #3. It offends my sensibilities, but it’s quite easy and it works. Here’s an example:
ArrayList<PrayerList> prayerLists = new ArrayList<PrayerList>(); // -1 represents an ID that will never be used in a real PrayerList prayerLists.add(new PrayerList(-1, "Select one")); prayerLists.addAll(result.getLists()); display.getPrayerListFilter().setSelections(prayerLists);
While we’re at it, we can even add a little bit of CSS to style our fake -1 object with a line underneath to act as a menu separator:
/* Select one */ option[value="-1"] { border-bottom: 1px solid black; padding-bottom: 2px; }
To me, this little hackery is worth the convenience and correctness of HasSelectedValue.
HasSelectedValues (plural) and SelectManyListBox are left as an exercise to the reader (for now, anyway).
Enjoy!