Saturday, February 15, 2014

Knockout & AJAX Autocomplete with MVC4

The other day I was trying to get a auto-complete working in my web application.(MVC4)

What I had to design was a simple lookup tool. you know, like google.

As usual, I did not and never will try to reinvent the wheel. so, these are what I went through..and tried...and failed.
http://stackoverflow.com/questions/7537002/autocomplete-combobox-with-knockout-js-template-jquery
http://jsfiddle.net/rniemeyer/PPsRC/
http://jsfiddle.net/rniemeyer/MJQ6g/
http://stackoverflow.com/questions/7537002/autocomplete-combobox-with-knockout-js-template-jquery/7538860#7538860
http://stackoverflow.com/questions/15694425/connect-knockout-and-jqueryui-autocomplete/15713822#15713822
https://gist.github.com/studgeek/1214258
http://opensoul.org/2011/06/23/live-search-with-knockoutjs/

The jsfiddle ones are working well, but not in my case. I didn't have much time to fix the issues so I kept on looking.

Then I found this solution.. http://jsfiddle.net/HfNk9/3/. It also uses a knockout binding handler. Just in case if you are wondering, I found it through here on this SOF question. I'm just gonna embed the fiddle here.


Even though it worked perfectly well, It really wasn't what i was expecting, So I kinda changed the original code a little bit. I will highlight what I changed. It was all has to do with styling and responding to mouse/keyboard events.

/*
Orignla author: http://www.andreasgustafsson.se
*/

var viewModel = new SearchResultViewModel();
var focusedItem = 0;
var oldItem = "";


ko.bindingHandlers.autoComplete = {

    init: function (element, valueAccessor, allBindingsAccessor) {
        //We want valueupdate on keydown
        allBindingsAccessor().valueUpdate = 'afterkeydown';
        //get the settings from the html control
        var settings = allBindingsAccessor().settings || {};

        //create ul that will be added to the DOM

        //var ul = '<ul class="knockout-autoComplete" data-bind="foreach: searchResults"><li data-bind="text: label, click : $parent.select.bind($data), css : {\'autocomplete-selected\' : focus}"></li></ul>';

        var li = '<li class="ui-menu-item" role="menuitem" data-bind="text: label, click : $parent.select.bind($data), css : {\'autocomplete-selected\' : focus}"></li>';

        var ul = '<ul style="z-index: 1; display: block;max-height:200px;overflow:auto" class="ui-autocomplete ui-menu ui-widget ui-widget-content ui-corner-all" aria-activedescendant="ui-active-menuitem" role="listbox" data-bind="visible:searchResults().length> 0, foreach: searchResults">' + li + '</ul>';



        //Add ul to DOM after input (searchbox)
        $(element).after(ul);

        //Add blur event for cleaning searchresults
        $(element).blur(function () {
            //Timeout function to be able to Click on a item before the lists dissapear
            setTimeout(function () {
                //clear searchresults
                viewModel.searchResults([]);
                //clear elements value
                //$(element).val('');
            }, 150);
        });



        //Key navigation
        ko.utils.registerEventHandler(element, 'keydown', function (evt) {
            var item;

            var noOfListItems = viewModel.searchResults().length - 1;
            var curListItem = focusedItem;

            switch (evt.keyCode) {
                //Esc clicked blur element
                case 27:

                    $(element).trigger('blur');

                    break;

                    //9 TAB key

                case 9:

                    item = viewModel.searchResults()[focusedItem];

                    viewModel.select(item);

                    focusedItem = 0;

                    //clear searchresults

                    viewModel.searchResults([]);

                    break;

                    //38 Key upp

                case 38:

                    reserFocusStatus();

                    // Decrement the selection by one, unless that will be less than zero, then go to the last option

                    focusedItem = (curListItem - 1 < 0) ? noOfListItems : curListItem - 1;
                    viewModel.toggleSelected(focusedItem);

                    break;
                    //40 keydown
                case 40:

                    reserFocusStatus();

                    // Increment the selection by one, unless that will be more than the number of options, then go to the first option

                    focusedItem = (curListItem + 1 > noOfListItems) ? 0 : curListItem + 1;
                    viewModel.toggleSelected(focusedItem);

                    break;

                case 13:

                    //Enter select item with index of focusedItem

                    item = viewModel.searchResults()[focusedItem];

                    viewModel.select(item);

                    focusedItem = 0;

                    //clear searchresults

                    viewModel.searchResults([]);

                    break;

            }

        });


        function reserFocusStatus() {
            for (var i = 0; i < viewModel.searchResults().length; i++)
                viewModel.searchResults()[i].focus(false);
        }
        //Set element in viewModel
        viewModel.setElement(element);
        //set selectFunction of item
        viewModel.setSelectFunction(settings.selectCallback);
        //set updatedItemValue & updatedItemLabel of item
        viewModel.setUpdatedItem(settings.updatedItemValue, settings.updatedItemLabel);

        //Bind ul with viewModel
        ko.applyBindings(viewModel, $(element).next('ul')[0]);
        ko.bindingHandlers.value.init(element, valueAccessor, allBindingsAccessor);

    },

    update: function (element, valueAccessor, allBindingsAccessor) {
        var value = valueAccessor();
        var settings = allBindingsAccessor().settings || {};

        //Remove old results
        viewModel.searchResults.removeAll();

        //Proceed with updating only if the previous and current values are different
        if (oldItem !== value())
            if (value().length > 0) {
                //Only do search if input value is longer then 0
                //Ajax call to server
                $.ajax({
                    url: settings.url + value(),
                    contentType: 'application/json; charset=UTF-8',
                    //data: { name: value },
                    success: function (data) {
                        //Map data that is returned from server
                        var mapped = ko.utils.arrayMap(data, function (item) {
                            return { label: item.Name, value: item.Postcode, focus: ko.observable(false) };
                        });

                        //put the mapped data to the viewModels searchresults array
                        viewModel.searchResults(mapped);
                    }
                });
            }

        ko.bindingHandlers.value.update(element, value, allBindingsAccessor);

    }

};

//Autocomplete Viewmodel

function SearchResultViewModel() {

    var self = this;

    //array for searchresults

    self.searchResults = ko.observableArray([]);

    //bound element (for cleaning value during select)

    self.element = null;

    //function that will be triggerd on select

    self.selectFunction = null;

    //function that will be triggerd on select

    self.updatedObservableVal = null;

    self.updatedObservableLbl = null;

    self.setSelectFunction = function (selectFunction) {

        if (selectFunction) {
            self.selectFunction = selectFunction;
        }
    };

    self.setUpdatedItem = function (updatedValueObservable, updatedLabelObservable) {

        if (updatedValueObservable) {

            self.updatedObservableVal = updatedValueObservable;

        }

        if (updatedLabelObservable) {

            self.updatedObservableLbl = updatedLabelObservable;

        }

    };



    self.setElement = function (element) {

        if (element) {

            self.element = element;

        }

    };

    //When navigating up and down toggle what item is selected and not

    self.toggleSelected = function (index) {

        var item = viewModel.searchResults()[index];

        if (item && !item.focus()) {
            item.focus(true);
        }

    };

    //item click function
    self.select = function (data) {

        self.searchResults([]);

        try {

            oldItem = data.label;

            $(self.element).val(data.label);

            self.updatedObservableVal(data.value);

            self.updatedObservableLbl(data.label);

        } catch (e) {

            console.log(e);

        }

        //trigger custom select function

        self.selectFunction(data);



    };

}

The KeyUP, KeyDown events handling, I found them through here. http://stackoverflow.com/questions/7181282/jquery-arrow-up-down-keys-in-input-tag-change-selected-select-item