Thursday, February 14, 2013

Simple Cascading DropDown User Control


Sometimes you need two drop downs that work together, where the value you select in the first determines which values you see in the second. ASP.NET AJAX has the CascadingDropDown control to handle this, but requires calls to the server. If the total number of possible options is small, it may be worth it to preload all the values and use javascript to determine which to display.

I have done this in a somewhat convoluted manner, but it seemed to be the best way from the research I did. The overall gist is that I create a drop down that has ALL the possible options, and hide it (I call this the "Source"). Then, create a second drop down that is empty, but visible (I call this the "Target"). Then, when the drop down which controls the filtering is changed, javascript code will loop over all the options in the Source and copy the ones that match whatever criteria were specified on the server.


When the Target value is changed, the value selected is set as the selected value for PostBack, and to the calling code it looks like the user directly selected from the Source drop-down.

Here's how this works. First, the user control is very simple. Just a select control. Originally I tried to use an asp:DropDownList control, but I got an error that effectively said controls can't be modified on the client without turning off some security features, so I just made it a client-only control:

<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="DropDownFilterExtender.ascx.cs" Inherits="DropDownFilterExtender.DropDownFilterExtender" %>
<select id="<%= ClientID+"_targetDropDown" %>" onchange="<%= OnTargetChangeFunction %>" ></select>

The next step is to map the values from the Source drop down to their corresponding values in the Filter. We do this one of two ways. The first is to check and see if the Source is data bound. If it is, you can try and get a property from it. I specified a "FilterProperty" property, which will use reflection to try and pull a filter value from a particular property if Source is data bound. Otherwise you'll have to use an event to set it custom on your page. Here is the function that does that:

protected void AddFilterAttribute()
{
    if (!Page.IsPostBack)
    {
        IEnumerable<object> dataSource = null;
        if (SourceDropDown.DataSource != null && typeof(IEnumerable<object>).IsAssignableFrom(SourceDropDown.DataSource.GetType()))
            dataSource = SourceDropDown.DataSource as IEnumerable<object>;

        List<string> filterValues = new List<string>();

        for (int i = 0; i < SourceDropDown.Items.Count; i++)
        {
            DropDownFilterExtenderGetFilterValueEventArgs e = new DropDownFilterExtenderGetFilterValueEventArgs();
            ListItem listItem = SourceDropDown.Items[i];
            e.Item = listItem;
            if (dataSource != null)
            {
                // If we can get the actual data item out of the filter source, try it.
                e.DataItem = dataSource.ElementAt(i);
                if (!string.IsNullOrEmpty(FilterProperty) && e.DataItem != null)
                {
                    object filterValue = e.DataItem.GetPropertyOrIndexValueSafe(FilterProperty);
                    if (filterValue != null)
                    {
                        e.FilterValue = filterValue.ToString();
                    }
                }
            }

            if (GetFilterValue != null)
            {
                GetFilterValue(this, e);
            }

            if (!string.IsNullOrEmpty(e.FilterValue))
            {
                filterValues.Add(e.FilterValue);
            }
            else
            {
                filterValues.Add("");
            }
        }

        string filterValuesJoined = string.Join(",", filterValues);
        Page.ClientScript.RegisterHiddenField(ClientID + "_filterValues", filterValuesJoined);
        ViewState[ClientID + "_filterValues"] = filterValuesJoined;
    }
    else
    {
        Page.ClientScript.RegisterHiddenField(ClientID + "_filterValues", (string)ViewState[ClientID + "_filterValues"]);
    }
}

Notice that I use a registered hidden field to accomplish this. On the client side, the way this will work is that I will loop over all the options in the source drop down, and all the elements in the filterValues hidden field. If the selected value from the filter drop down is the same as an element in the filterValues hidden field, then copy the corresponding option to the target drop down.

Note also that I save the filter values in ViewState. This is because, at least the way my code was set up, there was no guarantee that the data bindings would have happened on PostBack. If I store it in ViewState, then on PostBack I can be guaranteed to use the same values for the filters.

So now, here is the javascript code for managing this on the browser:

/*
Called when the filter select drop down changes. Get the new type, then get the corresponding customer
type and filter the list by that.
*/
function onFilterSelectChange(sourceDropDownID, targetDropDownID, filterSelectDropDownID, includeNulls, thisClientID) {
    var filterSelectDropDown = document.getElementById(filterSelectDropDownID);
    var sourceDropDown = document.getElementById(sourceDropDownID);
    var targetDropDown = document.getElementById(targetDropDownID);

    var filterValue = filterSelectDropDown.value;

    if (filterValue)
        popuplateDynamicDropDown(sourceDropDown, targetDropDown, filterValue, includeNulls, thisClientID);

    sourceDropDown.disabled = false;
    targetDropDown.disabled = false;

}

/*
There is a hidden drop-down with all the customers in it. When this function is called,
take all the options where the "customertype" attribute is equal to the value in customerType
and copy them into the dynamicCustomerDropDown that is actually displayed.
*/
function popuplateDynamicDropDown(sourceDropDown, targetDropDown, type, includeNulls, thisClientID) {
    // First clear the dynamic drop down
    while (targetDropDown.hasChildNodes()) {
        targetDropDown.removeChild(targetDropDown.firstChild);
    }

    var showDefaultRow = document.getElementById(thisClientID + "_showDefaultRow").value;
    if (showDefaultRow == "True") {
        var defaultRowText = document.getElementById(thisClientID + "_defaultRowText").value;
        var defaultRowValue = document.getElementById(thisClientID + "_defaultRowValue").value;
        // Add the default option to the dynamic.
        targetDropDown.options[0] = new Option(defaultRowText, defaultRowValue);
    }

    var filterValuesString = document.getElementById(thisClientID + "_filterValues").value;
    var filterValues = filterValuesString.split(",");

    for (var i = 0; i < sourceDropDown.options.length; i++) {
        var copyOption = false;
        var opt = sourceDropDown.options[i];
        if (opt.value != null && opt.value != "") {
            var filterValue = filterValues[i];
            if (filterValue != null && filterValue.length > 0) {
                if (filterValue == type)
                    copyOption = true;
            } else if (includeNulls) {
                // If includeNulls is set, include an option that doesn't have the attribute at all.
                copyOption = true;
            }

            if (copyOption) {
                var newOpt = new Option(opt.text, opt.value);
                newOpt.selected = opt.selected;
                targetDropDown.appendChild(newOpt);
            }
        }
    }
}

/*
When the selection changes on the dynamic drop down with the subset of customers, set
the selected value on the original customer drop down so that the selection is reflected server-side.
*/
function onDynamicDropDownSelect(sourceDropDownID, targetDropDownID) {
    var sourceDropDown = document.getElementById(sourceDropDownID);
    var targetDropDown = document.getElementById(targetDropDownID);

    var selectedValue = targetDropDown.value;
    sourceDropDown.value = selectedValue;
}

So now the main thing that's left to do is put this all together in a control and register the various client functions with the page. I will leave that as an exercise to a zip file. Download a complete example. Sorry my write-up was so hasty. I am in somewhat of a hurry and probably shouldn't be wasting time blogging. Hopefully the example makes up for it.

UPDATE: Playing around a bit more with this today, and I found this could be done better (and more browser-compatibly) with jQuery:


/*
There is a hidden drop-down with all the customers in it. When this function is called,
take all the options where the "customertype" attribute is equal to the value in customerType
and copy them into the dynamicCustomerDropDown that is actually displayed.
*/
function onFilterSelectChange(sourceDropDownID, targetDropDownID, filterSelectDropDownID, includeNulls, thisClientID) {
    // Clear the target drop down.
    var targetDropDown = $("#" + targetDropDownID + " option");
    targetDropDown.remove().end();

    var filter = $("#" + filterSelectDropDownID).val();

    var showDefaultRow = getHiddenFieldValue(thisClientID, "_showDefaultRow") == "True";
    if (showDefaultRow) {
        var defaultRowText = getHiddenFieldValue(thisClientID, "_defaultRowText");
        var defaultRowValue = getHiddenFieldValue(thisClientID, "_defaultRowValue");

        $("#" + targetDropDownID).append($("<option></option>")
                                 .val(defaultRowValue)
                                 .text(defaultRowText));
    }

    var filterValuesString = getHiddenFieldValue(thisClientID, "_filterValues");
    var filterValues = filterValuesString.split(",");

    var sourceDropDown = $("#" + sourceDropDownID + " option");

    sourceDropDown.each(function (i) {
        // $(this) is the current option selector
        var copyOption = false;
        if ($(this).val()) {
            var filterValue = filterValues[i];
            if (filterValue) {
                if (filterValue == filter)
                    copyOption = true;
            } else if (includeNulls) {
                // If includeNulls is set, include an option that doesn't have the attribute at all.
                copyOption = true;
            }
        }

        if (copyOption) {
            $("#" + targetDropDownID).append($(this).clone());
        }
    });

}

function getHiddenFieldValue(clientID, fieldID) {
    return $("#" + clientID + fieldID).val();
}


/*
When the selection changes on the dynamic drop down with the subset of customers, set
the selected value on the original customer drop down so that the selection is reflected server-side.
*/
function onDynamicDropDownSelect(sourceDropDownID, targetDropDownID) {
    var selectedValue = $("#" + targetDropDownID).val();
    $("#" + sourceDropDownID).val(selectedValue);

}