Monday, November 06, 2006

Better than AJAX: adding client-side-only behavior to form components

The AJAX code for showing the section description was all well and good, but is it really worth a trip back to the server to fetch that data every time someone changes the drop down choice? A better way would be to send the mappings to the client and have the client update the description itself, without any server intervention.

My first attempt at this was to write out the necessary Javascript by giving a <script> tag a wicket:id and then defining a corresponding Label in my form code. If you go this route, you must set label.setEscapeModelStrings(false); otherwise, Wicket will turn instances of " into &quot;. Using this strategy, you can dynamically generate Javascript to be included in-place on the page. This is all well and good, but it means that any time you want this behaviour, you have to remember to include a <script> tag, sort out the HTML ids yourself, and then worry about multiple drop-downs defining the same function multiple times. This strategy is also not very reusable.

A better solution is to add a behaviour or as the Wicket folks spell it, a behavior. (Years upon years of watching and reading British science fiction have made their impression upon me.) I came across behaviors initially when I added the AjaxFormComponentUpdatingBehavior and again when I was wondering how to get my Javascript into the <head> of a document. It turns out that wicket provides HeaderContributor.forJavascript just for this purpose... and as it happens, HeaderContributor is itself a subclass of AbstractBehavior.

Implementing a behavior is a matter of extending AbstractBehavior and implementing the methods that you need. Typically one will implement bind(Component) and onComponentTag(Component, ComponentTag). It becomes somewhat important to understand the internal Wicket lifecycle, as you can guess from these names. bind is called when your behavior is initially bound to a Component. onComponentTag is called during rendering. It's important to realize that during rendering, you can no longer change the Component, or you'll get a ConcurrentModificationException or the like... but you can make changes to the ComponentTag.

Another "gotcha" is that the markup ID for a component is not generated until rendering time. I am told this is changing in Wicket 2. It's a bit of a limitation for this case; to inject Javascript into the document head, we have to modify a Component. The Javascript needs to know the HTML id of the tag (which corresponds to a Wicket Label) it is updating. For Wicket 1.2, we have to settle for specifying the HTML id of the Label's tag manually and supplying it to our behavior when we add it to our DropDownChoice. This also means that the Label cannot have setOutputMarkupId(true), because that would override the manually-specified HTML id. We can still figure out the id of the DropDownChoice via the onComponentTag method, because the getMarkupId method will return the correct markup id by that time.

Initially I had just used StringHeaderContributor to contribute both the static Javascript function and the dynamically-generated per-drop-down mappings, but Wicket offers a better way to inject the Javascript, using the forementioned HeaderContributor.forJavascript(Class class, String path) method. This allows you to place your Javascript file in the same package as your behavior class and pass that class and the filename of the Javascript. Wicket handles inserting the Javascript code reference and the Wicket servlet takes care of extracting and sending the file from your package in response to a request for it. This is probably a tiny bit less efficient than referencing an absolute path on the server, but the benefit is including the code in the same place and being able to easily edit it on the fly from your IDE.

The Javascript is fairly straightforward, though I had to get some help writing it. Here it is:

var updateMap = new Object();
function updateLabel(list, label) {
var mylist = document.getElementById(list);
var mylabel = document.getElementById(label);
var selectedItem = mylist.options[mylist.selectedIndex].text;
mylabel.innerHTML = updateMap[label + '.' + selectedItem];
}

Right now it doesn't guard against conditions like an entry not being found in that updateMap, but that should be easy enough to add. As you can see, it takes the name of a drop-down list and the name of the HTML entity it should update, looks up the current value from the drop-down in the updateMap, and sets the value. The mappings are dynamically generated by DropDownLabelUpdateBehavior, which is supplied with a List of beans, the names of the fields to access in those beans to get the value for the map and the data associated with that value, and the HTML id of the field it should update. After all this explanation, the code itself seems pretty simple:

package net.spatula.news.ui.behaviors;

import java.lang.reflect.Method;
import java.util.List;

import wicket.Component;
import wicket.behavior.AbstractBehavior;
import wicket.behavior.HeaderContributor;
import wicket.behavior.StringHeaderContributor;
import wicket.markup.ComponentTag;

public class DropDownLabelUpdateBehavior extends AbstractBehavior {

private static final long serialVersionUID = 1L;

private static final String javaScriptStart = "<script type=\"text/javascript\">\n";

private static final String javaScriptEnd = "</script>\n";

private String labelName;

private String value;

private String id;

private List beans;

public DropDownLabelUpdateBehavior(List beans, String labelName,
String idName, String valueName) {
this.beans = beans;
this.id = idName;
this.value = valueName;
this.labelName = labelName;

}

public void bind(Component component) {
component.add(HeaderContributor.forJavaScript(this.getClass(),
"dropDownLabelUpdateBehavior.js"));
component.setOutputMarkupId(true);
component.add(new StringHeaderContributor(javaScriptStart
+ getMappings() + javaScriptEnd));
}

public void onComponentTag(Component component, ComponentTag tag) {
tag.getAttributes().remove("onchange");
tag.getAttributes().add(
"onchange",
"updateLabel('" + component.getMarkupId() + "', '" + labelName
+ "')");

}

private String makeGetterName(String field) {
String upCaseFirstLetter = field.substring(0, 1).toUpperCase();
String rest = field.length() > 1 ? field.substring(1) : "";
return "get" + upCaseFirstLetter + rest;
}

private String getMappings() {

if (id == null || id.length() < 1 || value == null
|| value.length() < 1) {
return "";
}
String idMethod = makeGetterName(id);
String valueMethod = makeGetterName(value);

StringBuffer map = new StringBuffer(100);

for (Object object : beans) {
Method getId;
Method getValue;
String thisid, thisvalue;
try {
getId = object.getClass().getMethod(idMethod, (Class[]) null);
getValue = object.getClass().getMethod(valueMethod,
(Class[]) null);
thisid = (getId.invoke(object, (Object[]) null)).toString();
thisvalue = (getValue.invoke(object, (Object[]) null))
.toString();
} catch (Exception e) {
// Just make a best effort, and skip the element if anything
// goes wrong.
continue;
}
map.append("updateMap[\"" + labelName + "." + thisid + "\"] = \""
+ thisvalue + "\";\n");
}

return map.toString();
}
}

A few things to note: we turn on setOutputMarkupId on the component so that it'll be inserted in the HTML in the same way as we see it in onComponentTag, and any existing onchange attribute in the generated ComponentTag has to be removed first; calling tag.getAttributes().add does not seem to overwrite an existing value. One thing that would be good to add is a check in bind to ensure that the Component is compatible with this behavior.

Once all of this is in place, the code to fetch the Section list and add this behavior looks like this:

EntityManager em = ResourceManager.getInstance().getEMF()
.createEntityManager();
List sectionsList = SectionService.getInstance()
.getSections(em);
em.close();

sectionDescription.setOutputMarkupId(false); // absolutely don't want
// it overwritten
if (article.getSection() != null && article.getSection().getId() != 0) {
sectionDescription.setModelObject(article.getSection()
.getDescription());
}
add(sectionDescription);

DropDownChoice sectionChooser = new DropDownChoice("section",
new PropertyModel(article, "section"), sectionsList,
new ChoiceRenderer("name", "id"));
sectionChooser.add(new DropDownLabelUpdateBehavior(sectionsList,
"sectionDescription", "id", "description"));

add(sectionChooser);


There you have it: a reusable behavior for updating an HTML field when the selection in a DropDownChoice changes.

Labels:


Comments: Post a Comment





<< Home

This page is powered by Blogger. Isn't yours?

Subscribe to Posts [Atom]