# Thursday, July 09, 2009

Ajax History - a how to - Part 3 - complex example (c#)

Part 1 - Introduction
Part 2 - Basic Example
Part 3 - Complex Example
Part 4 - Final Notes
Bonus - Ajax History and the Memento Pattern
Extra Bonus - Issues with Opera

This is the third in a series looking at using Ajax History in .net 3.5. I would recommend you look at Part 1 and Part 2 if you have not already done so.

Ajax out of the box is wonderful in that it reduces page reloads, but it is not wonderful in that it does not treat each Ajax call as a unique URI. As a result all Ajax modified changes are lost when the Back or refresh button are clicked. In addition there is no way to store the URL of the page in such a way that it will display the results of Ajax changes. I explain much of this in Part 1. In Part 2 I provide (very) basic example of storing/retrieving Ajax changes. However in the real world we do not live with simple pages. Most of what we want to reconstruct are complex systems. This post will attempt to give a real-life example of such a functionality.

Many people use mapping pages as an example of how to do Ajax history functionality. That is very nice because, especially for retrieving the pages, because the world generally changes very little geographically. Longitude and Latitude have been fairly constant since the early explorers. GPS does not change from year to year. So it is the perfect example of how you can use a small portion of data (GPS coords for example) to return a large complex data (a map). This is similar to the Memento pattern (take a look at http://www.myfriedmind.com/techBlog/2009/05/20/AjaxHistoryAndTheMementoPattern.aspx for more on my thoughts on that).

Using a Memento pattern is critical because if you were to try to store all that complex data in the Ajax history it would quickly fall to pieces.

So, rule #1 - do not store complex data, store simple data that can be used to reconstruct complex data

While racking my brain for what to use as a non-map example, I came across a post by Senthilkumar Moorthy on forums.asp.net in which he lays out an interaction using Ajax that I thought would be a useful example since it uses multiple history points

The page layout

For simplicity sake I am keeping their names fairly generic and not adding a lot of extra (fun) code...

  • There are two UpdatePanels (upPanel1 and upPanel2).
  • upPanel1 contains a TextBox (textBox1), a Button (button1) and a DropDownList (ddl1)
  • when the user enters in phrase in textBox1 and clicks button1, ddl1 is populated with subchoices
  • when the user clicks on ddl1 to select a choice, upPanel2 displays the details of the selected item in a handful of labels

It should be noted that I am using the Adventure Works (light) database as my source of data.

  1. I will use textbox1 to search through ProductDescription and return a list of possible matching items into ddl1.
  2. Selecting an item from ddl1 will then display information about that particular item in the details pane.

I have also included a class in the page called ProductInformation.

public class ProductInformation
{
      
public int ProductId { get; set; }
      
public string Name { get; set; }
      
public string Model { get; set; }
      
public string Description { get; set; }
      
public DateTime LastModified { get; set; }
}

The history point events

The first thing to consider is where we want to put our history points. Ie, where do we want, if the user clicks 'Back' to roll them back to. Also, what do we want, if they post the URI, to display. It seems to me that there are clearly two logical places and these all take place when events occur (which is a good way to start)

  1. when the user clicks button1 to populate ddl1
  2. when the user selects an item from ddl1 and the detail is displayed

First off, note that we are storing three connected, but different points of data, as opposed to the single point in the simple example of Part 2. In addition we are having to store the Page Title (more on that later) because of an apparent issue with the way that .net handles that.

The caveats

The biggest issue here is that data changes. Here are a few possibilities

  1. the entry of the textbox1 is no longer valid and returns no matching items
  2. the selected item of ddl1 is no longer valid and returns no matching product
  3. the details in upPanel2 have changed since the last update

Again, if this were simply mapping, #1 and #2 (changes in basic data) would not be an issue. And since most people want the most up-to-date maps, #3 does not matter. However, we are going, for the sake of study, assume that we care about all 3 of these potential issues. This means we are going to have to handle that in a graceful way when we unpack it to recreate the data.

I have the sample code included below for you to download and I am using the AdventureWorksLT for my data. You can snag it from CodePlex at http://www.codeplex.com/MSFTDBProdSamples/Release/ProjectReleases.aspx?ReleaseId=4004 . The code itself is fairly rough in patches since I really wanted to hit the main points about Ajax History but let me know if I need to clarify any section of it just so you can understand what I am attempting to show.

Creating the basic methods to create the page display

My methods for populating the DDL are as follows:

    private void PopulateDDL(string text)
    {
        ddl1.Enabled = true;
        StringDictionary _results = GetMatchingDescriptions(text);
        if (_results != null)
        {
            ddl1.DataSource = _results;
            ddl1.DataTextField = "Value";
            ddl1.DataValueField = "Key";
            ddl1.DataBind();
        }
    }
    private void PrefixDDL(string text, string value)
    {
        ddl1.Items.Insert(0, new ListItem(text, value));
        if (ddl1.SelectedIndex != -1)
        {
            ddl1.SelectedItem.Selected = false;
        }
        ddl1.Items[0].Selected = true;
    }

My method for the Display Info (upPanel2) are as follows:

    private void DisplayInfo(string selectedValue, DateTime requestedOn)
    {
        if (String.IsNullOrEmpty(selectedValue))
        {
            DisplayError("That selection is no longer valid. Please retry");
        }
        else
        {
            int _productId;
            if (Int32.TryParse(selectedValue, out _productId))
            {
                ProductInformation _product = GetProduct(_productId);
                if (_product == null)
                {
                    DisplayError("That selection could not be found. Please retry");
                }
                else
                {
                    lblLastModified.Text = _product.LastModified.ToString("F");
                    lblName.Text = Server.HtmlEncode(_product.Name);
                    lblModel.Text = Server.HtmlEncode(_product.Model);
                    lblDescription.Text = Server.HtmlEncode(_product.Description);
                // what if the data is old? notify the user
                    if (requestedOn < _product.LastModified)
                    {
                        lblRequestedOn.Text = String.Format("This item has been modifed since request at {0}", requestedOn.ToString("F"));
                    }
                    else
                    {
                        lblRequestedOn.Text = requestedOn.ToString("F");
                    }
                }
            }
            else
            {
                DisplayError("That was an invalid selection");
            }
        }
    }
    private void ResetDisplayInfo()
    {
        lblRequestedOn.Text = "";
        lblLastModified.Text = "";
        lblName.Text = "";
        lblModel.Text = "";
        lblDescription.Text = "";
    }
    private void DisplayError(string errorText)
    {
        ResetDisplayInfo();
        lblName.Text = errorText;
    }

I will call these methods not merely when the page is initially created but also when it is REcreated during the ScriptManager's Navigate command.

Creating the HistoryPoint methods/event handler

Apart from the modifications that are made similar to what was done on Part 2, the main changes deal with the methods to store the History Points and to recreate the page after the History Points have been reloaded. In other words what happens when a History Point is set and what happens when a History Point is retrieved. Because we do not want to store the ENTIRE page in the browser URI the fundamental question is "what can I get away with?" This is a good time to practice your YAGNI (You ain't gonna need it) to eliminate all but the most essential and simplest pieces of information. In this case I have three pieces that can not be reduced any more

  1. the text in the textbox
  2. the selected value of the dropdown list
  3. the DateTime that the page was loaded (to see if the data has been changed)
  4. (bonus) In addition, due to a 'feature' of Ajax History in .net 3.5, I am also including the Page Title. I will discuss more about this later, but just know that it is included.

My method to store the History Point looks like this:

    protected void StoreHistoryPoints(string textBoxText, string ddlValue,
        DateTime requestedOn, string pageTitle)
    {
        ScriptManager _mySM = ScriptManager.GetCurrent(this.Page);
        NameValueCollection _historyPointEntries = new NameValueCollection();
        _historyPointEntries.Add("whatTextBoxContains", textBoxText);
        _historyPointEntries.Add("whatWasSelectedInDDL", ddlValue);
        _historyPointEntries.Add("requestedOn", requestedOn.Ticks.ToString()); // ticks are shorter in length
        _historyPointEntries.Add("pageTitle", String.Format("AV - {0}", pageTitle));
        _mySM.AddHistoryPoint(_historyPointEntries, String.Format("AV - {0}", pageTitle));
    }

It is very straightforward. I create a NameValueCollection to hold my minimal data and then call the AddHistoryPoint.

Before I continue I should mention that there are three overloaded methods for AddHistoryPoint

  • ScriptManager.AddHistoryPoint(string, string)
  • ScriptManager.AddHistoryPoint(string, string, string)
  • ScriptManager.AddHistoryPoint(NameValueCollection, string)

The second two have an additional string parameter which is used to set the Page Title when the History Point is stored. In this example I use it to set the Page Title to whatever bike model that they are looking at. Thus if they save it in their favorites it gives a user friendly name.

HOWEVER!!! While this works going forward that information is lost when the Navigate method is called and the HistoryPoint information is unpackaged. Ie - going forward the ScriptManager changes the Page Title, but when the page is loaded using the "Back" button or from a manually typed URI, it does not set the Page Title, nor is it accessible through the HistoryEventArgs. As a workaround I have included the Page Title as one of the KeyValue pairs in my history point so that I can recreate along with the rest of the page. Of course, since this is an issue I could simply have set the Page Title manually (for example in the portion of the code that determines the bike to display) but I wanted to show you this overload.

Also note that I could as easily have done this in my method:

    protected void StoreHistoryPoints(string textBoxText, string ddlValue,
        DateTime requestedOn, string pageTitle)
    {
        ScriptManager _mySM = ScriptManager.GetCurrent(this.Page);
        _mySM.AddHistoryPoint("whatTextBoxContains", textBoxText);
        _mySM.AddHistoryPoint("whatWasSelectedInDDL", ddlValue);
        _mySM.AddHistoryPoint("requestedOn", requestedOn.Ticks.ToString()); // ticks are shorter in length
        _mySM.AddHistoryPoint("pageTitle", String.Format("AV - {0}", pageTitle));
    }

There is actually no discernable difference except for the Page Title being handled by the Script Manager going forward. The NameValueCollection that the HistoryEventArgs of the ScriptManager's Navigate method returns are the same in both methods. The code that I display below for the Navigate event handles them exactly the same because both return the same NameValueCollection.

Quick note also that all these entries must be string values (they are stored in the URI after all). For this reason I have changed the DateTime to use ticks instead of writing it out, but you could have easily have chosen a format which would return a simpler string. The key is to approach this with a minimalist attitude. Not being too obsessive, but the shorting the string, the shorter the URI, the better for the system.

Recreating the page

Now I have the process in place for the storing of History Points. I can call this method as my final command from both the button1_Click event handler and the ddl1_SelectedIndexChanged event handler and it will store the information I pass it into the history. The Page does not need to know what this data represents. It is the Caretaker (see Ajax and Memento). All it knows is that it needs to store this information and return it when requested.

The second portion is the unpackaging when the History Points have been returned. This is handled through the ScriptManager's Navigate event. One quick thing to note - this event is called when the "Back" or "Refresh" commands are given to the browser, but it also is called when the page is loaded with the specific History Point URI, for example from a "Bookmark" or from a link.

My event handler looks like this:

   protected void sm1_Navigate(object sender, HistoryEventArgs e)
    {
        long _requestedOnAsTicks;
        bool _hasHistory = long.TryParse(e.State["requestedOn"], out _requestedOnAsTicks);
        if (_hasHistory)
        {
            DateTime _requestedOn = new DateTime(_requestedOnAsTicks);

            string _textStored = e.State["whatTextBoxContains"];
            if (!String.IsNullOrEmpty(_textStored))
            {
                // restore entries on TextBox and DDL
                textbox1.Text = _textStored;
                PopulateDDL(_textStored);
            }
            string _ddlValueStored = e.State["whatWasSelectedInDDL"];
            if (!String.IsNullOrEmpty(_ddlValueStored))
            {
                ListItem _matchingItem = ddl1.Items.FindByValue(_ddlValueStored);
                if (_matchingItem != null)
                {
                   // we have victory, display the item...
                    _matchingItem.Selected = true;
                    DisplayInfo(_ddlValueStored, _requestedOn);
                }
                else
                {
                    // something is wrong, the item no longer shows. Display error...
                    ListItem _errorItem = new ListItem(_ddlValueStored, "");
                    DisplayInfo("", _requestedOn);
                }
            }
            else
            {
                // since no DDL value was selected, add prompt at beginning of DDL
                PrefixDDL("Select One", "");
            }

            // this is a required hack because even though page title is specified in the AjaxHistory, it
            // does not work when you navigate backwards..
            string _pageTitle = e.State["pageTitle"];
            Page.Title = _pageTitle;
        }
        else
        {
            // if there is no DateTime stored than this is the original (ie no history) -> reset form
            textbox1.Text = "";
            ddl1.Items.Clear();
            ResetDisplayInfo();
            Page.Title = "AV";
        }
    }

The Navigate event uses the HistoryEventArgs. This really only has one thing in there - the State, which is the NameValueCollection that you passed the ScriptManager during the AddHistoryPoints calls.

The first thing I do is see if there is anything in there. Since I am wanted to get the date and the TryParse is a convenient way for me to verify that it is a valid long, I check it there. If there is no history than this is a non-modified Ajax Page (probably the very beginning) and I simply reset everything.

Once I know there is a history I have to go through the process of recreating the page. I extract the appropriate information and repopulate the Textbox, the DropDownList (if appropriate) and the Display (if appropriate). By doing this I am creating a complex page from simplistic data.

Because my data may have changed (say the Description has been updated) I have also included a notification to the user if this takes place in the DisplayInfo method. However, I no longer have access to the original data. That data is lost to me because all I am passing are the minimal pieces to recreate the page. What is displayed is the CURRENT information on that particular product.

Also, note the setting of the Page.Title. Remember that even though I did pass that as a parameter during my AddHistoryPoint method call, it is no longer available to me during the Navigate event. If you want to use a unique Page Title when an Ajax modification takes place, make sure to pass the Page Title as its own KeyValue pair and then unpack and recreate it.

Final Notes

If you have stuck in this long, congrats!!! I know this may still seem murky, probably more due to my explanation than to anything else, but there are a few more things I want to touch on. I will actually them in my (potentially) last blog on this - part 4 - wrap up.

Zipped Example

AjaxExamples.zip (1.37 MB)

Comments are closed.