ASP.NET MVC–Cascading Dropdown Lists Tutorial–Part 6: Creating a jQuery Cascade Plugin
- Part 1 – Defining the problem and the context
- Part 2 – Cascading using normal FORM post (Html.BeginForm helper)
- Part 3 – Cascading using Microsoft AJAX (Ajax.BeginForm helper)
- Part 4 – Cascading using FORM Hijaxing
- Part 5 – Cascading using jQuery.ajax():
- Part 6 – Creating a jQuery Cascade Select 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:
-
Specify which element is the root and set the children of each element that will perform cascades
-
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