Autocomplete with KnockoutJS

3rd February, 2016 - Posted by david

Recently I started a new job at a company that is looking to transition away from a customised, unstructured, jQuery module set up to use KnockoutJS and RequireJS for it’s modules. This approach was chosen because the core platform is based on Magento and the forthcoming Megento 2 uses KnockoutJS heavily throughout it’s frontend templates. As a good starting point and proof of concept, we decided to look at converting our existing custom-autocomplete module from a combination of EJS and jQuery to pure KnockoutJS. Luckily for me, I was the one who got to implement it, and thus learn a new skill!

I’m not going to go into the ins and outs of how KnockoutJS works but in short it’s a MVVM system, where you have Models, Views and ViewModels, the latter being the interface between the other 2, the client and the server. This autocomplete was a standard input field, whereby on typing 3 characters, an AJAX call is made to the server looking for strings that matched the search string and displayed a clickable list of results underneath the input field. Additionally, you could use the arrow keys to select items in the menu, as well as the mouse. We also have different instances of the autocomplete, to search for different types of entities (e.g. searching for a product vs. search for a place), so we need the code to work with each.

From this point on I’m going to assume at least a basic knowledge of KnockoutJS, how it uses

data-bind

etc.

The View Model

So, first up we’ll want an Autocomplete viewModel, to handle the DOM events in the view (e.g.

keyup

etc.), fetch data from the server and call the correct model to format the received data. It’ll have 2 observable attibutes:

suggestions

, an array of suggestion objects, and

q

, the incoming query from the user. As a parameter we’ll pass it the model type to format the suggestions (e.g.

LocationSuggestion

below) and we’ll have functions to fetch suggestions as JSON from the server (

loadSuggestions

), add them to our

suggestions

array (

addSuggestion

, formatting the data via the model along the way) and clear our array (

clearSuggestions

), as well a helper function to look for valid character key presses (

validKey

). None of this is overly complex and it’s well commented, so I’ll just leave the whole class here:

/**
 * AutoComplete viewModel. Handles the observable events from the view, requests data from the server and calls
 * the corresponding Model above to format fetched data
 *
 * @param  options  JSON object of options, to contain:
 *                  - url: URL to request the search results from
 *                  - suggestionEntry: required model (i.e. one of the above) to format the data
 */

function AutoComplete(options) {

    // KnockoutJS standard is to refer to 'self' instead of 'this' throughout the class.
    // It's because 'this' in a sub-function refers to the function, not the viewModel
    var self = this;

    $.extend(self, options);

    // Array to store suggestions received from the server
    self.suggestions = ko.observableArray([]);

    // Value of input field that user queries
    self.q = ko.observable('');

    // Attribute to store the current AJAX request. Means we can cancel the current request if the observable 'q' changes
    self.ajaxRequest = null;

    /**
     * Append a JSON search result to our suggestions array. Instantiates the correct model to format the data
     * (view is rendered automatically by KnockoutJS)
     *
     * @param  suggestion  JSON object, returned from search server
     */

    self.addSuggestion = function (suggestion) {
        self.suggestions.push(new self.suggestionEntry(suggestion, self.q()));
    }

    /**
     * If the user has entered a valid search string (more than 3 latin-ish or punctuation characters), cancel the current AJAX request (if any),
     * fetch the data from the server, format it and store in 'suggestions' array
     *
     * @param  obj  HTML <input> element (not used)
     * @param  event  The event object for the triggered event (keydown)
     */

    self.loadSuggestions = function(obj, event) {
        // if a valid, non-control, character has been typed
        if (self.validKey(event)) {
            self.clearAjaxRequest(); // cancel current request
            var q = self.q();
            // if they've entered less than 3 characters, just clear the array, which clears the suggestions drop down
            if (q.length < 3) {
                self.clearSuggestions();
                return;
            }

            // request data from the server
            self.ajaxRequest = $.getJSON(self.url, {term: q}, function(response) {
                self.clearSuggestions(); // clear out current values
                for (var i = 0; i < response['suggestions'].length; i++) {
                    self.addSuggestion(response['suggestions'][i]); // add search result
                }
            });
        }
    }

    self.clearSuggestions = function() {
        self.suggestions([]);;
    }

    self.clearAjaxRequest = function() {
        if (self.ajaxRequest) {
            self.ajaxRequest.abort();
            self.ajaxRequest = null;
        }
    }

    /**
     * Check what key was pressed is valid: if it was alphanumeric, space, punctuation or backspace/delete
     */

    self.validKey = function(event) {
        var keyCode = event.keyCode ? event.keyCode : event.which;
        // 8 is backspace, 46 is delete
        return keyCode == 8 || keyCode == 46 || /^[a-zA-Z0-9\s\-_\+=!"£$%^&*\(\)\[\]\{\}:;@'#~<>,\.\/\?ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž]$/.test(event.key);
    }
}

We also store the current AJAX request with the object in the

ajaxRequest

attribute. By doing this, we can cancel any existing requests as the user types more keys. So, when they type the first 3 characters, a request is fired off; when they type the 4th character, we’ll cancel the existing request if it hasn’t finished and do a new search for the longer string.

The Model(s)

For this example, I mentioned above that the user could be searching for locations or products; let’s go with a location search for this example. Below, we have a class

LocationSearch

, which takes a JSON object that was returned from our server, formats the matched string by wrapping

&lt;strong&gt;

tags around the bit of the string that was matched (via the global

accentInsensitiveRegex

function, which I unfortunately don’t have the code for), generates the URL for the result and translates the type of location found (.e.g city, county etc.).

 /**
  * Model for a location search result. Formats data to be displayed in the HTML view
  *
  * @param  data  JSON object
  * @param  q     User's original query
  *
  */

function LocationSuggestionEntry(data, q) {
    this.type = (data.type === 'region' && Number(data.id) > 999) ? 'country' : data.type;

    var separator = data.url.indexOf('?') !== -1 ? '&' : '?';
    this.url = data.url + separator + 'autocomplete=1&ac-text=' + q;

    this.id = data.id;

    this.label = data.label;
    // wrap what the user typed in a <strong> tag
    var regexp = new RegExp('(' + accentInsensitiveRegex(q) + ')', 'gi');
    this.labelFormatted = data.label.replace(regexp, '<strong>$1</strong>');

    this.typeTranslated = Translator.translate('autocomplete::type::' + this.type);
}

The View

So, for the HTML side, we need an

&lt;input&gt;

field for the user’s query and a

&lt;ul&gt;

for the search results. The

&lt;ul&gt;

will obviously be hidden if we’ve no results to show. We wrap the whole thing in a

&lt;div&gt;

with class

autocomplete

, which we’ll use when binding the whole thing together later.

For the

&lt;input&gt;

field, we bind our

AutoComplete

‘s

q

attribute to Knockout’s

textInput

data binding (

textInput: q

), so that every time the value of the

&lt;input&gt;

changes,

q

will too. Additionally, we want to fire our

loadSuggestions

function, which will check the length of

q

and fetch suggestions from the server if it’s greater than 3 characters; this is achieved by calling

loadSuggestions

when a Javascript

keyup

event is fired on the

&lt;input&gt;

(

event: {keyup: loadSuggestions}

).

The HTML for the

&lt;ul&gt;

is also fairly straight-forward. If we have any suggestions to show, we want to add the

has-results

class to the

&lt;ul&gt;

(

css: {'has-results': suggestions().length > 0}

) and of course hide the

&lt;ul&gt;

when there’s less than 3 characters typed in the

&lt;input&gt;

(

visible: q().length > 2

). Assuming we have suggestions to show, we loop through the

suggestions

array, displaying an

&lt;li&gt;

for each, containing the suggestion’s

labelFormatted

and

translatedType

, as well as adding some attributes to the surrounding

&lt;a&gt;

(

data-bind="attr: {href: url ...

).

<div class="autocomplete">
    <input class="search-input" data-bind="textInput: q, event: {keyup: loadSuggestions}" type="text" name="text" placeholder="Enter a location..."/>
    <ul class="autocomplete-results" data-bind="css: {'has-results': suggestions().length > 0}, visible: q().length > 2">
        <!-- ko foreach: suggestions -->
        <li>
            <a data-bind="attr: {href: url, 'data-type': type, 'data-id': id, 'data-label': label}">
                <span data-bind="html: labelFormatted"></span> - <span data-bind="text: translatedType"></span>
            </a>
        </li>
        <!-- /ko -->
    </ul>
</div>

Binding It All Together

At this point we need to bind our

AutoComplete

object to our

div.autocomplete

, passing it the search URL and suggestion type as parameters:

$('.autocomplete').each(function() {
    ko.applyBindings(new AutoComplete({
        url: '/suggestions/locations/',
        suggestion: LocationSuggestion
    }), this);
});

One last bit…

To get all this working nicely, you’ll need CSS for the

&lt;ul&gt;

and it’s

&lt;li&gt;

children. Additionally, you might want code to look out for when the up and down arrows are pressed on the keyboard and highlight the next row correctly. The code I have for this isn’t mine, so I’m not going to put it here. However, I will point out that to add any fancy jQuery on your view, i.e. to handle these up/down arrow keypress events, you can use KnockoutJS’s custom binding handlers. This is to keep business and presentation logic separate from each other. So, in JS you’d have something like:

/**
 * This custom binding is how knockout lets you set up your HTML elements. It's separate from the viewModel, which
 * should purely deal with business logic, not display stuff.
 *
 * Sample usage: <input type="text" data-bind="autoComplete">
 */

ko.bindingHandlers.autoComplete = {
    /**
     * Called when the HTML element is instantiated
     */

    init: function(element, valueAccessor, allBindings, viewModel) {
        var $el = $(element);

        // specific jQuery code goes here
    },
    update: function() {} // not needed here
};

The HTML for your

&lt;ul&gt;

would change to

&lt;ul class="autocomplete-results" data-bind="autoComplete, css: {'has-results': suggestions ...

– note the addition of

autoComplete

on the

data-bind

.

Tags: javascript knockoutjs | david | 3rd Feb, 2016 at 22:31pm | No Comments

No Comments

Leave a reply

You must be logged in to post a comment.