April 16, 2010

Extending the XPages Type Ahead Functionality

Xpages comes with a built-in type-ahead functionality for text input fields for your forms. This functionality is working OK straight out of the box, as long as you don't want to do anything too advanced...

There are several other blog entries out there that explain the basic and some more extended usage of the Type Ahead feature:keithstric.com, Lotus.com, DontPanic82. These entries (and others) all provide great information to get you started, but they didn't quite suit all my needs...

My task was as follows:
  • Develop a type ahead functionality for a company's employee search
    • Large number of employees (have to do a work-around for Notes' @DbLookup 64Kb limit).
  • You should get suggestions based on both first names and last names
    • Typing "and" in the input field should provide both "Andy Johnson", "John Anderson" and "Randy Thompson" as suggestions. (the built-in feature would only do a search for "and" or "and*", not "*and*")
Time to get started on some hacking on my own!


I started off by putting up the basic structure: add an Edit Box from the "Core Controls" menu in the Domino Designer and enabling the type ahead functionality from its property panel. By now, switching to the source view in the Designer, you should see something like this:


Left click somewhere within the xp:typeAhead tags to see all the type Ahead properties in your properties panel. I've ended up with these settings:

The important attributes are "minChars", "partial", "valueList", "valueMarkup" and "var". As long as you do your own computations (which we will discuss in a minute) to find the type ahead suggestions, the setting for "ignoreCase" doesn't matter.
  • minChars: the minimum number of characters that need to be in the input field to start looking for suggestions. I've set this property to 2, as keeping it at 1 (the default) will get me too many suggestions, while setting it to 3 or more kind of defeats the purpose of the type ahead. Set it to whatever suits your task :-)
  • mode: The AJAX request mode. Set it to "partial" to avoid a full page reload.
  • valueMarkup: setting this field to "true" tells the Designer that you will be taking care of creating the markup for the list of suggestions.
  • var: the name of the variable that contains the string entered in the input field. This variable is then accesible when we will create our suggestion list, which brings us to...
  • valueList: Now, this is where the magic happens! This is where you enter your code, so that you get the type ahead suggestions you want presented in the way you want it.

The code I ended up with in the "valueList" property is shown in its full below:

    //Getting the view containing a document for each of the employees
    var searchView:NotesView = session.getDatabase("","myemployees.nsf").getView("employeesTypeAhead");

    // Creating a Lotus Notes search query. Notice the reference to lupkey!
    var query = "(FIELD LastName CONTAINS *" + lupkey +"* OR FIELD FirstName CONTAINS *" + lupkey +"*)";

    // Creating an array to store hits in
    var searchOutput:Array = ["å","åå"];

    // Doing the actual search
    var hits = searchView.FTSearch(query);

    var entries = searchView.getAllEntries();
    var entry = entries.getFirstEntry();

    //Sort the array manually, since Notes doesn't want to sort them alphabetically
    for (i=0; i<hits; i++) {
    searchOutput.push(entry.getColumnValues()[0]);
    entry = entries.getNextEntry();
    }
    searchOutput.sort();

    // Build the resulting output HTML code
    var result = "<ul><li><span class='informal'>Suggestions:</span></li>";

    var limit = Math.min(hits,20);
    for (j=0; j<limit; j++) {
    var name = searchOutput[j].toString();
    var start = name.indexOfIgnoreCase(lupkey)
    var stop = start + lupkey.length;
    //Make the matching part of the name bold
    name = name.insert("</b>",stop).insert("<b>",start);
    result += "<li>" + name + "</li>";
    }

    result += "</ul>";
    return result;

    OK, so I want you to pay special attention to a few things...
    • For the search to work, you should make sure that the documents in the view have fields named "LastName" and "FirstName", or change the code to your needs.
    • By adding a "*" at the start and end of the lupkey variable in my search query, I'm able to get a partial match on any substring in the field values.The built-in feature will only allow partial match from the start of the field value (i.e. searching for "lupkey*").
    • When you do the FTSearch, the entries in the view will automatically be sorted by their search score (some mysterious score calculated by Notes showing how "relevant" the entry is for your query). Since I wanted my results sorted alphabetically, I had to take care of that myself.
    • The return value of the script must be an unordered list (see here for further information)

    8 comments:

    1. Looks great!

      I hope I can be so bold as to suggest a couple of code tweaks (less code/a little more efficient, but maybe not readable enough?):

      after searchOutput.sort()
      // Fetch the first 20 items
      var limitedSearchOutput = searchOutput.slice( 0, 21 );

      // Join the result with li-tags and bold the lupKey matches
      return '<ul><li><span class='informal'>Suggestions:</span></li><li>' + @ReplaceSubstring( limitedSearchOutput, lupKey, '<b>' + lupKey + '</b>' ).join( '</li><li>' ) + '</li></ul>';

      ReplyDelete
    2. Thanks for your input!

      However, I had to do a little tweek on your suggestion to support "case insensitive" bolding, so I ended up with:

      var limitedSearchOutput = searchOutput.slice(0,Math.min(hits,20));

      limitedSearchOutput = limitedSearchOutput.join("</li><li>").replace(eval("/"+@LowerCase(lupkey)+"/g"),"<b>"+@LowerCase(lupkey)+"</b>").replace(eval("/"+@ProperCase(lupkey)+"/g"),"<b>"+@ProperCase(lupkey)+"</b>");

      return "<ul><li><span class='informal'>Suggestions:</span></li><li>" + limitedSearchOutput + "</li></ul>";

      ReplyDelete
    3. Another tweak suggestion.. :P

      Instead of using eval (which supposedly is evil) in your replace, it's more readable if you write new RegExp( lupkey, 'ig' ).

      The second parameter of the RegExp constructor is the modifiers (i -> case insensitive).

      ReplyDelete
    4. True!

      But I still need to do two separate replace operations, since I want to put in the lowercase version where I find the lowercase match, and the propercase version where I find the propercase match.

      ReplyDelete
    5. On second thought, I knew there was a better solution...so I ended up with:

      limitedSearchOutput = limitedSearchOutput.join( "</li><li>" ).replace( new RegExp( lupkey, "gi" ),"<b>" + $& "</b>");

      ReplyDelete
    6. Looks good.

      You should get your blog added to PlanetLotus..

      Here's how to do it: http://planetlotus.org/forum/index.php/topic,27.0.html

      ReplyDelete
    7. Thanks!
      I've sent the e-mail, so let's wait and see what happens!

      ReplyDelete
    8. Hello,

      @Rasmus to the second thought:

      this raises an error: $ not found.
      Do you have an idea, how to fix that?

      ReplyDelete