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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | /** * 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 <strong>
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.).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /** * 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 <input>
field for the user’s query and a <ul>
for the search results. The <ul>
will obviously be hidden if we’ve no results to show. We wrap the whole thing in a <div>
with class autocomplete
, which we’ll use when binding the whole thing together later.
For the <input>
field, we bind our AutoComplete
‘s q
attribute to Knockout’s textInput
data binding (textInput: q
), so that every time the value of the <input>
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 <input>
(event: {keyup: loadSuggestions}
).
The HTML for the <ul>
is also fairly straight-forward. If we have any suggestions to show, we want to add the has-results
class to the <ul>
(css: {'has-results': suggestions().length > 0}
) and of course hide the <ul>
when there’s less than 3 characters typed in the <input>
(visible: q().length > 2
). Assuming we have suggestions to show, we loop through the suggestions
array, displaying an <li>
for each, containing the suggestion’s labelFormatted
and translatedType
, as well as adding some attributes to the surrounding <a>
(data-bind="attr: {href: url ...
).
1 2 3 4 5 6 7 8 9 10 11 12 | <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:
1 2 3 4 5 6 | $('.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 <ul>
and it’s <li>
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | /** * 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 <ul>
would change to <ul class="autocomplete-results" data-bind="autoComplete, css: {'has-results': suggestions ...
– note the addition of autoComplete
on the data-bind
.
No Comments
Leave a reply
You must be logged in to post a comment.