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
{
public const string Make = "Make";
public const string Model = "Model";
public TextProperty MakeProperty { get; private set; }
public TextProperty ModelProperty { get; private set; }
protected override void Initialize()
{
MakeProperty = new TextProperty(this, Make);
MakeProperty.ItemsProvider = delegate(object input)
{
return MakeList;
};
ModelProperty = new TextProperty(this, Model);
ModelProperty.ItemsProvider = delegate(object intput)
{
var models = from m in ModelList
where m.Make == MakeProperty.Value
select m.Model;
return models.ToList();
};
MakeProperty.Change += delegate(object sender, PropertyChangeEventArgs args)
{
if (args.Change.IncludesValue()) {
ModelProperty.SetValue(null); ModelProperty.FirePropertyChange( new PropertyChangeEventArgs(PropertyChange.Items, null, null));
}
};
}
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.
Header model = new Header(“car_models”, “123”, “LX 450”);
model.addToAttribute(“make”, “Lexus”); model.DefaultFormat = Header.FieldText;
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.
String format = String.Format( "{0} ({1})", Header.FiedText, Header.FieldId );
format = String.Format( "{0} - {1}", String.Format(Header.AttrPattern, "make"), Header.FiedText );
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.
IEnumerable<Header> modelList = buildModelList();
LookupTable lkupTbl = new LookupTable("car_models", modelList, true);
Header model = lkupTbl.LookupById("123");
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()
{
LookupCache.LookupTableReady onReady = delegate(string type)
{
if (!done) WorkWithCarModels();
};
LookupCache cache = LookupCache.Get(LookupCache.Global);
LookupTable tblModels = cache.GetLookupTable("car_models", onReady);
if (tblModels != null)
{
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)
{
LookupTable lkupTbl = buildLookupTable(type);
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; ModelProperty.DisplayFormat = Header.FieldText; ModelProperty.KeyFormat = Header.FieldId;
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
{
public EnumProperty MakeProperty { get; private set; }
public EnumIntProperty ModelProperty { get; private set; }
protected override void Initialize()
{
MakeProperty = new EnumProperty(this, "Make");
MakeProperty.EnumType = "car_makes";
ModelProperty = new EnumIntProperty(this, "Model");
ModelProperty.EnumType = "car_models";
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