Thursday, 26 March 2009

Cascading Filters – for Dynamic Data v1.0 (UPDATED)

This is an article is a follow on from my previous article Cascading or Dependant Field Templates for ASP.Net 4.0 Preview and is the start of a series of three articles which describe Cascading Filters for:

  1. Dynamic Data (v1.0) .Net 3.5 SP1
  2. Dynamic Data Futures (Futures on Codeplex)
  3. Dynamic Data Preview 3 (Preview 3 on Codeplex)

This first version is pretty similar to the previous article on cascading FieldTemplates here we will adapt the CascadingFieldTemplate class to facilitate our need for cascading filters.

Again there will be a new event that will be used for the cascade allowing us to fire the event other than when the dropdown list in changes (i.e. then the parent is changed and we need to pass on the cascade to the next child)

 Filters that need to be cascaded

Figure 1 – Filters that need to be cascaded

In Figure 1 we want Developer to be filtered by Builder and HouseType to in turn be filtered by Development so we see in Figure 2 the desired result, you cannot select a Developer or HouseType until it’s parent as been selected.

Cascading Filters showing cascade in default

Figure 2 – Cascading Filters showing cascade in default

I’m not going to go into a deep explanation here as the code is well commented, I will point out were the differences between the cascading file and filter are.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

/// <summary>
/// Attribute to identify which column to use as a 
/// parent column for the child column to depend upon
/// </summary>
public class CascadeAttribute : Attribute
{
    /// <summary>
    /// Name of the parent column
    /// </summary>
    public String ParentColumn { get; private set; }

    /// <summary>
    /// Default Constructor sets ParentColumn
    /// to an empty string 
    /// </summary>
    public CascadeAttribute()
    {
        ParentColumn = "";
    }

    /// <summary>
    /// Constructor to use when
    /// setting up a cascade column
    /// </summary>
    /// <param name="parentColumn">Name of column to use in cascade</param>
    public CascadeAttribute(string parentColumn)
    {
        ParentColumn = parentColumn;
    }
}

Listing 1 – CascadeAttribute this is the same as in the Filter example

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.DynamicData;
using System.Web.UI.WebControls;
using System.Linq.Expressions;
using System.Web.UI;

/// <summary>
/// Event Arguments for Category Changed Event
/// </summary>
public class SelectionChangedEventArgs : EventArgs
{
    /// <summary>
    /// Custom event arguments for SelectionChanged 
    /// event of the CascadingFieldTemplate control
    /// </summary>
    /// <param name="value">
    /// The value of the currently selected 
    /// value of the parent control
    /// </param>
    public SelectionChangedEventArgs(String value)
    {
        Value = value;
    }
    /// <summary>
    /// The values from the control of the dependee control
    /// </summary>
    public String Value { get; set; }
}

/// <summary>
/// Modifies the standard FilterUserControlBase
/// to support cascading of selected values.
/// </summary>
public class CascadingFilterTemplate : System.Web.DynamicData.FilterUserControlBase
{
    #region Properties
    /// <summary>
    /// Data context
    /// </summary>
    private object DC;

    /// <summary>
    /// This controls list control 
    /// </summary>
    public ListControl ListControl { get; private set; }

    /// <summary>
    /// Parent column of this column named in metadata
    /// </summary>
    public MetaForeignKeyColumn ParentColumn { get; private set; }

    /// <summary>
    /// This FieldTemplates column as MetaForeignKeyColumn
    /// </summary>
    public MetaForeignKeyColumn ChildColumn { get; private set; }

    /// <summary>
    /// Parent control acquired from ParentColumn 
    /// </summary>
    public CascadingFilterTemplate ParentControl { get; set; }
    #endregion

    protected virtual void Page_Init(object sender, EventArgs e)
    {
        DC = Column.Table.CreateContext();

        // get the parent column
        var parentColumn = Column.GetAttributeOrDefault<CascadeAttribute>().ParentColumn;
        if (!String.IsNullOrEmpty(parentColumn))
            ParentColumn = Column.Table.GetColumn(parentColumn) as MetaForeignKeyColumn;

        // cast Column as MetaForeignKeyColumn
        ChildColumn = Column as MetaForeignKeyColumn;

        //TODO: find a way of determining which the parent control is DetailsView or FormView

        // get dependee field (note you must specify the
        // container control type in <DetailsView> or <FormView>
        ParentControl = GetParentControl();
    }

    /// <summary>
    /// Delegate for the Interface
    /// </summary>
    /// <param name="sender">
    /// A parent control also implementing the 
    /// ISelectionChangedEvent interface
    /// </param>
    /// <param name="e">
    /// An instance of the SelectionChangedEventArgs
    /// </param>
    public delegate void SelectionChangedEventHandler(
        object sender,
        SelectionChangedEventArgs e);

    //publish event
    public event SelectionChangedEventHandler SelectionChanged;

    /// <summary>
    /// Raises the event checking first that an event if hooked up
    /// </summary>
    /// <param name="value">The value of the currently selected item</param>
    public void RaiseSelectedIndexChanged(String value)
    {
        // make sure we have a handler attached
        if (SelectionChanged != null)
        {
            //raise event
            SelectionChanged(this, new SelectionChangedEventArgs(value));
        }
    }

    // advanced populate list control
    protected void PopulateListControl(ListControl listControl, String filterValue)
    {
        //get the parent column
        if (ParentColumn == null)
        {
            // if no parent column then just call
            // the base to populate the control
            PopulateListControl(listControl);
            // make sure control is enabled
            listControl.Enabled = true;
        }
        else if (String.IsNullOrEmpty(filterValue))
        {
            // if there is a parent column but no filter value
            // then make sure control is empty and disabled
            listControl.Items.Clear();

            listControl.Items.Add(new ListItem("[Not Set]", ""));

            // make sure control is disabled
            listControl.Enabled = false;
        }
        else
        {
            // get the child columns parent table
            var childTable = ChildColumn.ParentTable;

            // get parent FiledTeamlate
            string[] parentColumnPKV = filterValue.Split(',');
            // this is where I use that file from Dynamic Data Futures
            var parentFieldTemplate = GetSelectedParent(
                parentColumnPKV,
                ParentColumn.ParentTable);

            // get list of values filters by the parent's selected entity
            var itemlist = GetQueryFilteredByParent(
                childTable,
                ParentColumn,
                parentFieldTemplate);

            // clear list controls items collection before adding new items
            listControl.Items.Clear();

            listControl.Items.Add(new ListItem("[Not Set]", ""));

            // add returned values to list control
            foreach (var row in itemlist)
                listControl.Items.Add(
                    new ListItem(
                        childTable.GetDisplayString(row),
                        childTable.GetPrimaryKeyString(row)));

            // make sure control is enabled
            listControl.Enabled = true;
        }
    }

    /// <summary>
    /// Get the entity value of the selected 
    /// value of the parent column
    /// </summary>
    /// <param name="primaryKeyValues">
    /// An array of primary key values
    /// </param>
    /// <param name="parentTable">
    /// Parent columns FK table
    /// </param>
    /// <returns>
    /// Returns the currently selected entity
    /// from the parent list as an object
    /// </returns>
    private object GetSelectedParent(
        string[] primaryKeyValues,
        MetaTable parentTable)
    {
        var query = parentTable.GetQuery(DC);

        // Items.Where(row => row.ID == 1).Single()
        var singleWhereCall = LinqExpressionHelper.BuildSingleItemQuery(
            query,
            parentTable,
            primaryKeyValues);

        return query.Provider.Execute(singleWhereCall);
    }

    /// <summary>
    /// Returns an IQueryable of the current FK table filtered by the  
    /// currently selected value from the parent filed template
    /// </summary>
    /// <param name="childTable">
    /// This columns FK table
    /// </param>
    /// <param name="parentColumn">
    /// Column to filter this column by
    /// </param>
    /// <param name="selectedParent">
    /// Value to filter this column by
    /// </param>
    /// <returns>
    /// An IQueryable result filtered by the parent columns current value
    /// </returns>
    private IQueryable GetQueryFilteredByParent
        (MetaTable childTable,
        MetaForeignKeyColumn parentColumn,
        object selectedParent)
    {
        // get query {Table(Developer)}
        var query = ChildColumn.ParentTable.GetQuery(DC);

        // {Developers}
        var parameter = Expression.Parameter(childTable.EntityType, childTable.Name);

        // {Developers.Builder}
        var property = Expression.Property(parameter, parentColumn.Name);

        // {value(Builder)}
        var constant = Expression.Constant(selectedParent);

        // {(Developers.Builder = value(Builder))}
        var predicate = Expression.Equal(property, constant);

        // {Developers => (Developers.Builder = value(Builder))}
        var lambda = Expression.Lambda(predicate, parameter);

        // {Table(Developer).Where(Developers => (Developers.Builder = value(Builder)))}
        var whereCall = Expression.Call(typeof(Queryable),
            "Where",
            new Type[] { childTable.EntityType },
            query.Expression,
            lambda);

        // generate the query and return it
        return query.Provider.CreateQuery(whereCall);
    }

    /// <summary>
    /// Gets the Parent control in a cascade of controls
    /// </summary>
    /// <param name="column"></param>
    /// <returns></returns>
    private CascadingFilterTemplate GetParentControl()
    {
        // get the parent container
        var parentDataControl = GetContainerControl();

        if (ParentColumn != null)
        {
            // Get Parent FieldTemplate
            return parentDataControl.FindFilterControlRecursive(ParentColumn.Name)
                as CascadingFilterTemplate;
        }
        return null;
    }

    /// <summary>
    /// Get the Data Control containing the FiledTemplate
    /// usually a DetailsView or FormView
    /// </summary>
    /// <param name="control">
    /// Use the current field template as a starting point
    /// </param>
    /// <returns>
    /// A FilterRepeater the control that 
    /// contains the current control
    /// </returns>
    private FilterRepeater GetContainerControl()
    {
        var parentControl = this.Parent;
        while (parentControl != null)
        {
            var p = parentControl as FilterRepeater;
            if (p != null)
                return p;
            else
                parentControl = parentControl.Parent;
        }
        return null;
    }
}

Listing 2 – the CascadingFilterTemplate

The main differences here we are no longer testing for Insert or Edit mode as they don't apply to this control and in the area of acquiring the parent control we now search for a different control type both for the container control and the actual template control. Also not that in the GetParentControl method we don’t have to extract the control from a container this is because it’s the same user control each time.

/// <summary>
/// Get the DynamicControl by searching recursively for it by DataField.
/// </summary>
/// <param name="Root">The control to start the search at.</param>
/// <param name="Id">The DataField of the control to find</param>
/// <returns>The found control or NULL if not found</returns>
public static Control FindFilterControlRecursive(this Control root, string dataField)
{
    var dc = root as CascadingFilterTemplate; //Category
    if (dc != null)
    {
        if (String.Compare(dc.DataField, dataField, true) == 0)
            return dc;
    }

    foreach (Control Ctl in root.Controls)
    {
        Control FoundCtl = FindFilterControlRecursive(Ctl, dataField);

        if (FoundCtl != null)
            return FoundCtl;
    }
    return null;
}

Listing – 3 FindFilterControlRecursive

Listing 3 show the extension method which if I combined both the cascading FieldTemplates and FilterTemplates I would make this a generic extension method:

public static Control FindDynamicControlRecursive<T>(this Control root, string dataField)
where T : Control

So I could reuse the method for either.

Note: For both the CascadingFieldTemplate and the CascadingFilterTemplate to work together there would need to be some changes as to where the delegate is declared as it’s the same delegate for each.

Download (UPDATED)

And here’s the sample website with a script to create the database and an excel worksheet with table data in it.

Next we will cover getting this working with the old Dynamic Data Futures.

9 comments:

Азамат said...

Hi,

There are very useful articles about cascading filters, great work.

But there are some issues with navigating from one entity page to another with filters. For example, from Developers page to RequieredPlots.
There is exception

DropDownList1' has a SelectedValue which is invalid because it does not exist in the list of items.
Parameter name: value

[ArgumentOutOfRangeException: 'DropDownList1' has a SelectedValue which is invalid because it does not exist in the list of items.
Parameter name: value]
System.Web.UI.WebControls.ListControl.PerformDataBinding(IEnumerable dataSource) +2713781
System.Web.UI.WebControls.ListControl.PerformSelect() +49
System.Web.UI.Control.DataBindChildren() +11024287
System.Web.UI.Control.DataBind(Boolean raiseOnDataBinding) +182
System.Web.UI.Control.DataBindChildren() +11024287
System.Web.UI.Control.DataBind(Boolean raiseOnDataBinding) +182
System.Web.UI.WebControls.Repeater.CreateControlHierarchy(Boolean useDataSource) +674
System.Web.UI.WebControls.Repeater.OnDataBinding(EventArgs e) +68
System.Web.DynamicData.FilterRepeater.DataBind() +31
System.Web.DynamicData.FilterRepeater.Page_InitComplete(Object sender, EventArgs e) +47
System.EventHandler.Invoke(Object sender, EventArgs e) +0
System.Web.UI.Page.OnInitComplete(EventArgs e) +11035598
System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint) +1674

Have you thought about this issues?

Stephen J. Naughton said...

Hi Азамат, you are right I will look into this but I will deal with it in the latest version.

http://csharpbits.notaclue.net/2009/04/cascading-filters-and-fields-dynamic.html as this is EF compatible

And I will post updates there :D

Steve :D

Stephen J. Naughton said...

Hi Азамат, you will need to add this to the line in the Page_Init where it tests the InitialValue the DropDownList1.Items.Count > 1

I'm going to update the downloads now

Thanks for your vigilance :D

Steve

Stephen J. Naughton said...

This bug was due to keeping the DDL's empty until the partent was populated :D

Stephen J. Naughton said...

Hi Азамат, I've fixed all the downloads now :D

Remember you can message me no via Digby on my blog when I'm online, just ping me

Steve :D

Anonymous said...

Hi Steve

I have a question in relation to your "GetQueryFilteredByParent" function. Correct me if I'm wrong, but as far as I understand it, it doesn't work for entity data modell where I would like to use the navigation properties to get a result?!

A short example (1:n relation)

table Customers
---------------
CustomerID (uniqueIdentifier)
Name (text)
Area (navigation property)

table Areas
-----------
AreaID (uniqueIdentifier)
Name (text)
Customers (navigation property)

What I would like to do now, is to show only customers that belong to a certain area using your cascading filter

The problem is now, that something like:

var customers = (From c In ctx.Customers Where c.Area == SelectedArea Select c);

won't work. What should work is something like:

var customers = (From c In ctx.Customers Where c.Area.AreaID == SelectedArea.AreaID Select c);

But for this situtation I'm unable to build a proper "GetQueryFilteredByParent" function.

It would be really fantastic, if you could help me out. I'm loosing my hair...

Thank you very much

Stephen J. Naughton said...

Yes you are correct this has been updated by this article (as this version was not EF compatible) http://csharpbits.notaclue.net/2009/04/cascading-filters-and-fields-dynamic.html the new uses feature I found in the Preview 3 filter template ForeignKey.aspx.cs

Steve :D

Wes said...

Thanks!! Your sample was a big help.

I haven't actually tried to implement the cascading filters yet but I had it bookmarked to try later when I ran into a situation where I needed an Expression to return a row. I'm having a difficult time understanding Expressions but your GetSelectedParent function does exactly what I needed.

Now I just need to find a plausable place to insert cascading filters into the project :-)

Stephen J. Naughton said...

Hi Wes, yes Expression are hard :( but the old Futures project still on codeplex has some good examples, ;-) that's where most of my code comes from :D

Steve