Showing posts with label Custom Filter. Show all posts
Showing posts with label Custom Filter. Show all posts

Friday, 13 February 2009

AJAX History in a Dynamic Data Website

If you want to know a little more about AJAX History have a look at this video on MSDN Screencasts (used to be MSDN .Net Nuggets) Managing Browser History with ASP.NET AJAX and the ASP.NET 3.5 Extensions Preview it is slightly out of date in that some of the properties have been renamed but its essentially correct and a good foundation for AJAX History’s use. There is a second article Managing Browser History on the Client with ASP.NET AJAX and the ASP.NET 3.5 Extensions Preview which may be of interest for some.

The Problem

To appreciate the solution you need to understand the problem. So here’s what happens you have a standard ASP.Net Dynamic Data V1 website with several filters on each list page, there are two things that will irritate your users to do with AJAX partial post back:

1. You make several selections and click back you will go to the previous page not the last selection (try it with and without partial render enabled in site.master EnablePartialRendering="true" see Listing 1).

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Dynamic Data Site</title>
    <link href="~/Site.css" rel="stylesheet" type="text/css" />
</head>
<body class="template">
    <h1><span class="allcaps">Dynamic Data Site</span></h1>
    <div class="back">
        <a runat="server" href="~/"><img alt="Back to home page" runat="server" src="DynamicData/Content/Images/back.gif" />Back to home page</a>
    </div>

    <form id="form1" runat="server">
    <div>
        <asp:ScriptManager ID="ScriptManager1" runat="server" EnablePartialRendering="true"/>
        <asp:ContentPlaceHolder id="ContentPlaceHolder1" runat="server">
        </asp:ContentPlaceHolder>
    </div>
    </form>
</body>
</html>

Listing 1 – the default Site.master

2. You click a link from a List page on which you have made several filter selections view the sub page and then click back you reach the page with no filtering applied.

Both of these issues can be really irritating for the user, because A it’s not the expected behaviour and B it’s a pain to have to redo all those filters (after all users are really lazy smile_omg)

Have you watched the video? well if you have no prior knowledge of AJAX History I strongly suggest you have a look at the video after all it's only 13mins 27secs long.

The Solution

First of all this is only my first attempt at this I think once I get into the Dynamic Data V2.0 Preview/Release I will redo this as a class that inherits from UserFilterControlBase to easily give this functionality in filter templates.

<asp:ScriptManager 
    ID="ScriptManager1" 
    runat="server" 
    EnableHistory="true"
    EnableSecureHistoryState="false"
    EnablePartialRendering="true"/>

Listing 2 – the ScriptManager on Site.master alterations

As you can see from the Listing 2 enabling AJAX History is quite easy you just need to add the EnableHistory="true" property to the control. The other property is to disable encryption of the history information that is added to the URL EnableSecureHistoryState="false" this is one of the methods that have been renamed for the RTM version.

Now we need to edit the ~/DynamicData/Content/FilterUserControl.ascx.cs file see Listing 3 for the code all alteration are in BOLD italic.

using System;
using System.Collections.Specialized;
using System.Linq;
using System.Web.UI;
using System.Web.UI.WebControls;

public partial class FilterUserControl : System.Web.DynamicData.FilterUserControlBase
{
    // global variable for holding the History Point value
    private String HistoryPointValue;

    public event EventHandler SelectedIndexChanged
    {
        add
        {
            DropDownList1.SelectedIndexChanged += value;
        }
        remove
        {
            DropDownList1.SelectedIndexChanged -= value;
        }
    }

    public override string SelectedValue
    {
        get
        {
            return DropDownList1.SelectedValue;
        }
    }

    protected void Page_Init(object sender, EventArgs e)
    {
        if (!Page.IsPostBack)
        {
            PopulateListControl(DropDownList1);

            // Set the initial value if there is one
            if (!String.IsNullOrEmpty(InitialValue))
                DropDownList1.SelectedValue = InitialValue;
        }

        // add event handler to capture history point.
        this.SelectedIndexChanged += ThisSelectedIndexChanged;

        // Add OnNavigate handler to restore History points.
        var scriptManager = ScriptManager.GetCurrent(Page);
        if (scriptManager != null)
            scriptManager.Navigate += ScriptManagerOnNavigate;
    }

    protected void ThisSelectedIndexChanged(object sender, EventArgs e)
    {
        var filterRepeater = this.GetParentFilterRepeater();
        if (filterRepeater != null)
        {
            // add History point for the curret state of
            // all filters so we can restore them later.
            var scriptManager = ScriptManager.GetCurrent(Page);
            if (scriptManager != null && scriptManager.IsInAsyncPostBack)
            {
                var nvcHistory = new NameValueCollection();

                var filterControls = filterRepeater.GetFilterControls();
                if (filterControls.Count() > 0)
                {
                    foreach (var filter in filterControls)
                    {
                        var f = filter as FilterUserControl;
                        if (f != null)
                            nvcHistory.Add(f.DataField, f.SelectedValue);
                    }
                    // if we get some filter states add the history point.
                    if (nvcHistory.Count > 0)
                        scriptManager.AddHistoryPoint(nvcHistory, Page.Title);
                }
            }
        }
    }

    protected void ScriptManagerOnNavigate(object sender, HistoryEventArgs e)
    {
        HistoryPointValue = e.State[DataField];
        if (e.State[DataField] == null || e.State[DataField] == "")
            DropDownList1.SelectedIndex = 0;
        else if(HistoryPointValue != null)
        {
            // check if history point is in the DDL and set History Point value
            ListItem item = DropDownList1.Items.FindByValue(HistoryPointValue);
            if (item != null)
                DropDownList1.SelectedValue = HistoryPointValue;
        }
    }
}

Listing 3 – FilterUserControl.ascx.cs file

And finally here are the extension methods use in Listing 3.

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

/// <summary>
/// Summary description for ExtensionMethods
/// </summary>
public static class ExtensionMethods
{
    /// <summary>
    /// Get the parent FilterRepeater of the control it's used on.
    /// </summary>
    /// <param name="control">The control to find the parent FilterRepeater of.</param>
    /// <returns>The parent FilterRepeater.</returns>
    public static FilterRepeater GetParentFilterRepeater(this Control control)
    {
        var parentControl = control.Parent;
        while (parentControl != null)
        {
            var FilterRepeater = parentControl as FilterRepeater;
            if (FilterRepeater != null)
                return FilterRepeater;
            else
                parentControl = parentControl.Parent;
        }
        return null;
    }

    /// <summary>
    /// Get a List of FilterUserControlBase filters from the FilterRepeater.
    /// </summary>
    /// <param name="filterRepeater">The FilterRepeater to get a List of filter from</param>
    /// <returns>returns a List of type FilterUserControlBase.</returns>
    public static IEnumerable<UserControl> GetFilterControls(this FilterRepeater filterRepeater)
    {
        var filters = new List<UserControl>();

        foreach (RepeaterItem item in filterRepeater.Items)
        {
            var filter = item.Controls.OfType<UserControl>().FirstOrDefault();
            filters.Add(filter);
        }

        return filters.AsEnumerable();
    }
}

Listing 4 – Extension methods

Now when you skip back and forth through pages you should find it works just like you and your users were expecting.

Tuesday, 9 September 2008

Dynamic Data Futures – Part 3 – AnyColumnAutocomplete Filter

  1. Part 1 - Getting Dynamic Data Futures filters working in a File Based Website.
  2. Part 2 - Create the AnyColumn filter from the Dynamic Data Futures Integer filter.
  3. Part 3 – Creating the AnyColumnAutocomplete filter.

Creating the AnyColumnAutocomplete filter

This version of the Autocomplete filter was adapted in response to this article on the Dynamic Data forum by levalencia.

The issue is that the Autocomplete filter and AutocompleteFilter web service is designed to work with Foreign Key columns, I thought this would be like the previous article in this series and be quite simple, instead it turned out to be a bit more complicated smile_thinking.

Making the AnyColumnAutocomplete FieldTemplate.

The issue initially was that the Autocomplete filter was assuming MetaForeignKeyColumn fkColumn and then referencing the ParentTable from it this meant that we needed a new FieldTemplate no just some tweaking.

protected void Page_Init(object sender, EventArgs e)
{
    var fkColumn = Column as MetaForeignKeyColumn;

    //// dynamically build the context key so the web service knows which table we're talking about
    autoComplete1.ContextKey = AutocompleteFilterService.GetContextKey(fkColumn.ParentTable);
    ...
}

Listing 1 – Autocomplete filter Page_Init

And then in the AutocompleteFilter web service has no reference to the column this meant that either we create a new web service of modify the current I decided to modify it as there were only a few new methods needed to handle the non foreign key columns.

So without any further ado here’s the changes to the FieldTemplate to make the AnyColumnAutocomplete filter.

We remove the line:

var fkColumn = Column as MetaForeignKeyColumn;

and replace all referenced to fkColumn.PartentTable with Column.Table or Column

e.g. change

MetaTable parentTable = fkColumn.ParentTable;
to
MetaTable parentTable = Column.Table;

And change the following line

autoComplete1.ContextKey = AutocompleteFilterService.GetContextKey(fkColumn.ParentTable);

to

autoComplete1.ContextKey = AutocompleteFilterService.GetContextKey(Column);

And that's the changes to make the AnyColumnAutocomplete filter.

Now the changes to the AutocompleteFilter web service

The first change to the AutocompleteFilter web service was to add an overloaded GetContextKey that takes a MetaTable as remember this line from above:

autoComplete1.ContextKey = AutocompleteFilterService.GetContextKey(Column);

So the overloaded method takes a MetaColumn as it’s parameter not MetaTable:

public static string GetContextKey(MetaColumn column)
{
    return String.Format("{0}#{1}#{2}", column.Table.DataContextType.FullName, column.Table.Name, column.Name);
}

Listing 2 – GetContextKey method

Note: That from the column it gets similar properties as the original but also get the Column.Name

Next the changes to the GetCompletionList which returns the list of results.

[System.Web.Services.WebMethod]
[System.Web.Script.Services.ScriptMethod]
public string[] GetCompletionList(string prefixText, int count, string contextKey) {
    MetaTable table = GetTable(contextKey);

    IQueryable queryable = BuildFilterQuery(table, prefixText, count);

    return queryable.Cast<object>().Select(row => CreateAutoCompleteItem(table, row)).ToArray();
}

Listing 3 – GetCompletionList original method

[System.Web.Services.WebMethod]
[System.Web.Script.Services.ScriptMethod]
public string[] GetCompletionList(string prefixText, int count, string contextKey)
{
    MetaTable table;
    String[] param = contextKey.Split('#');

    if (param.Length > 2)
    {
        table = GetTable(contextKey);
        var list = BuildFilterQuery(table, prefixText, count, param[2]);

        return list;
    }
    else
    {
        table = GetTable(contextKey);
        IQueryable queryable = BuildFilterQuery(table, prefixText, count);

        return queryable.Cast<object>().Select(row => CreateAutoCompleteItem(table, row)).ToArray();
    }
}

Listing 4 – GetCompletionList modified method

In the modified version we split the contectKey into param local variable and check the number of entries to determine whether it’s dealing with ForeignKey column or any other column.

Also note the minor change to the GetTable method where the number of parameters is check and added a check for 2 OR 3 paramers.

Debug.Assert(param.Length == 2  param.Length == 3, String.Format("The context key '{0}' is invalid", contextKey));

After this I have added another overloaded method for the BuildFilterQuery which also takes the column name.

private static String[] BuildFilterQuery(
    MetaTable table,
    string prefixText,
    int maxCount,
    String columnName)
{
    var column = table.GetColumn(columnName);

    // query = {Table(Customer)}
    var query = table.GetQuery();

    // row
    // entityParam = {row}
    var entityParam = Expression.Parameter(column.Table.EntityType, "row");

    // row.DisplayName
    //var property = Expression.Property(entityParam, columnName);
    //property = {row.City}
    var property = Expression.Property(entityParam, column.EntityTypeProperty);

    // row => row.Property
    // columnLambda = {row => row.City}
    var columnLambda = Expression.Lambda(property, entityParam);

    // "prefix"
    // constant = {"Lo"}
    var constant = Expression.Constant(prefixText);

    // row.DisplayName.StartsWith("prefix")
    // startsWithCall = {row.City.StartsWith("Lo")}
    var startsWithCall = Expression.Call(
        property,
        typeof(string).GetMethod("StartsWith", new Type[] { typeof(string) }),
        constant);

    // row => row.DisplayName.StartsWith("prefix")
    // whereLambda = {row => row.City.StartsWith("Lo")}
    var whereLambda = Expression.Lambda(startsWithCall, entityParam);

    // Customers.Where(row => row.DisplayName.StartsWith("prefix"))
    // whereCall = {Table(Customer).Where(row => row.City.StartsWith("Lo"))}
    var whereCall = Expression.Call(
        typeof(Queryable),
        "Where",
        new Type[] { table.EntityType },
        query.Expression,
        whereLambda);

    // query.Select(row => row.Property)
    // selectCall = {Table(Customer).Where(row => row.City.StartsWith("Lo")).Select(row => row.City)}
    var selectCall = Expression.Call(
        typeof(Queryable),
        "Select",
        new Type[] { query.ElementType, columnLambda.Body.Type },
        whereCall,
        columnLambda);

    // query.Select(row => row.Property).Distinct
    // distinctCall = {Table(Customer).Where(row => row.City.StartsWith("Lo")).Select(row => row.City).Distinct()}
    var distinctCall = Expression.Call(
        typeof(Queryable),
        "Distinct",
        new Type[] { column.EntityTypeProperty.PropertyType },
        selectCall);

    // Customers.Where(row => row.DisplayName.StartsWith("prefix")).Take(20)
    // distinctCall = {Table(Customer).Where(row => row.City.StartsWith("Lo")).Select(row => row.City).Distinct().Take(20)}
    var takeCall = Expression.Call(
        typeof(Queryable),
        "Take",
        new Type[] { typeof(String) },
        distinctCall,
        Expression.Constant(maxCount));

    var result = query.Provider.CreateQuery(takeCall);
    List<String> list = new List<string>();
    foreach (var item in result)
    {
        list.Add(AutoCompleteExtender.CreateAutoCompleteItem(item.ToString(), item.ToString()));
    }

    return list.ToArray();
    //return query.Provider.CreateQuery(distinctCall);
}

Listing 5 – overloaded BuildFilterQuery method

Listing 5 essentially does what Sample 1 does; which get the first two cities from the Customer table where they begin with “Br” and makes sure they are distinct.

var DC = new NWDataContext();
var q = (from c in DC.Customers
        where c.City.StartsWith("Br")
        select c.City).Distinct().Take(2);

Sample 1 – getting the first 2 customer whose City starts with “Br”

And then returns an array that is ready to pass back to the AjaxToolkit Autocomplete control.

And here’s the Metadata classes

[MetadataType(typeof(Customer_MD))]
public partial class Customer
{
    public class Customer_MD
    {
        [Filter(FilterControl = "AnyColumnAutocomplete")]
        public object City { get; set; }
    }
}

Listing 6 – metadata classes

Website project file (not including Dynamic Data Futures project)

Hope this helps smile_teeth