0

I need to be able to have an autocomplete component in jsf2 (without using any 3rd party libraries) that doesn't store the list of all possible values ahead of time. Instead, it should use AJAX to call a method in the bean which performs the search and returns a list of matches.

Every other implementation I have seen relies on having the full list of options stored, but this leads to large page sizes (and therefore long load times) when you have an incredibly large list (or a large number of autocompletes on a page).

Is it possible to do this?

eljstonge
  • 45
  • 2
  • 11

1 Answers1

2

The answer comes in the form of a composite/custom component, along with a "AutoCompleteUtils" class which will be sort of like a backing bean for the component. The composite portion of the component will be used to generate the html for the component, as well as pass attribute values to AutoCompleteUtils as necessary. AutoCompleteUtils will be used mostly to invoke methods in your bean that you will pass to the component. The custom portion of the component is really only needed for the validation of the values, via the validate(FacesContext context) method.

I'll start by first explaining the basics of the component, and then include a full example with all the bells and whistles at the end.

The Basics

The first step is to declare the component and its resources. So in your taglib.xml file in your components project, you need:

<tag>
    <tag-name>autoCompleteInput</tag-name>
    <component>
        <resource-id>
            components/autoCompleteInput.xhtml
        </resource-id>
    </component>
</tag>

And in your faces-config.xml:

<component>
    <component-type>AutoCompleteInput</component-type>
    <component-class>base.ui.jsf.components.AutoCompleteInput</component-class>
</component>

<managed-bean>
    <managed-bean-name>autoCompleteUtils</managed-bean-name>
    <managed-bean-class>base.ui.jsf.components.utils.AutoCompleteUtils</managed-bean-class>
    <managed-bean-scope>view</managed-bean-scope>
</managed-bean>

The declaration of AutoCompleteUtils here is because it was found that using the usual @ManagedBean @ViewScoped annotations did not work in this case (although honestly I'm not sure why not).

I think the best way to understand how the component will work is to immediately jump to seeing how it will be implemented on the page. If we use a list of users as an example, it would look something like this:

<namespace:autoCompleteInput id="selectUser" 
                             value="#{beanName.userId}">
    <f:attribute name="searchListener" value="#{beanName.searchUsers}" />
    <f:attribute name="existingItemLoader" value="#{beanName.loadExistingUser}" />
</namespace:autoCompleteInput>

As you can see, methods are passed to the component via the f:attribute tag. The searchListener is the method that will be invoked on every key press to perform the search. It accepts a single String argument (the term to be searched for), and returns a List containing the matches found (where the label property is the value to be shown on the page and the value property is the value to be submitted to the bean).

In the case that the component is to be used as part of an editable form which must load data from existing records, an existingItemLoader is also required. The existingItemLoader is a method in your backing bean which accepts a single String argument (the value of the existing item), and returns a String of the label for the item. It is up to the designer to write the logic which retrieves the appropriate label for the specified value.

So in your bean, you must have:

public List<SelectItem> searchUsers(String searchTerm)
{
    List<SelectItem> selectItems = new ArrayList<SelectItem>(); 

    // Some action which retrieves a list of matches:           
    // Sort the list of matches here if desired, as the component does no sorting

    for (UserObject user: matchingUsers)
    {
        selectItems.add(new SelectItem(user.getUserId(), user.getFullName()));
    }

    return selectItems;
}

public String loadExistingUser(String userId)
{
    int id = -1;
    String label = "";

    // Some action which retrieves the label for the item:

    return label;
}

Now to actually call those methods from the component, we need to have a "middle man" method in AutoCompleteUtils. That is because we want to use f:ajax on the input to perform the search of matches, but it can not call a listener directly. So the ajax listener will be a method in AutoCompleteUtils, which will then invoke the searchListener from the bean. So in the composite component, there would be something like this (in a simplified version for now):

<h:inputText id="labelInput"
             value="#{autoCompleteUtils.itemLabel}"
             label="#{cc.attrs.label}"
             autocomplete="off">
        <f:ajax event="keyup" listener="#{autoCompleteUtils.itemLabelChange}" render="matches">
</h:inputText>

<h:selectOneListbox id="matches">
    <f:selectItems value="#{autoCompleteUtils.selectItems}"/>                                   
</h:selectOneListbox>

<h:inputHidden id="valueInput"
               binding="#{cc.valueInput}"
               value="#{cc.attrs.value}" />

The value of the input is being passed to AutoCompleteUtils so that it can then be passed to the searchListener so that it knows what term to search form. It will return the list of matches, which is stored in AutoCompleteUtils, and given to the selectOneListbox. When the user selects an item from the listbox, that item's value will be put into the hidden input, whose value is the one that gets submitted to your bean (which was specified earlier as value="#{beanName.userId}").

Now, to actually invoke our searchListener, we have the following in AutoCompleteUtils:

public void itemLabelChange(AjaxBehaviorEvent event)
{
    selectItems = new ArrayList<SelectItem>();
    ValueExpression searchListener = null;
    AutoCompleteInput input = (AutoCompleteInput) up(event.getComponent(), UIInput.class);
    searchListener = input.getValueExpression("searchListener");

    if (searchListener != null)
    {           
        Class<?>[] paramTypes = {String.class};
        Object[] paramValues = {itemLabel};

        selectItems = (List<SelectItem>) invokeMethod(FacesContext.getCurrentInstance(), searchListener.getExpressionString(), paramValues, paramTypes);
    }
}   

private Object invokeMethod(FacesContext context, String expression, Object[] params, Class<?>[] paramTypes)
{
    ExpressionFactory eFactory = context.getApplication().getExpressionFactory();
    ELContext elContext = context.getELContext();
    MethodExpression method = eFactory.createMethodExpression(elContext, expression, Object.class, paramTypes);

    return method.invoke(elContext, params);
}

private UIComponent up(UIComponent base, Class type) 
{
    UIComponent finder = base.getParent();

    while (!(type.isInstance(finder)) && finder.getParent() != null) {
        finder = finder.getParent();
    }

    if (!type.isInstance(finder)) {
        finder = null;
    }

    return finder;

}

The existingItemLoader works in much the same way. The other big issue that is worthy of discussion here is how to delay the ajax request so that it does not occur on every single keypress. For that, we use javascript. In the composite component, we need:

<script type="text/javascript">
        var #{cc.id}JS = new AutoCompleteInput('#{cc.clientId}', #{cc.attrs.delay});
</script>

Which refers to a autoCompleteInput.js file, which contains the following:

function AutoCompleteInput(clientID, ajaxDelay)
{
var labelInput = $("#" + clientID + "-labelInput");
var valueInput = $("#" + clientID + "-valueInput");
var matchesList = $("#" + clientID + "-matches");
var matchesCount = 0;

labelInput.each(function(index, input) {

    var onkeyup = input.onkeyup;
    input.onkeyup = null;

    $(input).on("keydown", function(e){                 
        var key;
        if (typeof e.which != "undefined"){
            key = e.which;
        }
        else{
            key = e.keyCode;
        }   

        if (key == 40){ // If down, set focus on listbox
            e.preventDefault();
            matchesList.focus()
        }
        else if(key != 37 && key != 38 && key != 39){ // Reset the value on any non-directional key to prevent the user from having an invalid label but still submitting a valid value.
            valueInput.val('') 
        }
    });     

    $(input).on("keyup", function(e) {

        var key;
        if (typeof e.which != "undefined"){
            key = e.which;
        }
        else{
            key = e.keyCode;
        }   

        if (key == 37 || key == 38 || key == 39){ // If left, right, or up, do not perform the search
            return false;
        }

        else{ // Otherwise, delay the ajax request by the specified time
            delay(function() { onkeyup.call(input, e); }, ajaxDelay);
        }

    });
});

var delay = (function() {
    var timer = 0;

    return function(callback, timeout) {

        clearTimeout(timer);
        timer = setTimeout(callback, timeout);
    };
})();
}

We also need something to determine to to hide/show the list of matches (it should be defaulted to hidden). We only want to show it if matches are found, but we don't know if matches are found until the AJAX request has finished. To do that, we change our input's f:ajax to the following:

<f:ajax event="keyup" listener="#{autoCompleteUtils.itemLabelChange}" render="matches" onevent="function(data) { if (data.status === 'success') { #{cc.id}JS.checkMatches() }}"/>

Which calls the function:

this.checkMatches = function()
{   
    labelInput = $("#" + clientID + "-labelInput");
    matchesList = $("#" + clientID + "-matches");
    matchesCount = matchesList.children("option").length;   
    if (matchesCount > 1 && labelInput.val().length > 0){
        matchesList.show();
    }
    else if (matchesCount == 1){ // If the search returned only one possible match, automatically select it
        var item = matchesList.children("option").get(0);
        selectItem(item.text, item.value);
        setTimeout(function(){matchesList.fadeOut()}, 500)

    }
    else{
        matchesList.hide();
    }           
}

Full Example

That should cover most of the basics of the component, so I'll cut to the chase and just show the full example. It includes keyboard accessibility and automatic resizing for the listbox. It also includes a "strict" attribute: If true, requires that the user manually selects one of the items from the list of suggestions. If false, the component will submit whatever value the user has entered (to allow for partial/custom values).

Composite Component

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml"   
    xmlns:h="http://java.sun.com/jsf/html"
    xmlns:f="http://java.sun.com/jsf/core"
    xmlns:ui="http://java.sun.com/jsf/facelets"
    xmlns:jsf="http://xmlns.jcp.org/jsf"
    xmlns:composite="http://java.sun.com/jsf/composite"
    xmlns:c="http://java.sun.com/jsp/jstl/core"
    xmlns:pt="http://xmlns.jcp.org/jsf/passthrough"
    >

<composite:interface componentType="AutoCompleteInput">
    <composite:attribute name="value"/>
    <composite:attribute name="delay" default="400" type="java.lang.Integer"/>
    <composite:attribute name="strict" default="true" type="java.lang.Boolean"/>
    <composite:attribute name="required" default="false" type="java.lang.Boolean"/>
    <composite:attribute name="requiredMessage"/>
    <composite:attribute name="placeholder" default="" />
    <composite:attribute name="label"/>
    <composite:attribute name="inputSize" default="25" type="java.lang.Integer"/>
    <composite:attribute name="listboxSize" default="20" type="java.lang.Integer"/>
    <composite:attribute name="maxlength" default="10000" type="java.lang.Integer"/>
</composite:interface>

<composite:implementation>
    <h:outputScript name="components/js/autoCompleteInput.js" />
    <h:outputStylesheet name="components/css/autoCompleteInput.css"/>


    <div id="#{cc.clientId}-container" 
         class="#{cc.attrs.styleClass} has-success"
         style="#{cc.attrs.style}">

         <h:selectBooleanCheckbox id="strictCheckbox"
                                  binding="#{cc.strictCheckbox}"
                                  value="#{cc.attrs.strict}" 
                                  style="display:none"/>

        <h:inputHidden id="clientIdInput"
                       value="#{cc.clientId}"
                       binding="#{autoCompleteUtils.clientIdInput}" 
                       pt:disabled="disabled"/>

        <h:inputHidden id="utilsValueInput"
                       value="#{cc.attrs.value}"
                       binding="#{autoCompleteUtils.itemValueInput}" />

        <h:inputHidden id="listboxSizeInput"
                       binding="#{autoCompleteUtils.listboxSizeInput}"
                       value="#{cc.attrs.listboxSize}" />

        <h:inputHidden id="valueInput"
                       binding="#{cc.valueInput}"
                       value="#{cc.attrs.value}" />

        <h:inputText id="labelInput"
                     value="#{autoCompleteUtils.itemLabel}"
                     binding="#{cc.labelInput}"
                     pt:placeholder="#{cc.attrs.placeholder}"
                     label="#{cc.attrs.label}"
                     required="#{cc.attrs.required}"
                     requiredMessage="#{cc.attrs.requiredMessage}"
                     autocomplete="off"
                     size="#{cc.attrs.inputSize}"
                     styleClass="form-control focusable"
                     maxlength="#{cc.attrs.maxlength}"                       
                     onblur="#{cc.id}JS.checkFocus(#{cc.attrs.strict})"
                     onfocus="#{cc.id}JS.showMatches()"
                     rendered="#{autoCompleteUtils.loadExistingItem}">
            <f:ajax event="keyup" listener="#{autoCompleteUtils.itemLabelChange}" render="matches" onevent="function(data) { if (data.status === 'success') { #{cc.id}JS.inputKeyup(#{cc.attrs.strict}) }}"/>
        </h:inputText>

        <h:selectOneListbox id="matches"
                            onkeyup="#{cc.id}JS.listboxKeypress(this)"
                            onclick="#{cc.id}JS.listboxClick(this)"
                            tabindex="0"
                            onblur="#{cc.id}JS.checkFocus(#{cc.attrs.strict})"
                            styleClass="matchesContainer focusable"
                            size="#{autoCompleteUtils.listboxSize}">
            <f:selectItems value="#{autoCompleteUtils.selectItems}"/>

        </h:selectOneListbox>
    </div>

    <script type="text/javascript">
        var #{cc.id}JS = new AutoCompleteInput('#{cc.clientId}', #{cc.attrs.delay});
    </script>
</composite:implementation>
</html>

Custom Component

public class AutoCompleteInput extends UIInput implements NamingContainer{

private UIInput valueInput;
private UIInput labelInput;
private UISelectBoolean strictCheckbox;

@Override
public String getFamily(){
    return "javax.faces.NamingContainer";
}

@Override
public Object getSubmittedValue() {     
    return valueInput.getSubmittedValue();
}


@Override
public void validate(FacesContext context) {

    String itemLabel = (String) labelInput.getSubmittedValue();
    String itemValue = (String) getSubmittedValue();
    Boolean strict = (Boolean) strictCheckbox.getValue();

    if ((StringUtils.isEmpty(itemValue) || itemLabel.equals("-1")) && !StringUtils.isEmpty(itemLabel) && strict == true)
    {
        String message = (String)JsfUtils.resolveValueExpression("#{template.validationErrorAutoCompleteInputValue}");
        JsfUtils.addErrorMessageForComponent(getId(), message, message);

        setValid(false);
    }

    else if (isRequired() && (StringUtils.isEmpty(itemLabel) || itemLabel.equals("-1") || StringUtils.isEmpty(itemValue)))
    {
        String message = (String)JsfUtils.resolveValueExpression("#{template.validationErrorAutoCompleteInputLabel}");
        String requiredMessage = (String)getAttributes().get("requiredMessage");

        if (StringUtils.isNotBlank(requiredMessage)){
            message = requiredMessage;
        }

        JsfUtils.addErrorMessageForComponent(getId(), message, message);

        setValid(false);
    }


    super.validate(context);
}


public UIInput getvalueInput() {
    return valueInput;
}


public void setvalueInput(UIInput valueInput) {
    this.valueInput = valueInput;
}


public UIInput getLabelInput() {
    return labelInput;
}


public void setLabelInput(UIInput labelInput) {
    this.labelInput = labelInput;
}

public UISelectBoolean getStrictCheckbox() {
    return strictCheckbox;
}

public void setStrictCheckbox(UISelectBoolean strictCheckbox) {
    this.strictCheckbox = strictCheckbox;
}
}

Component Utils

public class AutoCompleteUtils implements Serializable {

private static final long serialVersionUID = 1636810191718728665L;
private String itemLabel;
private List<SelectItem> selectItems = new ArrayList<SelectItem>();
private UIInput itemValueInput;
private UIInput clientIdInput;
private UIInput listboxSizeInput;

public boolean isLoadExistingItem()
{
    String clientId = (String) clientIdInput.getValue();
    ValueExpression itemLoader = null;
    AutoCompleteInput input = (AutoCompleteInput) FacesContext.getCurrentInstance().getViewRoot().findComponent(clientId);

    itemLoader = input.getValueExpression("existingItemLoader");

    if (itemLoader != null)
    {           
        String itemValue = itemValueInput.getValue().toString();
        if (!StringUtils.isEmpty(itemValue) && !itemValue.equals("-1"))
        {
            Class<?>[] paramTypes = {String.class};
            Object[] paramValues = {itemValue};

            itemLabel = (String) invokeMethod(FacesContext.getCurrentInstance(), itemLoader.getExpressionString(), paramValues, paramTypes);
        }
    }
    return true;
}

public void itemLabelChange(AjaxBehaviorEvent event)
{
    selectItems = new ArrayList<SelectItem>();
    ValueExpression searchListener = null;
    AutoCompleteInput input = (AutoCompleteInput) up(event.getComponent(), UIInput.class);
    searchListener = input.getValueExpression("searchListener");

    if (searchListener != null)
    {           
        Class<?>[] paramTypes = {String.class};
        Object[] paramValues = {itemLabel};

        selectItems = (List<SelectItem>) invokeMethod(FacesContext.getCurrentInstance(), searchListener.getExpressionString(), paramValues, paramTypes);
    }
}   

 public int getListboxSize()
 {
     int size = (Integer) listboxSizeInput.getValue();
     if (selectItems.size() > 0 && selectItems.size() < size)
     {
         size = selectItems.size();
     }
     return size;
 }

private Object invokeMethod(FacesContext context, String expression, Object[] params, Class<?>[] paramTypes)
{
    ExpressionFactory eFactory = context.getApplication().getExpressionFactory();
    ELContext elContext = context.getELContext();
    MethodExpression method = eFactory.createMethodExpression(elContext, expression, Object.class, paramTypes);

    return method.invoke(elContext, params);
}

private UIComponent up(UIComponent base, Class type) 
{
    UIComponent finder = base.getParent();

    while (!(type.isInstance(finder)) && finder.getParent() != null) {
        finder = finder.getParent();
    }

    if (!type.isInstance(finder)) {
        finder = null;
    }

    return finder;

}

public String getitemLabel() {
    return itemLabel;
}


public void setitemLabel(String itemLabel) {
    this.itemLabel = itemLabel;
}

public UIInput getClientIdInput() {
    return clientIdInput;
}

public void setClientIdInput(UIInput clientIdInput) {
    this.clientIdInput = clientIdInput;
}

public UIInput getItemValueInput() {
    return itemValueInput;
}

public void setItemValueInput(UIInput itemValueInput) {
    this.itemValueInput = itemValueInput;
}

public UIInput getListboxSizeInput() {
    return listboxSizeInput;
}

public void setListboxSizeInput(UIInput listboxSizeInput) {
    this.listboxSizeInput = listboxSizeInput;
}

public List<SelectItem> getselectItems() {
    return selectItems;
}

public void setselectItems(List<SelectItem> selectItems) {
    this.selectItems = selectItems;
}
}

Javascript

function AutoCompleteInput(clientID, ajaxDelay)
{
var labelInput = $("#" + clientID + "-labelInput");
var valueInput = $("#" + clientID + "-valueInput");
var matchesList = $("#" + clientID + "-matches");
var matchesCount = 0;

labelInput.each(function(index, input) {

    var onkeyup = input.onkeyup;
    input.onkeyup = null;

    $(input).on("keydown", function(e){                 
        var key;
        if (typeof e.which != "undefined"){
            key = e.which;
        }
        else{
            key = e.keyCode;
        }   

        if (key == 40){ // If down, set focus on listbox
            e.preventDefault();
            matchesList.focus()
        }
        else if(key != 37 && key != 38 && key != 39){ // Reset the value on any non-directional key to prevent the user from having an invalid label but still submitting a valid value.
            valueInput.val('') 
        }
    });     

    $(input).on("keyup", function(e) {

        var key;
        if (typeof e.which != "undefined"){
            key = e.which;
        }
        else{
            key = e.keyCode;
        }   

        if (key == 37 || key == 38 || key == 39){ // If left, right, or up, do not perform the search
            return false;
        }

        else{ // Otherwise, delay the ajax request by the specified time
            delay(function() { onkeyup.call(input, e); }, ajaxDelay);
        }

    });
});

var delay = (function() {
    var timer = 0;

    return function(callback, timeout) {

        clearTimeout(timer);
        timer = setTimeout(callback, timeout);
    };
})();

this.inputKeyup = function(strict)
{   
    labelInput = $("#" + clientID + "-labelInput");
    matchesList = $("#" + clientID + "-matches");
    matchesCount = matchesList.children("option").length;   
    if (matchesCount > 1 && labelInput.val().length > 0){
        matchesList.show();
    }
    else if (matchesCount == 1 && strict){
        var item = matchesList.children("option").get(0);
        selectItem(item.text, item.value);
        setTimeout(function(){matchesList.fadeOut()}, 500)

    }
    else{
        matchesList.hide();
    }           
}


this.listboxKeypress = function(input)
{
    $(input).on("keydown", function(e){
        var key;
        if (typeof e.which != "undefined"){
            key = e.which;
        }
        else{
            key = e.keyCode;
        }   
        if (key == 13){ // If enter, then select the item
            e.preventDefault();
            var label = $(input).find('option:selected').text();
            var value = $(input).val();
            selectItem(label, value);   
            matchesList.hide();
        }           
    });     
}

this.listboxClick = function(input)
{
    var label = $(input).find('option:selected').text();
    var value = $(input).val();
    selectItem(label, value);   
    matchesList.hide();
}

this.showMatches = function()
{
    if (typeof matchesList != "undefined" && matchesCount !=0 && labelInput.val().length > 0){
        matchesList.show();
    }
}

this.checkFocus = function(strict)
{
    setTimeout(function () {
        if (document.activeElement.className.indexOf("focusable") == -1 &&
            typeof matchesList != "undefined"){
            matchesList.hide();
        }
    }, 100);

    if (strict == false){
        valueInput.val(labelInput.val());
    }
}

function selectItem(itemLab, itemVal)
{

    labelInput.val(itemLab);
    valueInput.val(itemVal);
}

}

Closing Comments

I have to admit that there are some things about this component that I don't really like, particularly the use of hidden inputs in the facelet to pass attribute values. But this is what I found to work. However, I am still relatively new to JSF (and very new to javascript) so I would appreciate any comments and suggestions on how to improve this!

eljstonge
  • 45
  • 2
  • 11
  • 1
    It was later found that this component has some issues when more than one is used on the same page. To fix that, AutoCompleteUtils should be changed to request scoped, and the itemLabel variable in AutoCompleteUtils should be changed to a Map, where the key is the client ID of the component and the value is the label for that component. – eljstonge Jul 16 '14 at 13:11