Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Cascading Selection the MVC Way

0.00/5 (No votes)
29 Mar 2013 1  
How to easily implement cascading selection the MVC way and leverage cached static data.

Introduction

One of the common UI design patterns is cascading selection lists, which allows you to narrow down selection in one list based on the values selected from other lists. For example, you can select a manufacturer, which will narrow down the list of products to select from. Selecting a product can in turn allow selecting a specific model of that product from a smaller list. When using those as filter criteria, you may also want to allow selecting multiple manufacturers, which would display a combined list of products for all of them and so forth.

In a typical UI application, developers implement this behavior manually in the View/Controller layer by listening to the selection change events on the corresponding UI controls and refreshing the dependent selection lists accordingly. If the same cascading selection lists are common enough to be used in multiple screens, then developers may create some common reusable functions or custom controls at best, or just duplicate the same code in multiple screens at worst.

In this article we will show you how to implement this behavior in the Model layer instead, which allows doing it in a generic way and enables reusing it across multiple screens or even with different presentation layer technologies, such as WPF, Silverlight or ASP.NET. We will use the open-source Xomega Framework to demonstrate this approach. 

Xomega Framework Overview

In order to help you better understand the approach we are discussing, let's go over a quick overview of the Xomega Framework itself. If you want to get a more in-depth understanding of the framework's powerful features, you can read our other articles that are listed at the end of this article.

The View Model in the Xomega Framework consists of a Data Object, which contains a list of named Data Properties. Besides the actual data (values), data properties contain additional meta-information about the property, such as whether it is editable, visible, required, etc. They also provide the necessary functions to convert their values from one format to another, to validate the values and to provide a list of possible values that the property can assume. In addition, they support a property change notification whenever the property value or any other information about the property changes.

The data properties can be easily bound to the standard UI or web controls which will keep both the control's value and control's state in sync with the underlying data property. When a selection control, such as a drop down list or a listbox, is bound to a data property, it will be automatically populated from the list of possible values for that property, and it may also add an extra blank selection option as necessary, if the property is not required.

Whenever anything on the property is changed, such as its value or the editable status, the bound control will be automatically refreshed to reflect these changes. You can also manually trigger the refresh by firing an appropriate property change event.

Implementing Cascading Selection

Now that you know the basic principles of the Xomega Framework, it should be pretty easy for you to understand how one can implement cascading selection in the model. To demonstrate it let's create a simple Car data object that has two properties: a Make and a Model. Both properties will have a selection list of values associated with them, but the list for the model will depend on the currently selected value of the make.

First off, let's create a class CarObject that extends the base Xomega DataObject class. For convenience, we will declare constant strings for the two data property names, so that they could be used when binding to UI controls, and the actual members for the data properties' references with a public getter and a private setter. Technically, you can always access a data object's property by its name using the indexer, e.g. carObject[CarObject.Make], but this will allow for easier access to the properties.

Next, we will override the Initialize method, where we will construct, initialize and configure all the properties and the data object itself. The Make property will be just an instance of a text property, for which we will set the ItemsProvider delegate to return our list of various car makes. The Model property will be configured the same way, except that the ItemsProvider delegate will use the current value of the Make property to filter out the list of car models.

Finally, we will add a change listener to the Make property that checks if the property value has changed and then resets the value of the Model property and also makes it fire a property change event indicating that a list of possible values has changed. This will automatically refresh any selection controls that are bound to the Model property. 

The following code demonstrates this simple approach.

using System.Linq;

using Xomega.Framework;
using Xomega.Framework.Properties;

class CarObject : DataObject
{
    // declare property names that can be used for binding to controls
    public const string Make = "Make";
    public const string Model = "Model";

    // declare property instances to allow easy access to properties
    public TextProperty MakeProperty { get; private set; }
    public TextProperty ModelProperty { get; private set; }

    // construct the properties, initialize and configure
    protected override void Initialize()
    {
        // add a Make property and set up the item provider
        MakeProperty = new TextProperty(this, Make);
        MakeProperty.ItemsProvider = delegate(object input)
        {
            return MakeList;
        };

        // add a Model property and set up the item provider
        ModelProperty = new TextProperty(this, Model);
        ModelProperty.ItemsProvider = delegate(object intput)
        {
            // filter models by the currently selected Make
            var models = from m in ModelList
                         where m.Make == MakeProperty.Value
                         select m.Model;
            return models.ToList();
        };

        // set up the make property change listener to update the list of models
        MakeProperty.Change += delegate(object sender, PropertyChangeEventArgs args)
        {
            if (args.Change.IncludesValue()) // check if the make value was changed
            {
                ModelProperty.SetValue(null); // reset the model value
                ModelProperty.FirePropertyChange( // refresh the list of models
                    new PropertyChangeEventArgs(PropertyChange.Items, null, null));
            }
        };
    }

    // ========================================================
    // The following is some static sample data for the lists

    private static string[] MakeList = { "Acura", "BMW", "Lexus" };
    private static ModelItem[] ModelList = 
    {
        new ModelItem("Acura", "MDX"),
        new ModelItem("Acura", "RDX"),
        new ModelItem("BMW", "X5"),
        new ModelItem("BMW", "X3"),
        new ModelItem("Lexus", "LX 450"),
        new ModelItem("Lexus", "LX 570")
    };

    private class ModelItem
    {
        public string Model { get; private set; }
        public string Make { get; private set; }

        public ModelItem(string make, string model)
        {
            Make = make;
            Model = model;
        }
    }
}

Now you can just bind two drop down list controls in XAML or ASPX to these two properties and they will automatically be populated with the right values. Here's a snippet that illustrates this in XAML.

<Grid xmlns:xom="clr-namespace:Xomega.Framework;assembly=Xomega.Framework"
      xmlns:l="clr-namespace:MyNamespace;assembly=MyAssembly">
   <Label Name="lblMake">Make:</Label>
   <ComboBox Name="ctlMake" xom:Property.Name="{x:Static l:CarObject.Make}"
             xom:Property.Label="{Binding ElementName=lblMake}"/>

   <Label Name="lblModel">Model:</Label>
   <ComboBox Name="ctlModel" xom:Property.Name="{x:Static l:CarObject.Model}"
             xom:Property.Label="{Binding ElementName=lblModel}"/>
</Grid>

Cascading Selection for Cached Static Data

In the previous example we used hard coded arrays to provide lists of possible values for our data properties. Most of the time, selection lists contain static data that rarely if ever changes, and therefore can be cached either for the entire application, or for the current user session or even only for the duration of the current request as needed.

Xomega Framework provides powerful and flexible support for caching static data, which makes it incredibly easy to provide lists of possible values for selection controls, format the values in a custom way as well as to implement cascading selection. Let's take a look at the basic components of the Xomega Framework for caching static data.

Headers

The primary class that is used for storing static data items is called Header, which defines basic information about the object that an item represents. It includes an internal ID of the item as a string, a user facing name/description and an arbitrary number of additional named attributes. A header for an item can be created and configured as follows. 

// construct a new header of type car_models for LX 450 with an internal ID 123
Header model = new Header(“car_models”, “123”, “LX 450”);
model.addToAttribute(“make”, “Lexus”); // set the make attribute (or add to it)
model.DefaultFormat = Header.FieldText; // display item’s text vs. ID by default

The Header class provides a very simple way to display a custom combination of its ID, text or additional attributes as a string by using a special format string passed to its ToString method. You can set the default format on the header to indicate how it should be converted to a string by default. The following snippet demonstrates some of the examples of custom formats. 

// format to display the text followed by ID in parentheses, e.g. “LX 450 (123)”
String format = String.Format( "{0} ({1})", Header.FiedText, Header.FieldId );

// format to display the make followed by the model separated by a dash, e.g. “Lexus - LX 450”
format = String.Format( "{0} - {1}", String.Format(Header.AttrPattern, "make"), Header.FiedText );

// convert the model to a string using the specified format
String displayText = model.ToString(format);

Lookup Tables

After you construct a list of headers of a certain type for your static data, you can store it in a self-indexing lookup table, which allows you to look up a specific item by any unique combination of its ID, text, or any of the additional attributes. For example, if you store a unique abbreviation for the item as an additional attribute, you will be able to look it up in the table by its abbreviation. All you need to do is to call the LookupByFormat method using the desired format as we described above. Method LookupByID is a specific version of such a lookup for the most common case of looking up an item by, well, its ID. You can also perform look ups in case sensitive or insensitive manner. 

Here's a quick example of how to use the lookup tables. 

// build a list of car models using headers
IEnumerable<Header> modelList = buildModelList();
// construct a case sensitive lookup table
LookupTable lkupTbl = new LookupTable("car_models", modelList, true);

// look up the model by ID
Header model = lkupTbl.LookupById("123");
// look up the model by text
model = lkupTbl.LookupByFormat(Header.FieldText, "LX 450");

Lookup Cache

The lookup tables for different types of static data are stored in and accessed from a class called LookupCache. There could be various types of lookup caches that determine how and where the static data is cached, the lifespan of the cache, support for concurrent access, etc. Here is a list of potential types of caches.

  • Global cache. This is a default option for a global application cache, which caches the static data for the entire application and therefore is shared between all application users. This type is represented by the constant LookupCache.Global
  • User cache. This is a cache that is stored for the current user session only, which is different from the application cache in the web environment. It can be used to cache static data that is specific to the current user given security restrictions. This type is represented by the constant LookupCache.User
  • Request cache. This type of cache can be used to cache data for the duration of a request in server environment when processing a request requires reading a certain list and then looking up items in there in multiple places. 
  • Distributed cache. This is a cache that can be hosted on a cluster of multiple servers to distribute utilization of memory and other resources and to provide better scalability and availability. It can be set up on top of the existing distributed cache implementations such as AppFabric, Coherence, etc. The cached data can be shared between multiple applications in this environment. 

Providing a proper lookup cache for the given type is handled by a class implementing the ILookupCacheProvider interface. Xomega Framework includes some default cache providers, but you can configure it to use a custom implementation in the application settings as follows.

<appSettings>
  <add key="Xomega.Framework.Lookup.ILookupCacheProvider" value="MyPackage.MyLookupCacheProvider"/>
</appSettings>

In order to obtain a cached lookup table, you first need to get a lookup cache of the right type and then retrieve the lookup table of the specified type. The lookup table may get loaded into the cache the first time it is being accessed. Sometimes the lookup table will be loaded asynchronously, such as when the loading happens via a WCF service call from Silverlight, which are always asynchronous. In this case you'll need to provide a callback to be invoked after the lookup table is available. The following sample illustrates how to get a cached LookupTable.

protected void WorkWithCarModels()
{
    // set up a delegate if the table is loaded asynchronously
    LookupCache.LookupTableReady onReady = delegate(string type)
    {
        if (!done) WorkWithCarModels();
    };
    // get the global lookup cache
    LookupCache cache = LookupCache.Get(LookupCache.Global);

    // retrieve the lookup table; pass null callback if loading is always synchronous
    LookupTable tblModels = cache.GetLookupTable("car_models", onReady);
    if (tblModels != null)
    {
        // work with the lookup table
        done = true;
    }
}

Loading the Cache

To populate the cache, you can obviously preload each LookupCache at the start of the application, user session or the request as appropriate by constructing the lookup tables and calling the CacheLookupTable directly on the lookup cache object. 

However, a better approach would be to register a number of cache loaders during the application startup, which can each load one or multiple lookup tables dynamically as needed. This will allow you to avoid loading and using a memory for the data that does not get accessed, and also helps better modularize the routines for loading different types of static data. 

To create a cache loader you need to define a class that implements the ILookupCacheLoader interface. The easiest way to do this is to subclass the abstract base class LookupCacheLoader and implement its LoadCache method as follows. 

public partial class CarModelsCacheLoader : LookupCacheLoader
{
    public CarModelsCacheLoader()
        : base(LookupCache.Global, false, "car_models")
    {
    }

    protected override void LoadCache(string tableType, CacheUpdater updateCache)
    {
        // build the lookup table
        LookupTable lkupTbl = buildLookupTable(type);

        // update cache by calling the provided function (possibly asynchronously)
        updateCache(lkupTbl);
    }
}

Next, you can just register your cache loaders during application startup as follows and that will be it.

private void Application_Startup(object sender, StartupEventArgs e)
{
    LookupCache.AddCacheLoader(new CarModelsCacheLoader());
}

Putting It All Together 

Now that you know all the ingredients of the powerful support Xomega Framework provides for caching of static data as well as the basic elements of building view model data objects using data properties, let's take a look at how effortless it makes implementing cascading selection for cached static data. 

To encapsulate all the intricate work with the cached static data, Xomega Framework defines a special data property called EnumProperty, which uses lookup tables to provide a list of possible values as well as to look up and validate values that are being set on the property.

When your property is based on a static list of possible values you just need to construct it as an EnumProperty, or optionally EnumIntProperty when using integer IDs for the values, and then set the EnumType to the type string for the static data in the lookup cache. You can also additionally configure other parameters such as the cache type to use, display format to display the values as, key format that is used for manually entering values, etc. The following snippet demonstrates this property configuration.

EnumProperty ModelProperty = new EnumProperty(this, Model);
ModelProperty.EnumType = "car_models";
ModelProperty.CacheType = LookupCache.Global; // default
ModelProperty.DisplayFormat = Header.FieldText; //default
ModelProperty.KeyFormat = Header.FieldId; //default

Finally, if for each value you have an additional attribute that stores the corresponding value of another property, such as a make for each car model in a list, then you can set up cascading selection by simply calling the SetCascadingProperty method on your EnumProperty and pass the name of the additional attribute and the other property that it depends upon. The following code shows how the previous example of cascading Car Makes and Models can be greatly simplified when working with cached static data. 

class CarObject : DataObject
{
    // declare property instances to allow easy access to properties
    public EnumProperty MakeProperty { get; private set; }
    public EnumIntProperty ModelProperty { get; private set; }

    // construct the properties, initialize and configure
    protected override void Initialize()
    {
        // add a Make property and set up the enum type
        MakeProperty = new EnumProperty(this, "Make");
        MakeProperty.EnumType = "car_makes";

        // add a Model property and set up the enum type
        ModelProperty = new EnumIntProperty(this, "Model");
        ModelProperty.EnumType = "car_models";

        // set up cascading based on the make attribute
        // and the value of the MakeProperty
        ModelProperty.SetCascadingProperty("make", MakeProperty);
    }
}

Conclusion

In this article you have learned how Xomega Framework allows you to encapsulate most of the presentation layer behavior, such as cascading selection, in the view model of the MVC pattern rather than in the view or controller, which makes it reusable across multiple presentation technologies. 

You have also discovered the powerful support Xomega Framework has for caching static data and learned the basic components involved in it - a generic Header class with flexible formatting to represent static data elements, a self-indexing LookupTable that provides powerful lookups by any combination of static data attributes, a customizable LookupCache that can provide caching on multiple levels and, last but not least, modularized on-demand cache loaders that also support asynchronous cache loading. 

Finally, you were able to see how using special EnumProperty and EnumIntProperty data properties makes providing lists of possible values based on the static data and setting up cascading selection just a walk in the park.  

Next Steps  

We value your feedback, so please vote on this article and leave your comments if you liked it or have any suggestions for improving it or any questions about the framework in general. To better understand the features of the framework you can also read our other articles listed in the next section that cover other various  aspects of the framework. 

To see the framework in action you can download the latest release from CodePlex or install it using NuGet. 

Additional Resources

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here