A Scalable Ajax Wizard Sample Application


Introduction

Ajax advertises the ability to quickly develop web applications which act like window's stand alone applications by reducing the flicker between post backs. One potential trap, like with all dot-net applications, is that if these applications are not well architectured then they will not scale well.

The problem is that classical Dot Net development involves placing controls onto a form and depending on the situation setting some of these controls' visibility to false. The problem is even though the control's visibility is set to false most of the code for this control will still run. When there are many complex custom controls on a form with the visibility set to false, the performance suffers. This article will describe a complex custom Ajax enabled wizard control and how this control can be created dynamically on the page when needed. For example, this would be useful if there are many wizards on a form that could be launched from the sidebar.

The sample application discussed in this article involves the following:

  • Extending an ASP 2.0 framework wizard control to create a custom wizard and nesting it in an Ajax Tool Kit's "ModalPopupExtender" control to enable it to be shown in a popup.
  • Developing the wizard sheets using custom user controls and giving the wizard the ability to load them dynamically using the "LoadControl" method.
  • A "ContentPlaceHolder" control on the form will be loaded with the wizard when it is to be displayed.
  • Javascript will be used to load a hidden control on the page with a string value. This will tell the web page's code behind which wizard to display or whether to hide the wizard's popup. This is done to be able to dynamically load the wizard during the "OnInit" method of the form so that the wizard sheet's view state will be maintained between page posts. If instead, dot net server events were used, then these events would be handled after the "OnInit" method and their view state would be lost.
  • "UpdatePanel" controls are used to give the Ajax effect. The code behind manages which of these controls are updated using its "Update()" method.
  • Dot Net Validator controls and the Ajax Toolbox's "ValidatorCalloutExtender" control are used on the wizard sheets to demonstrate that classic validation still works.

To run this sample application go to: http://www.mcsoftware.biz/wizard/home.aspx

The sample application will only manage a single wizard from the form. However, this solution could be easily extended to have any number of wizard's launchable from a single page without degrading performance.

Extending the Standard Wizard Control

The wizard control will manage several wizard sheets. Each of these sheets will be a separate custom web user control and each will implement a common interface. These wizard sheets will be displayed one at a time as the User clicks the "Next" button. The common interfaces allow the code to use polymorphism so that the sheets' methods may be called without regard to which particular sheet is being referenced. In the "CustomWizard" class we extend the "Wizard" class and implement the "IWizard" interface. In the "CreateControlHierarchy" method, we specify the css classes that will give all of the wizards used by the application a similar look and feel. Note that each of the wizards' sheets will be responsible for loading and saving their own data.

/// <summary>

/// All wizards need to do these things.

/// </summary>

interface IWizard

{

    void CreateControls();

    void Save(int contactID);

    void LoadData(int contactID); 

}

 

/// <summary>

/// Each wizard step needs to save their data.

/// </summary>

public interface IWizardStepNew

{

    void Save(DataSetContacts.DataTableContactsRow dataTableContactsRow);

}

 

/// <summary>

/// In edit mode each wizard will need to load the data on the wizard step first.

/// </summary>

public interface IWizardLoadData

{

    void Load(DataSetContacts.DataTableContactsRow dataTableContactsRow);

} 

 

/// <summary>

/// This class will set the look and feel for all the wizards in the application.

/// </summary>

public class CustomWizard:Wizard, IWizard

{

    public CustomWizard()

    {

        CreateControls();

    }

    protected override void OnUnload(EventArgs e)

    {

        base.OnUnload(e);

    }

    protected override void CreateControlHierarchy()

    {

        base.CreateControlHierarchy();

        this.CssClass = "wizardMaster";

 

        this.SideBarButtonStyle.ForeColor = System.Drawing.Color.White;

 

        this.NavigationButtonStyle.CssClass = "navigation";

 

        this.SideBarStyle.CssClass = "sideBarStyle";

        this.HeaderStyle.CssClass = "headerStyle";

    }

    public void AddWizardStep(WizardStep wizardStep)

    {

        this.WizardSteps.Add(wizardStep);

    }   

}

The "CustomWizard" class is extended to give a specific type of wizard as is shown in the code below. Note that in the "CreateControls" method, individual custom web user controls for our wizard sheets are loaded using the "LoadControl" method. In the "LoadData" method, it doesn't matter which of these individual wizard sheets is being referenced as long as it implements the "WizardStep" interface. Likewise, the "Save" method works similarly.

public class Wizard1 : CustomWizard

{ 

    public override void CreateControls()

    { 

        this.ID = "WizardA";

 

        // ========Sheet 1========

        WizardStep wizardStep = new WizardStep();

        wizardStep.Title = "Personal Information";

 

        // create an instance of the desired control

        System.Web.UI.UserControl userControl = new UserControl();

        Control lControl = userControl.LoadControl(@"~\Wizard1\Sheet1.ascx");

        wizardStep.Controls.Add(lControl);

 

        this.AddWizardStep(wizardStep);

 

        // ========Sheet 2========

        wizardStep = new WizardStep();

        wizardStep.Title = "Comments";

 

        // create an instance of the desired control

        userControl = new UserControl();

        lControl = userControl.LoadControl(@"~\Wizard1\Sheet2.ascx");

        wizardStep.Controls.Add(lControl);

 

        this.AddWizardStep(wizardStep);

 

        // ========Finish===========

        wizardStep = new WizardStep();

        wizardStep.Title = "Confirmation";

        wizardStep.StepType = WizardStepType.Complete;

 

        // create an instance of the desired control

        userControl = new UserControl();

        lControl = userControl.LoadControl(@"~\Wizard1\Finish.ascx");

        wizardStep.Controls.Add(lControl); 

        this.AddWizardStep(wizardStep); 

    }

    public override void LoadData(int contactID)

    {

        DataSetContacts dataSetContacts = DataMethods.GetDataSetContacts();

        DataSetContacts.DataTableContactsRow dataTableContactsRow = dataSetContacts.DataTableContacts.FindByContactID(contactID);

        foreach (WizardStep wizardStep in this.WizardSteps)

        {

            foreach (Control control in wizardStep.Controls)

            {

                IWizardLoadData wizardLoadData = control as IWizardLoadData;

                if (wizardLoadData != null)

                {

                    wizardLoadData.Load(dataTableContactsRow);

                }

            }

        }

    }

 

    public override void Save(int contactID)

    {

        DataSetContacts dataSetContacts = DataMethods.GetDataSetContacts(); 

        DataSetContacts.DataTableContactsRow dataTableContactsRow;

 

        if (contactID == -1)

        {

            dataTableContactsRow = dataSetContacts.DataTableContacts.NewDataTableContactsRow();

            dataSetContacts.DataTableContacts.AddDataTableContactsRow(dataTableContactsRow);

        }

        else

        {

            dataTableContactsRow = dataSetContacts.DataTableContacts.FindByContactID(contactID);           

        }

       

        foreach(WizardStep wizardStep in this.WizardSteps)

        {

            foreach(Control control in wizardStep.Controls)

            {

                IWizardStepNew wizardStepNew = control as IWizardStepNew;

                if (wizardStepNew != null)

                {

                    wizardStepNew.Save(dataTableContactsRow);

                }

            }

        }      

        DataMethods.SaveDataSetContacts(dataSetContacts);       

    } 

}


Implementation of a Wizard Sheet

The code below shows one of the custom web user controls. Note that the class implements both the "IWizardStepNew" and the "IWizardLoadData" interfaces. The first is used for both inserting a new contact row and editing a row. However, the latter one is used only when editing a contact row. This is because the "IWizardLoadData" interface is used to load the existing data into the controls that will be edited.

public partial class Wizard1_Sheet2 : System.Web.UI.UserControl, IWizardStepNew, IWizardLoadData

{

    protected void Page_Load(object sender, EventArgs e)

    { 

    }

 

    #region IWizardStepNew Members

 

    public void Save(DataSetContacts.DataTableContactsRow dataTableContactsRow)

    {

        dataTableContactsRow.Comments = TextBoxComments.Text;

    }

 

    #endregion

 

    #region IWizardLoadData Members

 

    public new void Load(DataSetContacts.DataTableContactsRow dataTableContactsRow)

    {

        TextBoxComments.Text = dataTableContactsRow.Comments;   

    }

 

    #endregion

Master Page HTML Markup

In this application, the wizard's visibility is handled in the MasterPage.

The html mark-up with this control hierarchy is shown in Figure 1 below.

Figure 1 – Control Hierarchy

I.    UpdatePanel (Used when we want to hide or show the popup dialog)
      a. Panel ( Reference by a ModalPopupExtender to display wizard in popup dialog)
            i. UpdatePanel (Used when we change wizard sheets)
                  1. PlaceHolder (Used to load the wizard control.)

The "Panel" control is referenced by an Ajax Toolbox's "ModalPopupExtender" providing the modal popup that will house the wizard control. The "PlaceHolder" control is where the wizard will be dynamically loaded into by the code behind when it needs to be displayed. The "UpdatePanel" controls are there to eliminate flicker during post back. The outer "UpdatePanel" will only be updated when we are showing and hiding the popup. The inner "UpdatePanel" is used when we go from wizard sheet to wizard sheet so that only the wizard sheet is updated and nothing else on the page.

Also worth noting on the Master Page's html markup are two hidden controls:

<input
type="hidden" runat="server" id="hiddenTarget" name="hiddenTarget" />
<input type="hidden" runat="server" id="hiddenRowID" name="hiddenID" />

The "hiddenTarget" control is used to tell the code behind which wizard to display or hide. The "hiddenRowID" is loaded with "-1" if it is a new row or if it will be loaded with the contact row's id being edited. These hidden fields will be inspected by the code behind's "OnInit" method. It is important that we load the wizard in the "OnInit" method so that the view state of the individual wizard sheet's are maintained between page posts.

The javascript at the top of the page is used to load these hidden fields and to post the page. See below:

<script type="text/javascript" language="javascript" >

function closeDialog()
{

    var hiddenTargetControl = $get('<%=hiddenTarget.ClientID %>');

    if (hiddenTargetControl)
    {

        hiddenTargetControl.value = 'hide';

        _doPostBack(hiddenTargetControl.id,'');

    }
}
function showContact()
{

    var hiddenTargetControl = $get('<%=hiddenTarget.ClientID %>');

    var hiddenRowID = $get('<%=hiddenRowID.ClientID %>');

    if (hiddenTargetControl)
    {

        hiddenTargetControl.value = 'showContact';

        hiddenRowID.value='';

        _doPostBack(hiddenTargetControl.id,'');

    }
}

function editContactRow(rowID)

{

    var hiddenTarget = $get('<%=hiddenTarget.ClientID %>');

    var hiddenRowID = $get('<%=hiddenRowID.ClientID %>');

    if (hiddenTarget)
    {

        hiddenTarget.value = 'showContact';

        hiddenRowID.value = rowID

        _doPostBack(hiddenTarget.id,'');

    }
}        

</script>

Note that when there is a post back to the server that the "hiddenTarget.id" is included as a parameter in the javascript "doPostBack" method. This is done because an "AsyncPostBackTrigger" is defined on the outer "UpdatePanel" in Figure 1 to be the "hiddenTarget" control. By including it as a parameter, a "partial page" refresh is done which only refreshes the popup modal. The rest of the page will not flicker with the post back. Download the code to see how this done.

Master Page HTML Code Behind

The master page code behind is shown below. At the top of the page, there are several properties which retrieve the values from the form's collection. This is done because the values are retrieved before the page loads its view state during the "Onit" method. The "Onit" method then calls the "manageModal" method to determine whether to show or to hide the popup wizard based on the values that were set in the hidden value controls from the javascript. Note that there is also an event handler that is called to update the grid on the content page. If a content page has a grid on it that needs to be updated when the wizard closes, then it will need to subscribe to this event. Also worth noting is that we control which update panel updates through the code behind page via the "UpdatePanel" control's "update()" method.

using
System;

using System.Data;

using System.Configuration;

using System.Collections;

using System.Web;

using System.Web.Security;

using System.Web.UI;

using System.Web.UI.WebControls;

using System.Web.UI.WebControls.WebParts;

using System.Web.UI.HtmlControls;

 

public partial class MasterPage : System.Web.UI.MasterPage

{

    public event EventHandler UpdateUserGrid;

 

    // Holds a reference to the wizard.

    Wizard1 wizard;

 

    /// <summary>

    /// Row ID for the grid that the User is editing.

    /// </summary>

    protected int RowID

    {

        get

        {

            string hRowID = Request.Form[hiddenRowID.ClientID.Replace("_", "$")];

            int rowID;

 

            if (!int.TryParse(hRowID, out rowID))

            {

                rowID = -1;

            }

            return rowID;

        }

    }

    /// <summary>

    /// Specifies which dialog the user wants to open.  Or it will tell us to close the dialog.

    /// </summary>

    protected string TargetValue

    {

        get

        {

            return Request.Form[hiddenTarget.ClientID.Replace("_", "$")];

        }

    }

   

    /// <summary>

    /// Show or hide the modal.  Important to load the dialog on OnInit

    /// to be able to retain state between posts.

    /// </summary>

    /// <param name="e"></param>

    protected override void OnInit(EventArgs e)

    {

        manageModal();      

        base.OnInit(e); 

    }

 

    protected override void OnLoad(EventArgs e)

    {

        // Do this to prevent an error on first save.

        if (!this.IsPostBack)

        {

            DataMethods.SaveDataSetContacts(DataMethods.GetDataSetContacts());

        }

    }

   

    /// <summary>

    /// Raise an event to tell child forms to update their grids.

    /// </summary>

    protected void updateUserGrid()

    {

        if (UpdateUserGrid!=null)

        {

            EventArgs eventArg = new EventArgs();

            UpdateUserGrid(this, eventArg);

        }       

    }

 

    /// <summary>

    /// Show or hide the modal depending on the targetValue

    /// </summary>

    protected void manageModal()

    {

        string targetValue = this.TargetValue;

        if (string.IsNullOrEmpty(targetValue) || PlaceHolder1.Controls.Count != 0)

        {

            return;

        }

        // show the contact dialog.

        if (targetValue == "showContact")

        {

            this.wizard = new Wizard1();

            wizard.FinishButtonClick += new WizardNavigationEventHandler(wizard_FinishButtonClick);

            PlaceHolder1.Controls.Add(wizard);

            ModalPopupExtender1.Show();

 

            //If the contact is in edit mode then we need to load the old data.

            if (this.RowID != -1)

            {

                this.wizard.LoadData(this.RowID);

            } 

        }

        // hide the contact dialog.

        else if(targetValue=="hide")

        {

            ModalPopupExtender1.Hide();

            UpdatePanelPopupPanel.Update();

        }       

    }

    // when the user clicks the finish button we need to save off the data

    // and close the dialog.

    void wizard_FinishButtonClick(object sender, WizardNavigationEventArgs e)

    {

        if (this.TargetValue == "showContact")

        {

            this.wizard.Save(this.RowID);

            updateUserGrid();

        }

    }

}

The Home.aspx Content Page

The code behind for the Home.aspx content page is shown below. This page is pretty standard. It contains and manages a "GridView" control that is nested inside an "UpdatePanel". Note that in the "OnPreInit" method, this page subscribes to the Master's page's "UpdateUserGrid" event so that when the wizard closes, this page knows to update it's grid.


using
System;

using System.Data;

using System.Configuration;

using System.Collections;

using System.Web;

using System.Web.Security;

using System.Web.UI;

using System.Web.UI.WebControls;

using System.Web.UI.WebControls.WebParts;

using System.Web.UI.HtmlControls;

 

public partial class Home : System.Web.UI.Page

{

    /// <summary>

    /// Wire up the masterPage's UpdateUserGrid event.

    /// </summary>

    /// <param name="e"></param>

    protected override void OnPreInit(EventArgs e)

    {

        base.OnPreInit(e);

        MasterPage masterPage = this.Master as MasterPage;

        masterPage.UpdateUserGrid += new EventHandler(masterPage_UpdateUserGrid); 

    }

 

    /// <summary>

    /// When the master page tell's us to update our grid call this method.

    /// </summary>

    /// <param name="sender"></param>

    /// <param name="e"></param>

    void masterPage_UpdateUserGrid(object sender, EventArgs e)

    {

        this.DataBind();

        UpdatePanelUserGrid.Update();

    }

 

    /// <summary>

    /// On page load bind the grid.

    /// </summary>

    /// <param name="sender"></param>

    /// <param name="e"></param>

    protected void Page_Load(object sender, EventArgs e)

    {

        this.DataBind();

        if (!this.IsPostBack && GridView1.Rows.Count==0)

        {

            PanelInitial.Visible = true;

        }       

    }

 

    /// <summary>

    /// Bind the grid.

    /// </summary>

    public override void DataBind()

    {

        GridView1.DataSource = DataMethods.GetDataSetContacts().DataTableContacts;

        GridView1.DataBind(); 

    }

  

    /// <summary>

    /// Method is called when binding the grid in the html markup.

    /// Will return the full name for the contact.

    /// </summary>

    /// <param name="firstName"></param>

    /// <param name="lastName"></param>

    /// <returns></returns>

    protected string GetName(string firstName, string lastName)

    {

        return string.Format("{0} {1}", firstName, lastName);

    }

 

    /// <summary>

    /// When the user wants to delete a name call this event.

    /// </summary>

    /// <param name="sender"></param>

    /// <param name="e"></param>

    protected void LinkButtonDelete_Click(object sender, EventArgs e)

    {

        LinkButton lLinkButton = (LinkButton)sender;

        int contactID;

        if (!int.TryParse(lLinkButton.CommandArgument, out contactID))

        {

            return;

        }

        DataMethods.DeleteContact(contactID);

        this.DataBind();

        UpdatePanelUserGrid.Update(); 

    }

}

Conclusion

The objective of this article was to develop an Ajax application that could be extended into a much larger application using the standard dot net Ajax controls. This was not an easy task. It is much easier to develop a small application using these controls that does not scale well. The most significant knowledge required to develop a scalable application in dot net using the standard controls is an understanding of the page's life cycle and what happens during each event.

The code above seems to run fine for the most part in Firefox 2.0 and IE 7.0. I did have a strange error when I first published the solution to my web server that had something to do with storing the "DataSet" object into session state. For this example application, I don't use a real database but instead persist a dataset in session state. The error had something to do with not beginning using session state until a partial page post. At that point when I tried to store the dataset in session state, it would throw an error. I got around it by caching an empty "DataSet" object into session state on first page load. I'm not sure why this fixes the issue but it works.

Also the application seems to run fine in both browsers if you run it directly. If you however inbed this application in another application using an "IFrame", then the popup doesn't close in IE 7.0. If you do something that causes IE to repaint the screen like moving the scroll bar then the page will refresh and the popup disappears. No such weirdness is observed when running the application in an "IFrame" with Firefox.