Radu Enucă's Blog

"By failing to prepare you are preparing to fail" - Benjamin Franklin

  • 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
     

    Read more...

  • ASP.NET MVC–Cascading Dropdown Lists Tutorial–Part 5.3: Cascading using jQuery.ajax() ($.ajax() and Knockout.js)


    Part 5.3: Cascading using jQuery.ajax() ($.ajax() and Knockout.js)

    For this part we will use Knockout.ks:

    Knockout is a JavaScript library that helps you to create rich, responsive display and editor user interfaces with a clean underlying data model. Any time you have sections of UI that update dynamically (e.g., changing depending on the user’s actions or when an external data source changes), KO can help you implement it more simply and maintainably. (see the References section).

    Before reading further it is better to read the Knockout.js documentation + demos (2-3 hours read) as the things can get really confusing.

    We will reuse the controller from Part 5.1 and add a new action called KnockOutJs:

    public virtual ViewResult KnockOutJs( )
    {
        return View( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.Views.KnockoutJs );
    }
    

    And of course the KnockoutJs.cshtml:

    <fieldset>
        <legend>Continents</legend>
        <select id="continents" 
                data-bind='
                    options: continents, 
                    optionsValue : "Id", 
                    optionsText: "Name", 
                    optionsCaption: "[Please select a continent]", 
                    value: selectedContinent'>
        </select>
    </fieldset>
    <fieldset>
        <legend>Countries</legend>
        <div>
            <select id="countries" 
                    data-bind='
                        options: selectedContinent() ? countries : null, 
                        optionsValue : "Id", 
                        optionsText: "Name", 
                        optionsCaption: "[Please select a country]", 
                        value: selectedCountry,
                        visible: (countries() && countries().length > 0)'>
            </select>
            <span data-bind='
                template: {name: "noInfoTemplate"},
                visible: !(countries() && countries().length > 0)'>
            </span>
        </div>
    </fieldset>
    <fieldset>
        <legend>Cities</legend>
        <div>
            <table data-bind='visible: (cities() && cities().length > 0 )'>
                <thead>
                    <tr>
                        <th>
                            Name
                        </th>
                        <th>
                            Population
                        </th>
                    </tr>
                </thead>
                <tbody data-bind='template: {name: "citiesTemplate", foreach: cities}'>
                </tbody>
            </table>
            <span data-bind='
                template: {name: "noInfoTemplate"},
                visible: !(cities() && cities().length > 0 )'>
            </span>
        </div>
    </fieldset>
    @DateTime.Now.ToString( "dd/MM/yyyy HH:mm:ss:fff" )
    <script type="text/html" id="citiesTemplate">
        <tr>
            <td>${Name}</td>
            <td align='right'>${Population}</td>
        </tr>
    </script>
    <script type="text/html" id="noInfoTemplate">
        No Information Available
    </script>
    

    Compared to Part 5.1 and 5.2 there is a lot of code but the real power of Knockout.js is when we have a very complex view. Knockout.js introduces an attribute called “data-bind” that binds the html tag to a JavaScript view model, for short.

    For the continents dropdown list we have the following (in exact order of the properties):

    • Get the options from the model property called continents
    • The option value will be filled from the Id property of each Continent item
    • The option text (what we see) will be filled from the Name property of each Continent item
    • The first item presented to the use, the option caption, is set to “[Please select a continent]” (the value for this option will be undefined or null)
    • The value of the selected Continent will go the selectedContinent property from the view model

    For the countries dropdown list we have the following (in exact order of the properties):

    • Get the options from the model property called countries but only if there is a continent selected
    • The option value will be filled from the Id property of each Country item
    • The option text (what we see) will be filled from the Name property of each Country item
    • The first item presented to the use, the option caption, is set to “[Please select a country]” (the value for this option will be undefined or null)
    • The value of the selected Country will go the selectedCountry property from the view model
    • The countries dropdown list will only be visible if the countries property is not null and not empty (visible expects a value that will evaluate to true/false, 1/0, null, undefined)

    For the cities we have the following:

    • The cities table is only visible if the cities property is not null and not empty
    • The cities table body gets its data (the rows) from a template called citiesTemplate which is called for each City in the cities property. We could use the {{each}} template tag directly in the template but in this way if a City is added to the cities array it will be automatically displayed.

    There are also 2 span tags visible if the countries and cities table are not visible. The span tags get their inner html form the noInfoTemplate.

    Now let’s see how the view model looks. Here is the complete script used by this view:

    <script type='text/javascript'>
        $(document).ready(function () {
            var atlas = function () {
                this.selectedContinent = ko.observable();
                this.selectedCountry = ko.observable();
                this.continents = ko.observableArray();
                this.countries = ko.observableArray();
                this.cities = ko.observableArray();
                // Whenever the continent changes, reset the country selection
                this.selectedContinent.subscribe(function (continentId) {
                    this.selectedCountry(undefined);
                    this.countries(undefined);
                    if (continentId != null) {
                        $.ajax({
                            url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetCountries( ) )',
                            data: { continentId: continentId },
                            type: 'GET',
                            success: function (data) {
                                atlasViewModel.countries(data);
                            }
                        });
                    }
                } .bind(this));
                this.selectedCountry.subscribe(function (countryId) {
                    this.cities(undefined);
                    if (countryId != null) {
                        $.ajax({
                            url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetCities( ) )',
                            data: { countryId: countryId },
                            type: 'GET',
                            success: function (data) {
                                atlasViewModel.cities(data);
                            }
                        });
                    }
                } .bind(this));
            };
            var atlasViewModel = new atlas();
            ko.applyBindings(atlasViewModel);
            //Load the continents
            $.ajax({
                url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetContinents( ) )',
                type: 'GET',
                success: function (data) {
                    atlasViewModel.continents(data);
                }
            });
        });
    </script>
    

    Another important piece of the Knockout.js library are the observable properties. When the observable properties change the tags with data-bind attribute will be automatically updated . We can also subscribe to the modification of those properties (as we did for the selectedContinent and selectedCountry properties). Knockout.js is activated by calling the applyBindings method with the view model passed as the argument.

    The above code does the following:

    • When the document is ready we make an Ajax request for the continents. The continents property of the view model is updated and it triggers the UI update (if there are binding to that property).
    • When we select a continent the selectedContinent property is updated (through the data-bind properties)
    • Because we have a subscription for the changing of the selectedContinent property our function will be called.
    • The function will clear the selectedCountry and the cities array (again triggering the UI update)
    • If the selected continent is not null (in other words if we haven’t selected the [Please select a continent] option) we make an Ajax request for the countries based on the selected continent id and the countries property is updated with the data received from the server (triggering an UI update).

    The function for the selectedCountry changed event is similar as the selectedContinent one.

    See it in action

    Cascading Dropdown Lists - Knockout.js

    The code can be improved by adding 2 dependent observables called countriesVisible and citiesVisible that should be used instead of (countries() && countries().length > 0) statement. Dependent observables depend on the other properties (they are basically function that return a result based on the other properties). For those reading the documentation should be easy to implement.

    References

    Knockout.js – Documentation | Knockout.js - Samples

    Download

    Download code

     

    Read more...

  • ASP.NET MVC–Cascading Dropdown Lists Tutorial–Part 5.2: Cascading using jQuery.ajax() ($.ajax() and jQuery Templates)


    Part 5.2: Cascading using jQuery.ajax() ($.ajax() and jQuery Templates)

    We will reuse the controller from Part 5.1 and add a new action called jQueryTemplates:

    public virtual ViewResult jQueryTemplates( )
    {
        return View( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.Views.jQueryTemplates );
    }
    

    And of course the jQueryTemplates.cshtml:

    <fieldset>
        <legend>Continents</legend>
        <div id='continentsContainer'>
        </div>
    </fieldset>
    <fieldset>
        <legend>Countries</legend>
        <div id='countriesContainer'>
        </div>
    </fieldset>
    <fieldset>
        <legend>Cities</legend>
        <div id="citiesContainer">
        </div>
    </fieldset>
    

    Compared to Part 5.1 where we had some empty select tags here we have only empty div tags. We will use jQuery Templates (see the References section for jQuery Templates documentation and samples) to define templates for the following:

    • Continents and Countries dropdown lists
    • Cities table
    • “No Information available”

    For the Continents and Countries dropdown lists we will define a template called selectTemplate:

    <script id="selectTemplate" type="text/html">
        {{if options.length}}
            <select id='${id}'>
                <option value=''>${choose}</option>
                {{each options}}
                    <option value='${Id}'>${Name}</option>
                {{/each}}
            </select>
        {{else}}
            {{tmpl '#noInfoTemplate'}}
        {{/if}}
    </script>
    

    This is how we define a template. It has a name to help identify it and a type. The template receives, as a parameter an object with 3 properties:

    • id – becomes the select tag id
    • choose – becomes the first option ([Please select a continent])
    • options – an array of objects that have an Id and Name property

    The ideea si simple. If there are any options then we output a select tag and for each option and option tag. If the options array is empty we call another template called “noInfoTemplate” that will output the text “No Information Available” (or anything we want).

    The citiesTemplate:

    <script id="citiesTemplate" type="text/html">
        {{if cities.length}}
            <table>
                <tr>
                    <th>Name</th>
                    <th>Population</th>
                </tr>
                {{each cities}}
                    <tr>
                        <td>${Name}</td>
                        <td align='right'>${Population}</td>
                    </tr>
                {{/each}}
            </table>
        {{else}}
            {{tmpl '#noInfoTemplate'}}
        {{/if}}
    </script>
    

    We follow the same idea. If the cities array is empty we call the noInfoTemplate, otherwise we output a table by using the {{each}} template tag.

    And finally the very complicated noInfoTemplate:

    <script id="noInfoTemplate" type="text/html">
        No information available
    </script>
    

    This is not all. We still need to perform the ajax requests and fill the templates with data. We can copy the functions form Part 5.1 and do some adjustments:

    <script type='text/javascript'>
        $(document).ready(function () {
            //Load the continents
            $.ajax({
                url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetContinents( ) )',
                type: 'GET',
                success: function (data) {
                    $("#continentsContainer").html($("#selectTemplate").tmpl({
                        id: 'continents',
                        choose: '[Please select a continent]',
                        options: data 
                    }));
                    $('#countriesContainer').html($('#noInfoTemplate').tmpl());
                    $('#citiesContainer').html($('#noInfoTemplate').tmpl());
                }
            });
            //Catch the continents change event
            $('#continents').live('change', function () {
                $('#citiesContainer').html($('#noInfoTemplate').tmpl());
                if ($('#continents option:selected').val() != '') {
                    $.ajax({
                        url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetCountries( ) )',
                        data: { continentId: $('#continents option:selected').val() },
                        type: 'GET',
                        success: function (data) {
                            $("#countriesContainer").html($("#selectTemplate").tmpl({
                                id: 'countries',
                                choose: '[Please select a country]',
                                options: data 
                            }));
                        }
                    });
                }
                else {
                    $('#countriesContainer').html($('#noInfoTemplate').tmpl());
                }
            });
            //Catch the countries change event
            $('#countries').live('change', function () {
                if ($('#countries option:selected').val() != '') {
                    $.ajax({
                        url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetCities( ) )',
                        data: { countryId: $('#countries option:selected').val() },
                        type: 'GET',
                        success: function (data) {
                            $("#citiesContainer").html($("#citiesTemplate").tmpl({ cities: data }));
                        }
                    });
                }
                else {
                    $('#citiesContainer').html($('#noInfoTemplate').tmpl());
                }
            });
        });
    </script>
    

    See it in action

    Cascading Dropdown Lists - jQuery Templates

    The code is much simple and clean now. We only have 1 line of code to create all 3 tags by calling the tmpl() function with the data returned from the server. But we still have to manually call the NoInfoTemplate if no data is returned or if there is nothing selected and there is still a lot of plumbing.

    In Part 5.3 we will use Knockout.js to further improve the way we perform the cascading.

    Download

     

    Read more...

  • ASP.NET MVC–Cascading Dropdown Lists Tutorial–Part 5.1: Cascading using jQuery.ajax() ($.ajax() and DOM Objects)


    Part 5.1: Cascading using jQuery.ajax() ($.ajax() and DOM Objects)

    In this part we will use ASP.NET MVC to create an application that will act more like a JSON service than a Web application. For start let’s create a new controller called DropDownjQueryAjaxPostController:

    public partial class DropDownjQueryAjaxPostController : Controller
    {
        private readonly IContinentRepository _continentRepository;
        private readonly ICountryRepository _countryRepository;
        private readonly ICityRepository _cityRepository;
        // If you are using Dependency Injection, you can delete the following constructor
        public DropDownjQueryAjaxPostController( ) 
            : this( new ContinentRepository( ) , new CountryRepository( ) , new CityRepository( ) ) { }
        public DropDownjQueryAjaxPostController( 
            IContinentRepository continentRepository , 
            ICountryRepository countryRepository , 
            ICityRepository cityRepository )
        {
            this._continentRepository = continentRepository;
            this._countryRepository = countryRepository;
            this._cityRepository = cityRepository;
        }
        public virtual ViewResult Index( )
        {
            return View( );
        }
    }
    

    Nothing unusual here. We set up repositories and the Index action. The unusual thing is that we return the Index view without any model and that the Index view is the only view we have:

    @{
        ViewBag.Title = "Index";
    }
    <fieldset>
        <legend>Continents</legend>
        <select id='continents'>
            <option value=''>[Please select a continent]</option>
        </select>
    </fieldset>
    <fieldset>
        <legend>Countries</legend>
        <div id="countriesContainer">
            <select id='countries' style='display: none;'>
                <option value=''>[Please select a country]</option>
            </select>
            <span id='noInfoCountries'>No information available </span>
        </div>
    </fieldset>
    <fieldset>
        <legend>Cities</legend>
        <div id="citiesContainer">
            <table id='cities' style='display: none;'>
                <tr>
                    <th>
                        Name
                    </th>
                    <th>
                        Population
                    </th>
                </tr>
            </table>
            <span id='noInfoCities'>No information available </span>
        </div>
    </fieldset>
    

    Let’s see what we have:

    • An empty continents dropdown list (we could fill the continents from the view but we’ll see another way)
    • An empty hidden countries dropdown list  and a “No information available” span that will be visible if there is no continent selected
    • An empty hidden cities table and another “No information available” span that will be visible if there is no country selected

    So the first thing that we have to do is to fill the continents dropdown list when the page loads:

    <script type='text/javascript'>
        $(document).ready(function () {
            //Load the continents
            $.ajax({
                url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetContinents( ) )',
                type: 'GET',
                success: function (data) {
                    var contients = $('#continents');
                    var domContinents = contients.get(0); // $('#id') != document.getElementById('id')
                    //Empty the continents dropdown list
                    for (var i = domContinents.options.length - 1; i > 0; i--) {
                        domContinents.remove(i);
                    }
                    for (var i = 0; i < data.length; i++) {
                        var item = data[i];
                        var continentOption = new Option(item.Name, item.Id);
                        contients.append(continentOption);
                    }
                }
            });
        });
    </script>
    

    We use the jQuery.ajax () method (or $.ajax() for short) to perform a Ajax Get request on the GetContinents action. If the call succeds we clear the contents of the dropdownlist and fill it with the data received. Nothing to complicated, just javascript objects and method calls. The only odd thing is the continents and domContinents (see the comment in the code above).

    Next we need to catch the change event for the continents dropdown list and perform another Ajax Get request to get the countries for the selected continent:

    //Catch the continents change event
    $('#continents').live('change', function () {
        var countries = $('#countries');
        var noInfo = $('#noInfoCountries');
        var domCountries = countries.get(0);
        for (var i = domCountries.options.length - 1; i > 0; i--) {
            domCountries.remove(i);
        }
        $('#cities').hide();
        $('#noInfoCities').show();
        if ($('#continents option:selected').val() != '') {
            $.ajax({
                url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetCountries( ) )',
                data: { continentId: $('#continents option:selected').val() },
                type: 'GET',
                success: function (data) {
                    if (data.length > 0) {
                        for (var i = 0; i < data.length; i++) {
                            var item = data[i];
                            var countryOption = new Option(item.Name, item.Id);
                            countries.append(countryOption);
                        }
                        noInfo.hide();
                        countries.show();
                    }
                    else {
                        countries.hide();
                        noInfo.show();
                    }
                }
            });
        }
        else {
            countries.hide();
            noInfo.show();
        }
    });
    

    When the continents dropdown list selection changes we empty the countries dropdown list. The request is only performed if there is a continent selected, otherwise the “No Information Available” span becomes visible. The span becomes visible also if there are no countries in the continent (for Antarctica).

    Next we catch the countries dropdown list change event:

    //Catch the countries change event
    $('#countries').live('change', function () {
        var cities = $('#cities');
        var domCities = cities.get(0);
        for (var i = domCities.rows.length - 1; i > 0; i--) {
            domCities.deleteRow(i);
        }
        var noInfo = $('#noInfoCities');
        if ($('#countries option:selected').val() != '') {
            $.ajax({
                url: '@Url.Action( MVC.CascadingDropDownLists.DropDownjQueryAjaxPost.GetCities( ) )',
                data: { countryId: $('#countries option:selected').val() },
                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 {
                        cities.hide();
                        noInfo.show();
                    }
                }
            });
        }
        else {
            countries.hide();
            noInfo.show();
        }
    });
    

    This is it. Not to complicated but there is some code to write.

    See it in action

    Cascading Dropdown Lists - jQuery.ajax()

    There are some things that can be improved in this method:

    • Using the DOM objects is kind of ugly (adding and removing options and rows)
    • There are a lot of ifs to hide and show elements depending on selections

    We will correct them in the next 2 parts

    Download
     

    Read more...