Search

What the Quote?

"You're not talking about the software, right? You're talking about the disease?"

David Bradshaw

"No means no, Ra."

Tim Tripcony

"Infinity is the new finity"

Eric Romo

« automatic recompilation - a great idea in theory but not in practice | Main| XPages safety tip - avoid using hyphens in control ID names »

XPages namepicker using standard typeahead

Category xpages
A couple days ago, Julian Buss released a very cool namepicker for XPages. This article will show you how to enable a similar feature in your XPage applications using only the standard typeahead functionality.

Typically, when enabling typeahead for an Edit Box control, we populate the valueList attribute with the full list of options that should be selectable - @DbColumn or an equivalent. In other words, this expression evaluates to the list that should be searched for partial matches, and when the user starts typing, Domino filters that full list. There are (at least) two limitations to this approach:
  1. It doesn't scale. The typical approach to typeahead will fail when used as a namepicker for almost any organization, because there are simply too many people in the average Domino Directory. This is true of any large dataset, due both to the time it takes to query it, and to the inherent limitations of some methods of doing so.
  2. It's boring. I love that typeahead is so easy now, but in many cases, having additional contextual information for each suggested match would make the typeahead results far more useful to the end user.
The good news is that, like so much of what can be done with XPages, there's an easy (and, until recently, undocumented) way to push this feature to the next level, allowing you to add whatever content (and style) you like to your typeahead results. Before I explain this in detail, let's skip straight to dessert:



The key to this particular technique is the mysterious valueMarkup attribute associated with the AJAX Type ahead control (in the Source, <xp:typeAhead/>). When you enable typeahead for any Edit Box control, it automatically adds an AJAX Type ahead control as a child element of that Edit Box. Some of its properties can be set directly from within the properties pane of the parent Edit Box, but (again, like so much of the functionality of XPages), some properties can only be set by navigating to the child control - either via the Outline or directly within the Source:



Once you've selected that control, you'll see a couple useful settings in the All Properties pane:



When valueMarkup is set to true, this tells Domino that you will handle all of the filtering yourself - in other words, instead of specifying an expression that evaluates to the entire dataset your users can choose from (in the case of a namepicker, the entire Domino Directory), your valueList attribute will be assumed by Domino to be an expression that returns only the matching results. So how do you actually do that filtering? Well, that's what the var attribute is for.

If you're familiar with repeat controls, you're already accustomed to defining a var attribute; in a repeat, that's where you specify what variable should be bound to each iterated member of the repeat. In the case of an AJAX Type ahead control, however, the var attribute designates the variable that will be bound, in each typeahead request, to what the user typed. Hence, in the above example, every time the valueList attribute is evaluated (namely, during every typeahead request), the variable named searchValue has a String value matching whatever characters the user has already entered. That String is passed as an argument to the directoryTypeahead function, which is contained in a script library I wrote for the purpose of this article... you could certainly define this functionality locally to each control, but for something this universal, it's obviously better to define it in a script library. Let's take a look at what that function does:

var directoryTypeahead = function (searchValue:string) {
    // update the following line to point to your real directory
    var directory:NotesDatabase = session.getDatabase("", "test/spnames.nsf");
    var allUsers:NotesView = directory.getView("($Users)");
    var matches = {};
    var includeForm = {
        Person: true,
        Group: true
    }
    var matchingEntries:NotesViewEntryCollection = allUsers.getAllEntriesByKey(searchValue, false);
    var entry:NotesViewEntry = matchingEntries.getFirstEntry();
    var resultCount:int = 0;
    while (entry != null) {
        var matchDoc:NotesDocument = entry.getDocument();
        var matchType:string = matchDoc.getItemValueString("Form");
        if (includeForm[matchType]) { // ignore if not person or group
            var fullName:string = matchDoc.getItemValue("FullName").elementAt(0);
            if (!(matches[fullName])) { // skip if already stored
                resultCount++;
                var matchName:NotesName = session.createName(fullName);
                matches[fullName] = {
                    cn: matchName.getCommon(),
                    photo: matchDoc.getItemValueString("photoUrl"),
                    job: matchDoc.getItemValueString("jobTitle"),
                    email: matchDoc.getItemValueString("internetAddress")
                };
            }            
        }
        if (resultCount > 9) {
            entry = null; // limit the results to first 10 found
        } else {
            entry = matchingEntries.getNextEntry(entry);
        }
    }
    var returnList = "<ul>";
    for (var matchName in matches) {
        var match = matches[matchName];
        var matchDetails:string =
            "<li><table><tr><td><img class=\"avatar\" src=\"",
            match.photo,
            "\"/></td><td valign=\"top\"><p><strong>",
            match.cn,
            "</strong></p><p><span class=\"informal\">",
            match.job,
            "<br/>",
            match.email,
            "</span></p></td></tr></table></li>"
        ].join("");
        returnList += matchDetails;
    }
    returnList += "</ul>";
    return returnList;
}



Most of the above probably speaks for itself, but I want to specifically mention two basic concepts about the format of the value it returns:
  1. The return value for the valueList expression must be a <ul/>. The client-side JavaScript that processes typeahead requests is expecting to be sent the markup of an unordered list. When the valueMarkup attribute is omitted (or false), Domino does this automatically by filtering the valueList and formatting any matches as <li/>'s inside a <ul/>. Conversely, by setting valueMarkup to true, we've committed to doing both the filtering and the formatting ourselves, so the String we return must conform to the same format.
  2. Non-value text must be wrapped in a special span. This part's not very intuitive, but once you're aware of it, it's easy to format the results any way you like: when the user selects a value from the typeahead suggestions, Domino ignores any tags inside the corresponding <li/> and treats the combination of all "tagless" text runs as the selected value. In the above example, for instance, the first text it finds that is inside a tag - but not part of one - is the user's common name. Because we want the job and the email to appear in the typeahead suggestion, but we don't want it to be considered part of the selected value, it has to be wrapped inside a span with a class of "informal". This tells Domino to ignore anything inside that span, treating only the common name as the actual value the user is selecting. Yes, this is a weird convention, and there would have been no way of guessing it without IBM spelling it out for us... but now that they have, we can add all manner of useful information into our typeahead results without it injecting this additional information into the field when the user selects a suggestion.
There are, of course, a couple other things you'll want to keep in mind. For instance, since we're doing all the filtering ourselves, attributes like ignoreCase and maxValues are ignored, so you may want to ensure that the logic you're using to return the list handles any case sensitivity issues you may have, and you'll almost certainly want to include your own limit to keep it from returning everything that matches (the above example returns a maximum of 10 results). Finally, while the primary limitation of the default typeahead behavior is that it doesn't scale, using this manual typeahead filtering doesn't automatically provide infinite scalability. Implementing the above code as a namepicker for an enormous directory will work better than the standard @DbColumn approach (because at least it won't fail), but the results will be slow simply because of how long the getAllEntriesByKey call takes, especially if you have a low minimum character count (imagine, for example, searching ($Users) for everything starting with c: that's right, it'll return an entry for every document in the view, because there's a canonicalized entry for each in addition to all the other permutations). We've actually implemented a far more robust version of this which not only leverages the Directory API to ensure the results include any matching entries from cascaded address books and LDAP directories in addition to the standard address book, but also provides intelligent sorting of the results and takes advantage of additional features of XPages to provide maximum scalability. Naturally, I haven't been given permission to disclose the source of that beast... but don't worry, here at Lotus 911 we only use our powers for good.

You can try out a live demo of this technique, and of course the example database is also available for download.

Comments

Gravatar Image1 - @Nathan - <xc:impulse loaded="false" />

Gravatar Image2 - Just couldn't stand it, could ya? Emoticon

Gravatar Image3 - Just excellent, very cool.
Tx Tim.

Gravatar Image4 - Holy Crap that's a great blog post!!!!!

Thanks for sharing this.


Gravatar Image5 - Thanks for sharing! Very cool.

Gravatar Image6 - BTW this line :

var directory:NotesDatabase = session.getDatabase("", "test/spnames.nsf");

Will point to the local machine if your running Xpages in the client instead of pointing to the server. You can resolve it by using database.server() instead of the the ""

Gravatar Image7 - As i said in the recent xPages podcast for Taking Notes : "Whoever creates the best namepicker will have a path beaten to their door."

This is a great namepicker, as is Julian's. I can't wait to see how it evolves.

Gravatar Image8 - @7 - Good point, Dec. Just for clarification, the method call would be database.getServer().

Gravatar Image9 - Well done Tim. Thank you for sharing.

Gravatar Image10 - Holy sh** batman. You're gonna have me working late all this week as I try to impress a few of my clients with this. Awesome stuff Tim.

Gravatar Image11 - Fantastic, Tim! Have to jump right into designer to see what other useful stuff is there in the TypeAhead properties. Very cool!

Gravatar Image12 - Great stuff, Tim! Thanks!

I've also never seen anyone do string matching the way you did with includeForm, so I gotz me some bonus learnin', too. Emoticon

Gravatar Image13 - Excellent. Thanks very much for the tip. I got it working with your example now to get it into my app which is causing some issue, but I will get it working.

Gravatar Image14 - Thanks for the post, very interesting. Have you found anyway to trigger an event (such as an instant search) when you click on the Type Ahead entry rather than it just entering the text in the non-'informal' class span tags into the input box.

Gravatar Image15 - Great solution, except I haven't been successful getting the selected entry returned to the input box other than by navigating to (using the arrow keys) or highlighting the entry, and hitting Enter. Shouldn't it work by selecting it with the mouse? Any ideas as to what I may have done wrong?

Thanks for sharing this tip!!!

Post A Comment

:-D:-o:-p:-x:-(:-):-\:angry::cool::cry::emb::grin::huh::laugh::lips::rolleyes:;-)