TechHui

Hawaiʻi's Technology and New Media Community

Knockout.js custom bindings (or how to make a jQuery autocomplete play nice with knockout.js)

The key to making any jQuery UI component play nice with knockout.js is to use a custom binding.  From the knockout site, this is how you add a custom binding:

ko.bindingHandlers.yourBindingName = {

    init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        // This will be called when the binding is first applied to an element
        // Set up any initial state, event handlers, etc. here
    },
    update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        // This will be called once when the binding is first applied to an element,
        // and again whenever the associated observable changes value.
        // Update the DOM element based on the supplied values here.
    }
};
So lets start with the simple stuff... You create a custom binding by adding a property to ko.bindingHandlers.  Your binding handler can contain two callbacks: init and update.  As described in the document, init happens when the binding is first encountered and update is call when the binding is encountered AND whenever the observable changes.
Next, lets cover the parameters:
  • element - this is the HTML element to which the data-bind attribute is added
  • valueAccessor - a function that returns the model property value.  For example if your data-bind is:               <input data-bind="value: some-Property">, you would call valueAccessor() in your binding to see what the value of some-Property is.
  • allBindingsAccessor - another function that will return all properties bound to this model.  Another example will help illuminate: <input data-bind='custom-Binding: property, my-Binding-Property: value-1, my-Binding-Property-2: value-2'>.  Calling allBindingsAccessor() would return a javascript object like this { custom-Binding: property, my-Binding-Property: value, my-Binding-Property-2: value-2}.
  • viewModel - the viewModel bound by ko.applyBindings(viewModel)
  • bindingContext - (from the knockout site)  An object that holds the binding context available to this element’s bindings. This object includes special properties including $parent$parents, and $root that can be used to access data that is bound against ancestors of this context.

So we've covered the signature and the basic functionality.  How do we apply this to a jQuery UI Autocomplete control? 

The data-bind:

<input id="filter" data-bind="value: $root.filter, jqAuto: { autoFocus: true, minLength: 1, delay: 200 }, jqAutoSource: $root.filterResults, jqAutoQuery: $root.filterQuery, jqAutoValue: $root.filter, jqAutoSourceLabel: 'Label', jqAutoSourceInputValue: Value', jqAutoSourceValue: 'Value' "/>

The first thing to note is that there are two bindings in this statement:

  •  value: $root.filter - tells knockout to bind the value contained in the filter property of the view model to the value of the input control.
  • jqAuto: { autoFocus... - tells knockout how to bind the autocomplete.  We'll see the code of the binding in a bit but lets explain each part of this binding first.
  •      {autoFocus: true, minLength: 1, delay: 200} - these are parameters passed straight through to jQuery as options for the autocomplete
  •      jqAutoSource: $root.filterResults - this is the autocomplete's source property.  It is bound to the filterResults property of the viewModel and is an observable array
  •      jqAutoQuery: $root.filterQuery - this is used in conjunction with the property above to fill the source collection via an ajax request.  So, you could just supply the values for the source in an observable array or you can supply this function to retrieve the source from your server
  •      jqAutoSourceLabel - if jqAutoSource contains an object, this is the property that identifies the label to use in the autocomplete
  •      jqAutoSourceInputValue: if jqAutoSource contains an object, this is the property that will show in the autocomplete dropdown
  •      jqAutoSourceValue: if jqAutoSource contains an object, this is the property that identifies the value to use for autocomplete

The binding: 

//jqAutoSource -- the array to populate with choices (needs to be an observableArray)

//jqAutoQuery -- function to return choices

//jqAutoValue -- where to write the selected value

//jqAutoSourceLabel -- the property that should be displayed in the possible choices

//jqAutoSourceInputValue -- the property that should be displayed in the input box
//jqAutoSourceValue -- the property to use for the value

ko.bindingHandlers.jqAuto = {

     init: function(element, valueAccessor, allBindingsAccessor, viewModel) {
              var options = valueAccessor() || {},
                      allBindings = allBindingsAccessor(),
                      unwrap = ko.utils.unwrapObservable,
                      modelValue = allBindings.jqAutoValue,
                      source = allBindings.jqAutoSource,
                      query = allBindings.jqAutoQuery,
                      valueProp = allBindings.jqAutoSourceValue,
                      inputValueProp = allBindings.jqAutoSourceInputValue || valueProp,
                      labelProp = allBindings.jqAutoSourceLabel || inputValueProp;

                     //function that is shared by both select and change event handlers

                     // this sets up a function that correctly writes the autocomplete value back to an 

                     // observable or non-observable

                     function writeValueToModel(valueToWrite) {
                             if (ko.isWriteableObservable(modelValue)) {
                                    modelValue(valueToWrite);
                             } else { //write to non-observable
                                       if (allBindings['_ko_property_writers'] && allBindings['_ko_property_writers']['jqAutoValue']) {
                                            allBindings['_ko_property_writers']['jqAutoValue'](valueToWrite);
                             }
                     }
              }

            // handle the select event of the autocomplete

            //on a selection write the proper value to the model
            options.select = function(event, ui) {
                writeValueToModel(ui.item ? ui.item.actualValue : null);
            };

            //on a change, make sure that it is a valid value or clear out the model value
            options.change = function(event, ui) {
   

            //skip clearing of values if not auto select

            if (!options.autoSelect) {

                return;
            }

            var currentValue = $(element).val();
            var matchingItem = ko.utils.arrayFirst(unwrap(source), function(item) {
                return unwrap(inputValueProp ? item[inputValueProp] : item) === currentValue;
            });

            if (!matchingItem) {
                writeValueToModel(null);
            }
    };

    //hold the autocomplete current response

    var currentResponse = null;

   

    //handle the choices being updated in a DO, to decouple value updates from source (options) updates
    var mappedSource = ko.dependentObservable({
        read: function() {
                    var mapped = ko.utils.arrayMap(unwrap(source), function(item) {
                    var result = {};
                    result.label = labelProp ? unwrap(item[labelProp]) : unwrap(item).toString(); //show in pop-up choices
                    result.value = inputValueProp ? unwrap(item[inputValueProp]) : unwrap(item).toString(); //show in input box
                   result.actualValue = valueProp ? unwrap(item[valueProp]) : item; //store in model
                   return result;
            });

            return mapped;
        },


        write: function(newValue) {
                      source(newValue); //update the source observableArray, so our mapped value (above) is correct
                      if (currentResponse) {
                            currentResponse(mappedSource());
                      }
                  },


        disposeWhenNodeIsRemoved: element
    });

    if (query) {
            options.source = function(request, response) {
            currentResponse = response;
            query.call(this, request.term, mappedSource);
        };

    } else {
         //whenever the items that make up the source are updated, make sure that autocomplete knows it
         mappedSource.subscribe(function(newValue) {
             $(element).autocomplete("option", "source", newValue);
         });

         options.source = mappedSource();
    }


    //initialize autocomplete
        $(element).autocomplete(options).css('width','auto');
    },


    update: function(element, valueAccessor, allBindingsAccessor, viewModel) {

        //update value based on a model change
        var allBindings = allBindingsAccessor(),
        unwrap = ko.utils.unwrapObservable,
        modelValue = unwrap(allBindings.jqAutoValue) || '',
        valueProp = allBindings.jqAutoSourceValue,
        inputValueProp = allBindings.jqAutoSourceInputValue || valueProp;
        if (Object.prototype.toString.call(modelValue) === '[object Date]') {
            modelValue = moment(modelValue).format('MM/DD/YYYY');
        }

        //if we are writing a different property to the input than we are writing to the model, then locate the object
        if (valueProp && inputValueProp !== valueProp) {
             var source = unwrap(allBindings.jqAutoSource) || [];
             modelValue = ko.utils.arrayFirst(source, function(item) {
             return unwrap(item[valueProp]) === modelValue;
    }) || {};
    }

    //update the element with the value that should be shown in the input
    $(element).val(modelValue && inputValueProp !== valueProp ? unwrap(modelValue[inputValueProp]) :         modelValue.toString());
    }


};

And there you have it.  I fully functioning jquery autocomplete element co-operating with knockoutjs bindings.  

Views: 3632

Comment

You need to be a member of TechHui to add comments!

Join TechHui

Comment by Boris Ning on July 30, 2013 at 5:11pm

Wow, this is relatively difficult to read. Can we have tags for code?

Sponsors

web design, web development, localization

© 2014   Created by Daniel Leuck.

Badges  |  Report an Issue  |  Terms of Service