Links: 12-25-2006

Script.aculous, Autocompleter, Element.collectTextNodes and Element.cleanWhitespace

A couple days ago I was fighting with the Script.aculous autocompleter. I was using code that was almost exactly like the customized autocompletion demo, where the content returned from the AJAX call was a bulleted list of items with each item containing three div’s:

<ul id="mylist">
  <li>
    <div><img src="/images/avatar/robot.png" /></div>
    <div>Aaron Johnson</div>
  </li>
  <li>
  ...
  </li>
</ul>

After the content is returned, the user selects an option and the ‘updateElement()’ function of Autocompleter.Base is called, which itself uses a function ‘Element.collectTextNodes()’. Element.collectTextNodes traverses the all the nodes in the selected option and retrieves the text value of each one, effectively stripping the HTML from the ul element. Finally, the autocompleter takes the resulting text value and updates the value of the textbox that you started with in the first place.

At least that’s what’s supposed to happen. What actually happened was that the textbox wasn’t showing the selected value at all and I beat my head against my desk a couple times. Why? If you take the HTML example above and run it through Element.collectTextNodes(), you’ll get this:


Aaron Johnson

not this:

Aaron Johnson

See the line break? If you try to update the value of a textbox with a variable whose first line is a line break, the textbox sees only the line break and nothing else.

The solution turns out to be a widely used function in prototype: Element.cleanWhitespace(), which removes all empty text node children of an element. So I updated Element.collectTextNodes to look like this:

Element.collectTextNodes = function(element) {
  element = Element.cleanWhitespace(element);
  return $A($(element).childNodes).collect( function(node) {
    return (node.nodeType==3 ? node.nodeValue :
      (node.hasChildNodes() ? Element.collectTextNodes(node) : ''));
  }).flatten().join('');
}

I created a wiki page on the script.aculous wiki for the Element.collectTextNodes function, post a comment on the wiki if you agree that the function should include a call to Element.cleanWhitespace.

Ajax.Autocompleter is not a constructor

Using script.aculous to create some cool effects on your website? Ever get this error message?

Ajax.Autocompleter is not a constructor

All of the results I found on Google suggested that the solution to the problem was to make sure that you were including controls.js (which is where the autocompletion stuff lives in script.aculous). If you checked and double checked that you have controls.js (or that you’re including scriptaculous.js which itself includes controls.js), then your problem could be that you included prototype.js twice. Example:

<script type="text/javascript" src="prototype.js"></script>
<script type="text/javascript" src="scriptaculous.js"></script>
<script>
  function createAC() {
    new Ajax.Autocompleter('mytextbox', 'myautocomplete', 
      'autocomplete/url', {});
  }
</script>
<input type="text" id="mytextbox" name="username" value="" />
<div id="myautocomplete" class="autocomplete"></div>
<a href="#" onclick="createAC(); return false;">start autocomplete</a>
<script type="text/javascript" src="prototype.js"></script>

Why? Prototype defines a class ‘Ajax’:

var Ajax = {
  getTransport: function() {
...

and then Script.aculous extends that class:

Ajax.Autocompleter = Class.create();
Object.extend(Object.extend(Ajax.Autocompleter.prototype, Autocompleter.Base.prototype), {
...

So including prototype.js a second time blows away the class defined by Script.aculous. Really easy to fix, but if you’re working on a large team, make sure that prototype.js is only included once on every page.

RSS/Atom feeds, Last Modified and Etags

Sometime last week I read this piece by Sam Ruby, which summarized says this:

…don’t send Etag and Last-Modified headers unless you really mean it. But if you can support it, please do. It will save you some bandwidth and your readers some processing.

The product I’ve been working on at work (which I should be able to start talking about soon which I can talk about now) for the last couple months uses feeds (either Atom, RSS 1.0 or RSS 2.0, your choice) extensively but didn’t have Etag or Last-Modified support so I spent a couple hours working on it this past weekend. We’re using ROME, so the code ended up looking something like this:

HttpServletRequest request = ...
HttpServletResponse response = ....
SyndFeed feed = ...
if (!isModified(request, feed)) {
  response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
} else {
  long publishDate = feed.getPublishedDate().getTime();
  response.setDateHeader("Last-Modified", publishDate);
  response.setHeader("Etag", getEtag(feed));
}
...
private String getEtag(SyndFeed feed) {
  return "\"" + String.valueOf(feed.getPublishedDate().getTime()) + "\"";
}
...
private boolean isModified(HttpServletRequest request, SyndFeed feed) {
  if (request.getHeader("If-Modified-Since") != null && request.getHeader("If-None-Match") != null) {
  String feedTag = getEtag(feed);
    String eTag = request.getHeader("If-None-Match");
    Calendar ifModifiedSince = Calendar.getInstance();
    ifModifiedSince.setTimeInMillis(request.getDateHeader("If-Modified-Since"));
    Calendar publishDate = Calendar.getInstance();
    publishDate.setTime(feed.getPublishedDate());
    publishDate.set(Calendar.MILLISECOND, 0);
    int diff = ifModifiedSince.compareTo(publishDate);
    return diff != 0 || !eTag.equalsIgnoreCase(feedTag);
  } else {
    return true;
  }
}

There are only a two gotchas in the code:

  1. The value of the Etag must be quoted, hence the getEtag(...) method above returning a string wrapped in quotes. Not hard to do, but easy to miss.
  2. The first block of code above uses the setDateHeader(String name, long date) to set the ‘Last-Modified’ HTTP header, which conveniently takes care of formatting the given date according to the RFC 822 specification for dates and times. The published date comes from ROME. Here’s where it gets tricky: if the client returns the ‘If-Modified-Since’ header and you retrieve said date from the request using getDateHeader(String name), you’ll get a Date in the GMT timezone, which means if you want to compare the date you’ll have to get the date into your own timezone. That’s relatively easy to do by creating a Calendar instance and setting the time of the instance to the value you retrieved from the header. The Calendar instance will transparently take care of the timezone change for you. But there’s still one thing left: the date specification for RFC 822 doesn’t specify a millisecond so if the long value you hand to setDateHeader(long date) method contains a millisecond value and you then try to use the same value to compare against the ‘If-Modified-Since’ header, you’ll never get a match. The easy way around that is to manually set the millisecond bits on the date you get back from the ‘If-Modified-Since’ header to zero.

If you’re interested, there are a number of other blogs / articles about Etags and Last-Modified headers: