This is a guest post written by Bess Siegal. Bess and I work together and she recently created an auto-complete field that handles multiple values. She stopped by to show us how it works, provide an open source example, and give you all the details you need to create a multi-value auto-complete field in GWT using REST.
More Than Just Another Type-Ahead Script
Type ahead fields are ubiquitous. Type ja into Google and it prompts you for Jamn 94.5, Java, and Jack Johnson. These single value fields work well for search, but don’t scale to multiple values. Our latest tool allows users to search for multiple users, roles, and other objects using an auto-complete field, and for that we needed something new.
This article shows you how to create a multi-valued auto-complete field using the GWT SuggestBox and REST. Communicating between the client and server with REST gives this example a strong separation between the client and the server. I proved it using a server-side component written as a Java servlet, then swapping it out, thanks to Zack, with a server-side component written in PHP.
This sample uses JavaScript, HTML, CSS, and nothing else. It runs in all the major browsers and all the way back to Internet Explorer 6. It’s also a good introduction to REST and how to use it from a browser.
Try It
The multi-value auto-complete field can handle any type of data. Here’s an example that searches for crayon colors.
Search for blue, mac, or *
A Closer Look
I began by investigating the GWT SuggestBox. It’s not multi-valued, but it does automatically send a request and shows a suggestion list popup when you type into its text field. By default its suggestions come from a MultiWordSuggestOracle
, which means all the suggestions reside on the client.
Using the GWT SuggestBox with RPC by Alex Moffat explains how to retrieve suggestions from a remote source using RPC by creating a custom SuggestOracle
. It got me started on creating my own SuggestOracle
using REST instead of using GWT’s RPC. The jQuery auto-complete plugin by Jörn Zaefferer inspired me to make it multi-valued.
Working Example
source code
I’ll walk you through the working code example and explain how it:
- retrieves suggestions using REST
- allows for multiple selection
- performs as an auto-completer even for multiple values that have been pasted in
- allows for browsing through large data sets without having to use a div overflow scrollbar
REST Endpoint
The REST Endpoint can be any HTTP URL that supporst GET and takes the following parameters.
- q – the query
- indexFrom – the 0-based starting index
- indexTo – the last index, inclusive
The endpoint returns JSON in the following format:
{ "TotalSize" : 25, "Options" : [ {"Value" : "#9ACEEB", "DisplayName" : "Cornflower"}, {"Value" : "#CC6666", "DisplayName" : "Fuzzy Wuzzy"} ] }
“TotalSize” is the total number of results yielded by the query and “Options” is the array of name-value pairs in the results from indexFrom and to indexTo.
This well-defined API allows for a server implementation in any language. The source code includes a Java servlet that serves this purpose and also the endpoint Zack wrote in PHP that drives the live demo in this article.
MultivalueSuggestBox
The MultivalueSuggestBox
is a custom GWT widget that utilizes the GWT SuggestBox
. It extends Composite
with a FlowPanel
as its main widget. It encapsulates six inner classes that support server-side data retrieval.
Below is the constructor. Its arguments are the REST endpoint URL and a boolean to specify multi-value.1 A FlowPanel
2 is instantiated so that a FormFeedback
3 control can be included next to the SuggestBox
. The FormFeedback
is a custom GWT widget that shows an icon indicating the status of the control. This lets the user know whether the control is loading and more importantly, helps to identify invalid values.
A SuggestBox
is instantiated using the RestSuggestOracle
4.
The m_valueMap
5 is instantiated to hold all the valid values found, keyed by name.
/** * Constructor. * @param the URL for the REST endpoint. * @param isMultivalued - true for allowing multiple values */ public MultivalueSuggestBox(String restEndpointUrl, boolean isMultivalued)1 { m_restEndpointUrl = restEndpointUrl; m_isMultivalued = isMultivalued; FlowPanel panel = new FlowPanel();2 TextBoxBase textfield; if (isMultivalued) { panel.addStyleName("textarearow"); textfield = new TextArea(); } else { panel.addStyleName("textfieldrow"); textfield = new TextBox(); } //Create our own SuggestOracle that queries REST endpoint SuggestOracle oracle = new RestSuggestOracle();4 //intialize the SuggestBox m_field = new SuggestBox(oracle, textfield); if (isMultivalued) { //have to do this here b/c gwt suggest box wipes //style name if added in previous if textfield.addStyleName("multivalue"); } m_field.addStyleName("wideTextField"); m_field.addSelectionHandler(this); panel.add(m_field); m_feedback = new FormFeedback();3 panel.add(m_feedback); initWidget(panel); m_valueMap = new HashMap<String, String>();5 resetPageIndices(); }
RestSuggestOracle
RestSuggestOracle
6, like all the other classes mentioned in the remainder of this article, is an inner class within MultivalueSuggestBox
. It extends SuggestOracle
implementing requestSuggestions
7 and overriding isDisplayStringHTML
to return true. It creates a timer8 that is reset whenever the user types9 because we only want to query the server when the user has paused in their typing. This will prevent overloading the server with unnecessary overlapping requests. After the timer has elapsed, getSuggestions
10 and conditionally findExactMatches
11 are called.
/** * A custom Suggest Oracle */ private class RestSuggestOracle6 extends SuggestOracle { private SuggestOracle.Request m_request; private SuggestOracle.Callback m_callback; private Timer m_timer; RestSuggestOracle() { m_timer = new Timer()8 { @Override public void run() { /* * The reason we check for empty string is found at * http://development.lombardi.com/?p=39 -- * paraphrased, if you backspace quickly the contents of the field * are emptied but a query for a single character is still executed. * Workaround for this is to check for an empty string field here. */ if (!m_field.getText().trim().isEmpty()) { if (m_isMultivalued) { //calling this here in case a user is trying to correct the //"kev" value of Allison Andrews, Kev, Josh Nolan or pasted //in multiple values findExactMatches();11 } getSuggestions();10 } } }; } @Override public void requestSuggestions(SuggestOracle.Request request, SuggestOracle.Callback callback)7 { //This is the method that gets called by the SuggestBox whenever typing in the text field m_request = request; m_callback = callback; //reset the indexes (b/c NEXT and PREV call getSuggestions directly) resetPageIndices(); //If the user keeps triggering this event (e.g., keeps typing), cancel and restart the timer m_timer.cancel(); m_timer.schedule(DELAY);9 }
getSuggestions
First we’ll look at getSuggestions
12. This method performs the query for the contents of the suggestion list popup. First, it determines the search term. When not multi-valued, the search term is the contents of the suggest box text field. If it is multi-valued, the search term is the part of the string after the last DISPLAY_SEPARATOR
, in this case a comma. It then sets the FormFeedback
widget to a LOADING
state, then calls queryOptions
.
private void getSuggestions()12
{
String query = m_request.getQuery();
//find the last thing entered up to the last separator
//and use that as the query
if (m_isMultivalued) {
int sep = query.lastIndexOf(DISPLAY_SEPARATOR);
if (sep > 0) {
query = query.substring(sep + DISPLAY_SEPARATOR.length());
}
}
query = query.trim();
//do not query if it's just an empty String
//also do not get suggestions you've already got an exact match for
//this string in the m_valueMap
if (query.length() > 0 && m_valueMap.get(query) == null) {
updateFormFeedback(FormFeedback.LOADING, null);
queryOptions(
query,
m_indexFrom,
m_indexTo,
new RestSuggestCallback(m_request, m_callback, query));
}
}
queryOptions
The queryOptions
13 method sends a GET request to the URL of the REST endpoint passed to the constructor of MultivalueSuggestBox
1. It uses the GWT RequestBuilder
object. queryOptions
converts the response from JSON to type safe Java beans14. The arguments are the query string, the indices of the result set desired, and an OptionQueryCallback
15.
/** * Retrieve Options (name-value pairs) that are suggested from the REST endpoint * @param query - the String search term * @param from - the 0-based begin index int * @param to - the end index inclusive int * @param callback - the OptionQueryCallback to handle the response */ private void queryOptions(final String query, final int from, final int to, final OptionQueryCallback callback)13 { RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, URL.encode(m_restEndpointUrl + "?q=" + query + "&indexFrom=" + from + "&indexTo=" + to)); // Set our headers builder.setHeader("Accept", "application/json"); builder.setHeader("Accept-Charset", "UTF-8"); builder.setCallback(new RequestCallback() { @Override public void onResponseReceived(com.google.gwt.http.client.Request request, Response response) { JSONValue val = JSONParser.parse(response.getText()); JSONObject obj = val.isObject(); int totSize = (int) obj.get(OptionResultSet.TOTAL_SIZE).isNumber().doubleValue(); OptionResultSet options = new OptionResultSet(totSize);14 JSONArray optionsArray = obj.get(OptionResultSet.OPTIONS).isArray(); if (options.getTotalSize() > 0 && optionsArray != null) { for (int i = 0; i < optionsArray.size(); i++) { JSONObject jsonOpt = optionsArray.get(i).isObject(); Option option = new Option(); option.setName(jsonOpt.get(OptionResultSet.DISPLAY_NAME).isString().stringValue()); option.setValue(jsonOpt.get(OptionResultSet.VALUE).isString().stringValue()); options.addOption(option); } } callback.success(options); } @Override public void onError(com.google.gwt.http.client.Request request, Throwable exception) { callback.error(exception); } }); try { builder.send(); } catch (RequestException e) { updateFormFeedback(FormFeedback.ERROR, "Error: " + e.getMessage()); } }
OptionQueryCallback
The OptionQueryCallback
abstract class handles success and error conditions from the REST call.
/**
* Handles success and error conditions from the REST call
*/
private abstract class OptionQueryCallback15
{
abstract void success(OptionResultSet optResults);
abstract void error(Throwable exception);
}
RestSuggestCallback
RestSuggestCallback
16 extends OptionQueryCallback
15 and is the object passed to queryOptions
13 from getSuggestions
12. The RestSuggestCallback
constructor takes the SuggestOracle.Request
, SuggestOracle.Callback
, and the query as arguments.
Once the request succeeds RestSuggestCallback
evaluates the response:
- if the total size is less than one, it means there were no suggestions returned from the REST endpoint, so the
FormFeedback
shows anERROR
status.17 TheOptionSuggestion
ArrayList
remains empty.18 - if the total size is equal to one, then there was only one suggestion. In this case we skip the popup and just show the value in the text field. The name of the
Option
replaces the string after the lastDISPLAY_SEPARATOR
19. TheFormFeedback
shows aVALID
status20. The name and value are added to the value map.21. TheOptionSuggestion
ArrayList
remains empty.18 - if the total size is greater than 1, the The
OptionSuggestion
ArrayList
is built up22 and these suggestions are sent back to theSuggestOracle.Callback
23. We also conditionally add the next and previous options for scrolling in the results.24 TheFormFeedback
shows anERROR
status until the user selects a valid option.25
/** * A custom callback that has the original SuggestOracle.Request * and SuggestOracle.Callback */ private class RestSuggestCallback extends OptionQueryCallback16 { private SuggestOracle.Request m_request; private SuggestOracle.Callback m_callback; private String m_query; //this may be different from m_request.getQuery when multivalued it's only the substring after the last delimiter RestSuggestCallback(Request request, Callback callback, String query) { m_request = request; m_callback = callback; m_query = query; } public void success(OptionResultSet optResults) { SuggestOracle.Response resp = new SuggestOracle.Response(); List<OptionSuggestion> suggs = new ArrayList<OptionSuggestion>();18 int totSize = optResults.getTotalSize(); if (totSize < 1) {16 //if there were no suggestions, then it's an invalid value updateFormFeedback(FormFeedback.ERROR, "Invalid: " + query);17 } else if (totSize == 1) { //it's an exact match, so do not bother with showing suggestions, Option o = optResults.getOptions()[0]; String displ = o.getName(); //remove the last bit up to separator m_field.setText(getFullReplaceText(displ, m_request.getQuery()));19 //it's valid! updateFormFeedback(FormFeedback.VALID, null);20 //set the value into the valueMap putValue(displ, o.getValue());21 } else { //more than 1 so show the suggestions //if not at the first page, show PREVIOUS if (m_indexFrom > 0) { OptionSuggestion prev = new OptionSuggestion( OptionSuggestion.PREVIOUS_VALUE, m_request.getQuery()); suggs.add(prev); } // show the suggestions for (Option o : optResults.getOptions()) { OptionSuggestion sugg = new OptionSuggestion( o.getName(), o.getValue(), m_request.getQuery(), m_query); suggs.add(sugg);22 } //if there are more pages, show NEXT if (m_indexTo < totSize) { OptionSuggestion next = new OptionSuggestion( OptionSuggestion.NEXT_VALUE, m_request.getQuery()); suggs.add(next);24 } //nothing has been picked yet, so let the feedback show an error (unsaveable) updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query);25 } //it's ok (and good) to pass an empty suggestion list back to the suggest box's callback method //the list is not shown at all if the list is empty. resp.setSuggestions(suggs); m_callback.onSuggestionsReady(m_request, resp);23 } @Override public void error(Throwable exception) { updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query); } }
OptionSuggestion
OptionSuggestion
objects are collected into a List
and passed to SuggestOracle.Callback.onSuggestionsReady
24. It extends SuggestOracle.Suggestion
overriding getDisplayString
and getReplacementString
and adding getValue
and getName
methods for Option
IDs and names. You could add more properties depending on your needs. In this example, the names are the crayon colors and the values are their corresponding hex codes.
OptionSuggestion
has two constructors. One is for navigation26 and the other is for an actual option27.
Since RestSuggestOracle.isDisplayStringHTML
is true, HTML can be used for the display string. In the body of the first constructor, the HTML returned is a div with a class that is included in the css. It will show an appropriate image to indicate navigation. In the body of the second constructor, HTML bold tags are used to highlight the query search term within each suggestion in the list popup. The value returned by getReplacementString
28 is determined by calling getFullReplaceText
so that the text box does not lose any previously selected options in the multi-value case, and only the portion of the contents of the text field after the last DISPLAY_SEPARATOR
is replaced.
/** * Constructor for navigation options * @param nav - next or previous value * @param currentTextValue - the current contents of the text box */ OptionSuggestion(String nav, String currentTextValue)26 { if (NEXT_VALUE.equals(nav)) { m_display = "<div class=\"autocompleterNext\" title=\"Next\"></div>"; } else { m_display = "<div class=\"autocompleterPrev\" title=\"Previous\"></div>"; } m_replace = currentTextValue; m_value = nav; } /** * Constructor for regular options * @param displ - the name of the option * @param val - the value of the option * @param replacePre - the current contents of the text box * @param query - the query */ OptionSuggestion(String displ, String val, String replacePre, String query)27 { m_name = displ; int begin = displ.toLowerCase().indexOf(query.toLowerCase()); if (begin >= 0) { int end = begin + query.length(); String match = displ.substring(begin, end); m_display = displ.replaceFirst(match, "<b>" + match + "</b>"); } else { //may not necessarily be a part of the query, for example if "*" was typed. m_display = displ; } m_replace = getFullReplaceText(displ, replacePre);28 m_value = val; }
onSelection
MultivalueSuggestBox
implements SelectionHandler<Suggestion>
, so onSelection
29 is called after the user chooses an option among the items shown in the SuggestBox
list popup. If an option is selected, the FormFeedback
shows a VALID
status and the option’s name and value are put30 into the value map. If the selection is one of the navigation options, the indices are changed and getSuggestions
is called again31. Having the next/previous options allows for browsing of the result set within a reasonably sized popup that does not need scrollbars.
@Override public void onSelection(SelectionEvent<Suggestion> event)29 { Suggestion suggestion = event.getSelectedItem(); if (suggestion instanceof OptionSuggestion) { OptionSuggestion osugg = (OptionSuggestion) suggestion; //if NEXT or PREVIOUS were selected, requery but bypass the timer String value = osugg.getValue(); if (OptionSuggestion.NEXT_VALUE.equals(value)) { m_indexFrom += PAGE_Size; m_indexTo += PAGE_Size; RestSuggestOracle oracle = (RestSuggestOracle) m_field.getSuggestOracle(); oracle.getSuggestions();31 } else if (OptionSuggestion.PREVIOUS_VALUE.equals(value)) { m_indexFrom -= PAGE_Size; m_indexTo -= PAGE_Size; RestSuggestOracle oracle = (RestSuggestOracle) m_field.getSuggestOracle(); oracle.getSuggestions(); } else { //made a valid selection updateFormFeedback(FormFeedback.VALID, null); //add the option's value to the value map putValue(osugg.getName(), value);30 //put the focus back into the textfield so user //can enter more m_field.setFocus(true); } } }
findExactMatches
Now let’s go back and look at findExactMatches
32. This is only called if multi-valued was specified, and is useful because the user might want to copy and paste an entire set of names or need to change a name that is in the middle of the text field. If there is more than one name in the text field, a query is executed for every name that does not already have a value in the value map. It resets the member variables m_findExactMatchesTotal
, m_findExactMatchesFound
, and m_findExactMatchesNot
33, then for every non-valued term it calls findExactMatch
34.
/** * If there is more than one key in the text field, * check that every key has a value in the map. * For any that do not, try to find its exact match. */ private void findExactMatches()32 { String text = m_field.getText(); String[] keys = text.split(DISPLAY_SEPARATOR.trim()); int len = keys.length; if (len < 2) { //do not continue. if there's 1, it is the last one, and getSuggestions can handle it return; } m_findExactMatchesTotal = 0; m_findExactMatchesFound = 0; m_findExactMatchesNot.clear();33 for (int pos = 0; pos < len; pos++) { String key = keys[pos].trim(); if (!key.isEmpty()) { String v = m_valueMap.get(key); if (null == v) { m_findExactMatchesTotal++; } } } //then loop through again and try to find them /* * We may have invalid values due to a multi-value copy-n-paste, * or going back and messing with a middle or first key; * so for each invalid value, try to find an exact match. */ for (int pos = 0; pos < len; pos++) { String key = keys[pos].trim(); if (!key.isEmpty()) { String v = m_valueMap.get(key); if (null == v) { findExactMatch(key, pos);34 } } } }
findExactMatch
findExactMatch
35 updates the FormFeedback
to show a LOADING
status. It then calls queryOptions
13, but this time the indices are hard-coded from zero to some relatively small amount so the exact match can attempt to be found within the top suggestions.
An anonymous OptionQueryCallback
36 evaluates the response:
- if the total size is equal to one, then the one option is the match, so the name and value will be placed in the value map and
m_findExactMatchesFound
is incremented.37 - if the total size is greater than one, it loops through the result set to find if there is an exact match within those top suggestions, and if so, the name and value will be placed in the value map and
m_findExactMatchesFound
is incremented.38 - if an exact match is not found, then the term is added to the
m_findExactMatchesNot
list.39
Once m_findExactMatchesFound + m_findExactMatchesNot.size()
is equal to m_findExactMatchesTotal
, the FormFeedback
will be updated to either show VALID
if all exact matches were found40 or ERROR
if any name could not be exactly matched. All names that could not be matched will be shown within the tooltip (i.e., title) of the FormFeedback
.41
private void findExactMatch(final String displayValue, final int position)35 { updateFormFeedback(FormFeedback.LOADING, null); queryOptions( displayValue, 0, FIND_EXACT_MATCH_QUERY_LIMIT, new OptionQueryCallback() {36 @Override public void error(Throwable exception) { // an exact match couldn't be found, just increment not found m_findExactMatchesNot.add(displayValue); finalizeFindExactMatches(); } @Override public void success(OptionResultSet optResults) { int totSize = optResults.getTotalSize(); if (totSize == 1) { //an exact match was found, so place it in the value map Option option = optResults.getOptions()[0]; extactMatchFound(position, option);37 } else { //try to find the exact matches within the results boolean found = false; for (Option option : optResults.getOptions()) { if (displayValue.equalsIgnoreCase(option.getName())) { extactMatchFound(position, option);38 found = true; break; } } if (!found) { m_findExactMatchesNot.add(displayValue);39 } } finalizeFindExactMatches(); } private void extactMatchFound(final int position, Option option) { putValue(option.getName(), option.getValue()); //and replace the text String text = m_field.getText(); String[] keys = text.split(DISPLAY_SEPARATOR.trim()); keys[position] = option.getName(); String join = ""; for (String n : keys) { join += n.trim() + DISPLAY_SEPARATOR; } join = trimLastDelimiter(join, DISPLAY_SEPARATOR); m_field.setText(join); m_findExactMatchesFound++; } private void finalizeFindExactMatches() { if (m_findExactMatchesFound + m_findExactMatchesNot.size() == m_findExactMatchesTotal) { //when the found + not = total, we're done if (m_findExactMatchesNot.size() > 0) { String join = ""; for (String val : m_findExactMatchesNot) { join += val.trim() + DISPLAY_SEPARATOR; } join = trimLastDelimiter(join, DISPLAY_SEPARATOR); updateFormFeedback(FormFeedback.ERROR, "Invalid:" + join);41 } else { updateFormFeedback(FormFeedback.VALID, null);40 } } } }); }
You can call getValue
42 when the values of the selected options are needed. This implementation of getValue returns a concatenated string of all the values delimited by VALUE_DELIM
, in this case a semi-colon. If any of the names were found to be invalid, that is, never exactly matched or chosen, it is removed43 and the FormFeedback
will show an ERROR
status with a tooltip containing the invalid name(s)44. Depending on your needs, you might choose to return the value map or an array of names and values if order is important.
/** * Get the value(s) as a concatenated String * @return value(s) as a String */ public String getValue()42 { //String together all the values in the valueMap //based on the display values shown in the field String text = m_field.getText(); String values = ""; String invalids = ""; String newKeys = ""; if (m_isMultivalued) { String[] keys = text.split(DISPLAY_SEPARATOR); for (String key : keys) { key = key.trim(); if (!key.isEmpty()) { String v = m_valueMap.get(key); if (null != v) { values += v + VALUE_DELIM; //rebuild newKeys removing invalids newKeys += key + DISPLAY_SEPARATOR;43 } else { invalids += key + DISPLAY_SEPARATOR; } } } values = trimLastDelimiter(values, VALUE_DELIM); //set the new display values m_field.setText(newKeys); } else { values = m_valueMap.get(text); } //if there were any invalid show warning if (!invalids.isEmpty()) { //trim last separator invalids = trimLastDelimiter(invalids, DISPLAY_SEPARATOR); updateFormFeedback(FormFeedback.ERROR, "Invalids: " + invalids);44 } return values; }
The Takeaway
This type-ahead control not only accepts multiple values but also auto-completes for a multi-valued copy and paste. The user-friendly interface doesn’t impose a minimum number of characters before sending a request because it can limit the results shown while still allowing a complete browse of the dataset.
You could do all of that without REST, but we keep the client code completely encapsulated with a REST interface. This sample could be extended with client-side caching to speed up performance.
The code in this example is free and released under the Apache 2.0 license. The other programs needed to run this example are also free, but some of them may use different licenses. Make sure to read and understand each license before using a tool.
We encourage you to use this example in your own applications and we’d love to see the results. Drop us a comment if you do.
Bess Siegal is a software engineer at Novell. She enjoys her one-minute commute so she can spend more time with her husband and 3 daughters.