3/9/09   Using GWT to progressively enhance an existing page with autocompletion

Much information is available online about how to create GWT applications, but there is very little information about how to use GWT to enhance an existing application to provide some user interface improvements such as autocomplete in text input fields. It is actually very straightforward, and is only non-obvious if you overthink it!

The key to achieving this is to understand some GWT fundamentals: a GWT module is basically a JavaScript module (i.e. each GWT module becomes one .js file which can be included in a page), and a RootPanel is simply a section of an HTML page to be managed by GWT. To enhance an existing page, we need a GWT module which can be compiled to a JavaScript file which can be included in any HTML page and will then decorate the page itself with no special support from the original page using the principal of Progressive Enhancement.

This technique can be applied to any HTML page, whether static or generated by a JSP or other templating language. I used it with JSPs and Struts Actions.

The module declares an entry point, which is responsible for finding appropriate enhancement points and adding GWT-managed widgets to them. The module also references a separate stylesheet for the GWT widgets and also includes any required RPC servlets.

<module>
      <!-- Inherit the core Web Toolkit stuff.                        -->
      <inherits name='com.google.gwt.user.User'/>
      <extend-property name="locale" values="en_GB"/>

      <!-- Specify the app entry point class.                         -->
      <entry-point class='com.tapina.example.client.AutocompleteDecorator'/>

      <!-- Specify the application specific style sheet.              -->
      <stylesheet src='../gwt.css' />
      
      <servlet path="/catalogueService.rpc" class="com.tapina.example.server.CatalogueServiceImpl" />
</module>

The original page and stylesheet are unmodified apart from adding a reference to include the GWT-compiled JavaScript:

 <script type="text/javascript" language="javascript" src="com.tapina.example.AutocompleteDecorator/com.tapina.example.AutocompleteDecorator.nocache.js"></script>

The entry point class's onModuleLoad method then uses DOM methods to find all text input fields on the page and checks for field names for which we can provide autocompletion:

final NodeList<Element> formFields = Document.get().getElementsByTagName("INPUT");
for (int i = 0; i < formFields.getLength(); i++) {
 final InputElement field = (InputElement) formFields.getItem(i);
 final String fieldName = field.getName();
 if (fieldName.equalsIgnoreCase("itemCode")) {
  replaceTextField(field, itemSuggestOracle);
 }
}

itemSuggestOracle is a SuggestOracle which provides suggestions for the field. Its implementation is the same as for any GWT application. In my application, the list of items is fairly small (several hundred), so all the items are fetched to the client side in the background and the suggest oracle uses the cached list to provide suggestions quickly rather than making server side calls. Initially, the suggest oracle is empty and the box behaves identically to a normal text input field.

A suggest oracle which uses Java 5 generics, provides multi word matching and provides for additional suggestion data (unlike the built-in MultiWordSuggestOracle) will be covered in a later blog posting.

All that is left for this blog posting is to present replaceTextField(). This method's job is to register a root panel containing the original text field and then create a new SuggestBox in place of the original text field. The parent element of the text field is used for the root panel, and if it does not already have an ID then a random one is assigned. The original field's attributes are copied onto the new field so that its appearance and behaviour are the same apart from the disabling of the browser's built-in autocomplete and the addition of our suggestions.

SuggestBox replaceTextField(final InputElement field, final SuggestOracle oracle) {
 final Element parent = field.getParentElement();
 if (parent.getId() == null || parent.getId().length() == 0) {
  // Parent node needs an ID before we can use it as a root panel.
  final String s = Long.toHexString((long) (Math.random() * Long.MAX_VALUE));
  parent.setId("auto-" + s);
 }
 final RootPanel root = RootPanel.get(parent.getId());
 SuggestBox newField = new SuggestBox(oracle);
 root.add(newField);
 InputElement textBox = (InputElement) newField.getElement().cast();
 root.getElement().replaceChild(textBox, field);
 textBox.setName(field.getName());
 textBox.setClassName(field.getClassName());
 if (field.getMaxLength() > 0) textBox.setMaxLength(field.getMaxLength());
 if (field.getSize() > 0) textBox.setSize(field.getSize());
 textBox.setTabIndex(field.getTabIndex());
 textBox.setValue(field.getValue());
 textBox.setAttribute("autocomplete", "off"); // Stop browser autocomplete
 return newField; 
}