ASP.NET MVC–Cascading Dropdown Lists Tutorial–Part 6: Creating a jQuery Cascade Plugin


Part 6: Creating a jQuery Cascade Plugin

If we take a closer look at Part 5 we will see that there is to much repetition in the java script code (for example for each additional dropdown list we need to add a callback for the change event). Not to mention the code gets bigger and bigger with each dropdown list we add (even for the simplified version that uses jQuery Templates). So the next natural move will be to encapsulate all the code inside a jQuery Cascade Select Plugin.

In this post we will only define a template for the plugin which we will expand in the following posts. According to Plugins/Authoring tutorial we should start with:

(function ($) {
    var methods = {
        init: function (options) {
            return this.each(function () {
                //The selector will probably return more than one element
                //Init the plugin for each element here
            });
        }
        //Other Methods here
    }

    $.fn.cascadeSelect = function (method) {
        if (methods[method]) {
            return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
        }
        else if (typeof method === 'object' || !method) {
            return methods.init.apply(this, arguments);
        }
        else {
            $.error('Method ' + method + ' does not exist on jQuery.cascadeSelect');
        }
    };
})(jQuery);

There are two approaches for building the plugin:

  1. Specify which element is the root and set the children of each element that will perform cascades
  2. Specify the parent to each element that will participate in the cascade. The element with no parent is the root element

The second method is the most flexible as will allow us to bind the child to any parent event (not only ‘change’) and this will be the option for a general purpose cascade plugin (maybe in a future post). For out specific task, cascade dropdown lists the first option is better because we only need the ‘change’ event.

We have identified the first option we need to set: root, and because there is only one root the default value for the root option will be false (meaning that we will explicitly set the root element).

Looking again at part 5 we can see that for each element participating in the cascade we need the url from which we will load the content. There is no default value for this option. Next we need to specify the child/children of the current element (the children will be loaded when the current element’s selection changes) so we need a childSelector option.

We also need to show a text that will prompt the user to select an item ( promptText with the default value ‘[Please select an item]’ ) and a text to show when there is no information to be shown ( noInfoText with the default value ‘No information available’).

The plugin looks like this now:

(function ($) {
    var methods = {
        init: function (options) {
            return this.each(function () {
                //The selector will probably return more than one element
                //Init the plugin for each element here
            });
        }
        //Other Methods here
    }

    $.fn.cascadeSelect = function (method) {
        if (methods[method]) {
            return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
        }
        else if (typeof method === 'object' || !method) {
            return methods.init.apply(this, arguments);
        }
        else {
            $.error('Method ' + method + ' does not exist on jQuery.cascadeSelect');
        }
    };

    $.fn.cascadeSelect.defaults = {
        root: false,
        url: null,
        childSelector: null,
        promptText: '[Please select an item]',
        noInfoText: 'No information available'
    };
})(jQuery);

Now it’s time to implement the “init” method:

init: function (options) {
    return this.each(function () {
        // setup private variables
        var $this = $(this);

        options = $.extend({}, $.fn.cascadeSelect.defaults, options);

        //Save the options
        $this.data('options', options);

        if (options.promptText) {
            setPromptText($this);
        }

        if (options.noInfoText) {
            setNoInfoText($this);
        }

        if (options.root) {
            methods['load'].call($this);
        }
        else {
            toggleNoInfoText($this, true);
        }

        if (options.childSelector != null) {
            onChange($this);
        }
    });
}

First we initialize the $this variables and we merge the options (provided from the plugin consumer) with the default options. Because we need to read the options at a later time we use the jQuery data() function to associate the options data with the element.

Next are a series of checks:

  • We set the promptText if one exists (the plugin consumer can set it to null even if we have a default)
  • We set the noInfoText if one exists
  • If the element it is a root element we need to load it first so we all the “load” method. If the element is a child element we toggle the NoInfoText (there is no parent element selected yet).
  • If the element has childs then we bind to the “change” event.

As we can see in the list above there are a lot of new private functions to be added and a public one (“load”). The load function will perform an Ajax call to the url set in the parameters and will also introduce two new parameters, one called filter and one call onLoad:

  • The filter parameter will be null by default (for example we load all Continents) and will have a value when we want to filter the results (for the Countries dropdown list the value of the filter parameter will be “continentId” and the value to be filtered will be passed when the Continents’ dropdown lists value changes).
  • The onLoad parameter will override the Load function with a custom function passed in from the “client”. This is useful because the Load function only knows how to fill dropdown lists but we want to show the cities in a table.

The plugin looks like this now:

(function ($) {
    //Return the options stored for this element
    var getOptions = function (element) {
        var options = element.data('options');

        if (options) {
            return options;
        }
        else {
            $.error('The element must be initialized first');
        }
    }

    // Set the promt text that will appear as the first value
    var setPromptText = function (element) {
        var defaultValue = getOptions(element).promptText;
        var option = new Option(defaultValue, '');
        element.append(option);
    }

    // Set the text that will appear if there is no data to display
    var setNoInfoText = function (element) {
        var options = getOptions(element);
        var noInfoElement = $('<span></span>').attr('id', 'noInfo_' + element.attr('id')).append(options.noInfoText);

        element.parent().append(noInfoElement);
    }

    // Toggle the noInfoText
    var toggleNoInfoText = function (element, visible) {
        var noInfoElement = $('#noInfo_' + element.attr('id'));
        var options = getOptions(element);

        if (visible) {
            noInfoElement.show();
            element.hide();
        }
        else {
            noInfoElement.hide();
            element.show();
        }

        $(options.childSelector).each(function () {
            var child = $(this);
            var noInfoChildElement = $('#noInfo_' + child.attr('id'));

            noInfoChildElement.show();
            child.hide();
        });
    }

    var onChange = function (element) {
        element.bind('change', function () {
            var options = getOptions(element);
            //Iterate the childs
            $(options.childSelector).each(function () {
                var child = $(this);
                //Clear the child of its elements
                methods['clear'].call(child);

                if (element.val() != '') {
                    methods['load'].call(child, element.val());
                }
                else {
                    toggleNoInfoText(child, true);
                }
            });
        });
    }

    var methods = {
        init: function (options) {
            return this.each(function () {
                // setup private variables
                var $this = $(this);

                options = $.extend({}, $.fn.cascadeSelect.defaults, options);

                //Save the options
                $this.data('options', options);

                if (options.promptText) {
                    setPromptText($this);
                }

                if (options.noInfoText) {
                    setNoInfoText($this);
                }

                if (options.root) {
                    methods['load'].call($this);
                }
                else {
                    toggleNoInfoText($this, true);
                }

                if (options.childSelector != null) {
                    onChange($this);
                }
            });
        },
        load: function (data) {
            var element = $(this);
            var options = getOptions(element);

            var url = options.url;
            var filter = options.filter;
            var json = {};

            if (filter != null) {
                json[filter] = data;
            }

            data = data || null;

            var onLoadCallback = options.onLoad;
            if (onLoadCallback && $.isFunction(onLoadCallback)) {
                onLoadCallback.call(element, json);
            }
            else {
                $.ajax({
                    url: url,
                    type: 'GET',
                    data: json,
                    dataType: 'JSON',
                    success: function (data) {
                        // because $('#id') != document.getElementById('id')
                        var domElement = element.get(0);

                        //Emtpy the dropdown list
                        for (var i = domElement.options.length - 1; i > 0; i--) {
                            domElement.remove(i);
                        }

                        if (data.length > 0) {
                            for (var i = 0; i < data.length; i++) {
                                var item = data[i];
                                var option = new Option(item.Name, item.Id);
                                element.append(option);
                            }
                            toggleNoInfoText(element, false);
                        }
                        else {
                            toggleNoInfoText(element, true);
                        }
                    }
                });
            }
        }
    }

    $.fn.cascadeSelect = function (method) {
        if (methods[method]) {
            return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
        }
        else if (typeof method === 'object' || !method) {
            return methods.init.apply(this, arguments);
        }
        else {
            $.error('Method ' + method + ' does not exist on jQuery.cascadeSelect');
        }
    };

    $.fn.cascadeSelect.defaults = {
        root: false,
        url: null,
        childSelector: null,
        promptText: '[Please select an item]',
        noInfoText: 'No information available',
        filter: null,
        onLoad: null
    };
})(jQuery);

There is nothing complicated about getOptions, setPromptText, setNoInfoText and toggleNoInfoText so let’s move to the onChange function.  The onChange function binds some code the change event to the current element. When the change events happens  we call the Clear function on each child and then call the Load function passing the selected item in case we selected an item and not the [Please select and item]. The Load function is very similar to what we’ve used in Part 5.1 with the exception of the callback onLoad function that overrides the default behavior.

There are two new additions, the Clear public function and the onClear parameter used to override the default behavior of the Clear function when the element is not a dropdown list. The final look of the plugin is:

(function ($) {
    //Return the options stored for this element
    var getOptions = function (element) {
        var options = element.data('options');

        if (options) {
            return options;
        }
        else {
            $.error('The element must be initialized first');
        }
    }

    // Set the promt text that will appear as the first value
    var setPromptText = function (element) {
        var defaultValue = getOptions(element).promptText;
        var option = new Option(defaultValue, '');
        element.append(option);
    }

    // Set the text that will appear if there is no data to display
    var setNoInfoText = function (element) {
        var options = getOptions(element);
        var noInfoElement = $('<span></span>').attr('id', 'noInfo_' + element.attr('id')).append(options.noInfoText);

        element.parent().append(noInfoElement);
    }

    // Toggle the noInfoText
    var toggleNoInfoText = function (element, visible) {
        var noInfoElement = $('#noInfo_' + element.attr('id'));
        var options = getOptions(element);

        if (visible) {
            noInfoElement.show();
            element.hide();
        }
        else {
            noInfoElement.hide();
            element.show();
        }

        $(options.childSelector).each(function () {
            var child = $(this);
            var noInfoChildElement = $('#noInfo_' + child.attr('id'));

            noInfoChildElement.show();
            child.hide();
        });
    }

    var onChange = function (element) {
        element.bind('change', function () {
            var options = getOptions(element);
            //Iterate the childs
            $(options.childSelector).each(function () {
                var child = $(this);
                //Clear the child of its elements
                methods['clear'].call(child);

                if (element.val() != '') {
                    methods['load'].call(child, element.val());
                }
                else {
                    toggleNoInfoText(child, true);
                }
            });
        });
    }

    var methods = {
        init: function (options) {
            return this.each(function () {
                // setup private variables
                var $this = $(this);

                options = $.extend({}, $.fn.cascadeSelect.defaults, options);

                //Save the options
                $this.data('options', options);

                if (options.promptText) {
                    setPromptText($this);
                }

                if (options.noInfoText) {
                    setNoInfoText($this);
                }

                if (options.root) {
                    methods['load'].call($this);
                }
                else {
                    toggleNoInfoText($this, true);
                }

                if (options.childSelector != null) {
                    onChange($this);
                }
            });
        },
        clear: function () {
            var element = $(this);
            var options = getOptions(element);

            var onClearCallback = options.onClear;
            if (onClearCallback && $.isFunction(onClearCallback)) {
                onClearCallback.call(element);
            }
            else {
                if (options.noInfoText) {
                    var domElement = element.get(0);
                    for (var i = domElement.options.length - 1; i > 0; i--) {
                        domElement.remove(i);
                    }
                }
                else {
                    element.empty();
                }

                //Call clear on the childs as well
                $(options.childSelector).each(function () {
                    var child = $(this);
                    //Clear the child of its elements
                    methods['clear'].call(child);
                });
            }
        },
        load: function (data) {
            var element = $(this);
            var options = getOptions(element);

            var url = options.url;
            var filter = options.filter;
            var json = {};

            if (filter != null) {
                json[filter] = data;
            }

            data = data || null;

            var onLoadCallback = options.onLoad;
            if (onLoadCallback && $.isFunction(onLoadCallback)) {
                onLoadCallback.call(element, json);
            }
            else {
                $.ajax({
                    url: url,
                    type: 'GET',
                    data: json,
                    dataType: 'JSON',
                    success: function (data) {
                        // because $('#id') != document.getElementById('id')
                        var domElement = element.get(0);

                        //Emtpy the dropdown list
                        for (var i = domElement.options.length - 1; i > 0; i--) {
                            domElement.remove(i);
                        }

                        if (data.length > 0) {
                            for (var i = 0; i < data.length; i++) {
                                var item = data[i];
                                var option = new Option(item.Name, item.Id);
                                element.append(option);
                            }
                            toggleNoInfoText(element, false);
                        }
                        else {
                            toggleNoInfoText(element, true);
                        }
                    }
                });
            }
        }
    };

    $.fn.cascadeSelect = function (method) {
        if (methods[method]) {
            return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
        }
        else if (typeof method === 'object' || !method) {
            return methods.init.apply(this, arguments);
        }
        else {
            $.error('Method ' + method + ' does not exist on jQuery.cascadeSelect');
        }
    };

    $.fn.cascadeSelect.defaults = {
        root: false,
        url: null,
        childSelector: null,
        promptText: '[Please select an item]',
        noInfoText: 'No information available',
        filter: null,
        onLoad: null,
        onClear: null
    };
})(jQuery);

and this is how to use it:

<fieldset>
    <legend>Continents</legend>
    <select id='continents'></select>
</fieldset>
<fieldset>
    <legend>Countries</legend>
    <select id='countries'></select>
</fieldset>
<fieldset>
    <legend>Cities</legend>
    <table id='cities'>
        <tr>
            <th>
                Name
            </th>
            <th>
                Population
            </th>
        </tr>
    </table>
</fieldset>

<script type='text/javascript'>
    $(document).ready(function () {
        $('#continents').cascadeSelect({
            root: true,
            url: '/CascadingDropDownLists/DropDownjQueryAjaxPost/GetContinents',
            promptText: '[Please select a continent]',
            //promptText: null,
            childSelector: '#countries'
        });

        $('#countries').cascadeSelect({
            url: '@/CascadingDropDownLists/DropDownjQueryAjaxPost/GetCountries',
            promptText: '[Please select a country]',
            filter: "continentId",
            childSelector: '#cities'
        });

        $('#cities').cascadeSelect({
            promptText: null,
            filter: "countryId",
            onLoad: loadCities,
            onClear: clearCities
        });
    });

    function clearCities() {
        var cities = $(this);
        var domCities = cities.get(0);
        for (var i = domCities.rows.length - 1; i > 0; i--) {
            domCities.deleteRow(i);
        }
    }

    function loadCities(data) {
        var cities = $(this);
        var domCities = cities.get(0);
        var noInfo = $('#noInfo_' + cities.attr('id'));

        $.ajax({
            url: '/CascadingDropDownLists/DropDownjQueryAjaxPost/GetCities',
            data: data,
            type: 'GET',
            success: function (data) {
                if (data.length > 0) {
                    for (var i = 0; i < data.length; i++) {
                        var item = data[i];

                        var lastRow = domCities.rows.length;
                        var cityRow = domCities.insertRow(lastRow);

                        var cityName = cityRow.insertCell(0);
                        cityName.innerHTML = item.Name;

                        var cityPopulation = cityRow.insertCell(1);
                        cityPopulation.innerHTML = item.Population.toString();
                        cityPopulation.align = 'right';
                    }
                    noInfo.hide();
                    cities.show();
                }
                else {
                    noInfo.show();
                    cities.hide();
                }
            }
        });
    }
</script> 

See it in action

Cascading Dropdown Lists - jQuery Cascade Plugin

Download
 

6 Comments

  • Hi Radu,
    I have been playing with Part 6 this weekend. When I see the plug-in in action all I can say is wow; it works so nice. So I took the plug-in and tried to adapt it into my program. My models are similar; I have state, county, and municipality classes. After playing with your sample I noticed that your model classes were all defined the same with an ID and Name property.
    Further, in the Plugin, Load Function you have the following line:
    var option = new Option(item.Name, item.Id);
    I must be missing something because it appears that the plugin will only work for models that have a Name and ID because of the hard coded reference. What if I have something like looking up a StateCode based on StateDescr, and then a CountyID based on StateCode's CountyNames, and finally a MunicipalityCode based upon a CountyID's MunicipalityDetails? In my world it is a nicety to work with a table look up that uses an ID for foreign key values. Most that I have use codes because they are somewhat descriptive. Is there a way to pass the property names in, or would you recommend mapping the ID and Name through the database context or something else?

    Thanks much,
    Steve

  • @seidensc:
    Wow thanks for pointing that up. It shouldn't be like this. A quicker way will be to define some additional options that must be set at start. For example when you set the #countries select you can set a Value and DisplayText but this is not the best ideea. I will think about it and come back with a solution

  • Nice tutorial.
    Easy to understand

  • How can I set a default value with this script please ?

    Thanks for all.

  • @Sophiane: You cannot with the actual implementation but it should be really easy to add it. You can either add an additional option in the configuration object ($.fn.cascadeSelect.defaults) and read it in the load function when the options are constructed (var option = new Option(item.Name, item.Id, true); true means is selected) or add a isSelected property in the JSON object that is returned from the server and set it in the same way.

  • Hi,

    Congrats for the nice tutorial.

    It does not seem to work on IE 8. Any idea on how to fix this?

    Thanks

Comments have been disabled for this content.