Saturday, January 19, 2008

Loading User Controls Using ASP.NET AJAX Page Methods

In this post I am loading ASP.NET user controls to running page using AJAX page methods.

The sample code makes easy to load UserControls from server to the calling page.

Also it loads the associated css stylesheet file url (if needed by the control) and adds new LINK object to the header of the page, so that page loads the stylesheet file (there is a method to unload LINK object from header back.

Also the sample loads any custom javascript code (if needed by the control), and executes it right after getting it from the server.

Here are screen shots for completed sample.

Loading indicator during page method call:

image

Loaded, rendered custom style, and executed custom script for the control:

image

"Next" button of control in turn calls the server for the second user control. This way we get a wizard :)

image

Here we have two functional buttons again - "Back" button loads from the server previous control, "Next" button - the next one.

image

I think you got the idea, used with Windows Workflow Foundation this approach can do a lot of good things :). The next three pages:

image

image

image

 

You can see live example, or download source code.

 

Please review my previous post, where I am explaining how Page method can be called by method name using reflection. I used that approach in this sample.

Also look at the post, where I am explaining how we can control AjaxControlToolkit ModalPopup control from javascript code.

I use some more interesting tricks in the sample, - code generation project, to generate navigation class using code generation templates engine inside VS 2008. See more about this in my previous post.

I use in sample code some new features of C# 3.0, - collection initializers and lambda expressions.

 

There is a lot of code in the sample, but I will try to list only most important parts of it. You can review full source code by downloading it.

1) How UserControls are organized:

image

Note, none of them are loaded into main page's control collection, they are just rendered during page method requests

2) How client side button requests the UserControl:

<input 
    value="Load user control!" 
    type="button" 
    onclick="mainScreen.LoadServerControlHtml(
                'Welcome',
                {'pageID':1,'data':null}, 
                'methodHandlers.BeginRecieve');" 
    />

mainScreen.LoadServerControlHtml javascript function:

mainScreen.LoadServerControlHtml = function(_title, _obj, _callback) {
    /// <summary>
    /// Loads Server user control to the modal dialog 
    /// </summary>
    /// <param name="_title">Title of modal popup</param>
    /// <param name="_obj">
    /// object that we pass to the server
    /// </param>
    mainScreen.ShowModal(
        _title,
        (mainScreen.activityImgObj)
        ?
        ("<center><img src='" + mainScreen.activityImgObj.src + "' /></center>")
        :
        ""
        );
    mainScreen.ExecuteCommand(
        'GetWizardPage', 
        _callback, 
        _obj
        );
}

This function shows modal dialog with progress image inside it and calls another js function which calls server page method

3) Server-side page method uses reflection to call 'GetWizardPage' method in dedicated command class:

/// <summary>
/// returns rendered control's string representation.
/// object "data" should be passed from javascript method 
/// as array of objects consisting of two objects,
/// first - pageID - integer identificator by which we will
/// lookup real control path; second object may be some data
/// that the control needs.
/// </summary>
public object GetWizardPage(object data)
{
    bool errorLogged = false;
    try
    {
        Dictionary<string, object> param =
            (Dictionary<string, object>)data;
        int pageID = (int)param["pageID"];
        object customData = param["data"];

        string controlPath = 
            m_NavigationData.Find(x => x.Key == pageID).Value;

        if (!String.IsNullOrEmpty(controlPath))
        {
            if(
                controlPath.ToLower()
                .EndsWith(".htm") 
                ||
                controlPath.ToLower()
                .EndsWith(".html") 
                ||
                controlPath.ToLower()
                .EndsWith(".txt"))
            {
                string result = "";
                using (
                        TextReader tr = 
                            new StreamReader(
                                HttpContext.Current.Server.MapPath(controlPath)
                                )
                            )
                {
                    result = tr.ReadToEnd();
                }
                return new ContentsResponse(result, string.Empty, string.Empty);
            }
            else
            {
                return TemplateViewManager.RenderView(controlPath, customData);
            }
        }
    }
    catch (Exception ex)
    {
        // Log error
        errorLogged = true;
    }
    if (!errorLogged)
    {
        // Log custom error saying 
        // we did not find the page
    }
    return ContentsResponse.Empty;
}

m_NavigationData is static variable, we use collection initializer to assign it a value right in the place of declaration:

private static List<KeyValuePair<int, string>>
            m_NavigationData = new List<KeyValuePair<int, string>>()
            {
                new KeyValuePair<int, string>(1,Pages.Controls.Welcome),
                new KeyValuePair<int, string>(2,Pages.Controls.AcceptLicenceAgreement),
                new KeyValuePair<int, string>(3,Pages.Controls.PleaseClickProceedButton),
                new KeyValuePair<int, string>(4,Pages.Controls.ConfirmationMessage),
                new KeyValuePair<int, string>(5,Pages.Controls.Installing),
                new KeyValuePair<int, string>(6,Pages.Controls.ThankYouMessage)
            };

ContentsResponse object looks like:

namespace Devarchive_net
{
    public class ContentsResponse
    {
        public ContentsResponse(string _html, string _script, string _customStyle)
        {
            html = _html;
            script = _script;
            customStyle = _customStyle;
        }

        public static ContentsResponse Empty
        {
            get
            {
                return new ContentsResponse(string.Empty, string.Empty, string.Empty);
            }
        }

        public string html = "";
        public string script = "";
        public string customStyle = "";
    }
}

this class is returned to the browser.

As you see page method reads the requested file right from disk as text file and returns it - if the extension for the page is htm, html or txt.

If the extension is ascx, it renders the control using TemplateViewManager.RenderView static method :

using System.IO;
using System.Web;

namespace Devarchive_net
{

    public class TemplateViewManager
    {
        public static ContentsResponse RenderView(string path)
        {
            return RenderView(path, null);
        }

        public static ContentsResponse RenderView(string path, object data)
        {
            TemplatePage pageHolder = new TemplatePage();
            TemplateUserControl viewControl = 
                (TemplateUserControl)pageHolder.LoadControl(path);

            if (viewControl == null)
                return ContentsResponse.Empty;

            if (data != null)
            {
                viewControl.Data = data;
            }

            pageHolder.Controls.Add(viewControl);

            string result = "";
            using (StringWriter output = new StringWriter())
            {
                HttpContext.Current.Server.Execute(pageHolder, output, false);
                result = output.ToString();
            }

            return new ContentsResponse(
                    result, 
                    viewControl.StartupScript, 
                    viewControl.CustomStyleSheet
                    );
        }
    }
}

Here is the trick with stylesheets and custom script. All user controls that may be requested should derive from TemplateUserControl class. This class defines the members for custom script and custom stylesheet:

using System.Web.UI;

namespace Devarchive_net
{
    public class TemplateUserControl : UserControl
    {
        private object m_Data = null;

        public object Data
        {
            get { return m_Data; }
            set { m_Data = value; }
        }

        private string m_StartupScript = string.Empty;

        public string StartupScript
        {
            get { return m_StartupScript; }
            set { m_StartupScript = value; }
        }

        private string m_CustomStyleSheet = string.Empty;

        public string CustomStyleSheet
        {
            get { return m_CustomStyleSheet; }
            set { m_CustomStyleSheet = value; }
        }
    }
}

Also there is a TemplatePage class, this class is derived from Page class. The instance of this class is created when rendering UserControl to get rendered HTML.

Here is it:

using System.Web.UI;

namespace Devarchive_net
{
    public class TemplatePage : Page
    {
        public override void VerifyRenderingInServerForm(Control control) { }
    }
}

Next...

4) Browser gets back a response from the server in callback function we specified earlier:

methodHandlers.BeginRecieve = function(_result) {
    /// <summary>
    /// method that shows result from 
    /// page method "GetWizardPage"
    /// </summary>
    var res = false;
    if(_result.customStyle && _result.customStyle!="") {
        mainScreen.LoadStyleSheet(_result.customStyle);
    }
    if(_result.html && _result.html!="") {
        mainScreen.mainModalContentsDiv.innerHTML = _result.html;
        res = true;
    }
    if(_result.script && _result.script!="") {
        eval(_result.script);
    }
    if(!res) {
        mainScreen.CancelModal();
    } else {
        mainScreen.mainModalExtender._layout();
        setTimeout('mainScreen.mainModalExtender._layout()', 3000);
    }
};

It loads CSS files, then contents, and finally executes custom script if any  was returned from the server.

I think you will be also interested with mainScreen.LoadStyleSheet method, here is it:

mainScreen.LoadStyleSheet = function(_path) {
    if(!this.styleSheets[_path]) {
        var styleSheet;
        styleSheet=document.createElement('link');
        styleSheet.type="text/css";
        styleSheet.rel='stylesheet';
        styleSheet.href = _path;
        document.getElementsByTagName("head")[0].appendChild(styleSheet);
        this.styleSheets[_path] = styleSheet;
    }
};

Also there is another method, that is called when modal dialog is closed :

mainScreen.CancelModal = function() {
    /// <summary>
    /// Hides modal dialog 
    /// </summary>
    this.mainModalExtender.hide();
    var _path;
    for(_path in this.styleSheets) {
        document.getElementsByTagName("head")[0].removeChild(this.styleSheets[_path]);
        delete this.styleSheets[_path];
    }
};
This method removes any links to custom stylesheet files to avoid design issues in future calls to server.

 

Lets return to the server code, and see what UserControls itself contain inside, and what the can do.

Lets see Wecome.ascx file:

<%@ Control Language="C#" AutoEventWireup="true" CodeFile="Welcome.ascx.cs" Inherits="Controls_Welcome" %>
<div class="div">
    <table class="tblStyle">
        <tr>
            <td class="tdLeftUpper">
                <img 
                    alt="Setup" src="Controls/Style/LeftImg.jpg" 
                    style="width: 162px; height: 311px" />
            </td>
            <td class="tdRightUpper">
                <p class="largeText" style="white-space:normal">
                Welcome to the Microsoft ASP.NET AJAX 
                Extentions Source Code Setup Wizard<br />
                </p>
                <br />
                <br />
                <p class="smallText" style="white-space:normal">
                The Setup Wizard allows you to change 
                the way Microsoft ASP.NET AJAX Extensions 
                Source 
                <span 
                    class="customStyleSheetClass">
                    Code
                </span> 
                features are installed on 
                your computer or to remove it from your 
                computer. Click Next to continue or Cancel 
                to exit Setup Wizard.
                </p>
                <div 
                    style="border:solid 1px black;" 
                    class="smallText" 
                    id="custSpan">
                </div>
            </td>
        </tr>
        <tr>
            <td colspan="2" class="tdLower">
                <input
                    type="button"
                    class="wizBtn wizBtnBack"
                    value="Back"
                    onclick=""
                    disabled="disabled"
                    />
                &nbsp;
                <input
                    type="button"
                    class="wizBtnR wizBtnNext"
                    value="Next"
                    onclick="
                       mainScreen.LoadServerControlHtml(
                        'License Agreement',
                        {'pageID':2,'data':null}, 
                        'methodHandlers.BeginRecieve'); 
                    "
                    />
                &nbsp;
                &nbsp;
                &nbsp;
                &nbsp;
                <input
                    type="button"
                    class="wizBtn wizBtnClose"
                    value="Cancel"
                    onclick="mainScreen.CancelModal();"
                    />
            </td>
        </tr>
    </table>
</div>

Note, we use all client side controls, but - you can use any server control you like ! You can use GridView etc.

Code behind for Welcome.ascx:

using System;
using Devarchive_net;

public partial class Controls_Welcome : TemplateUserControl
{
    protected void Page_Load(object sender, EventArgs e)
    {
        this.StartupScript =
            "$get('custSpan').innerHTML=" +
            "'Note - you will read this message "+
            "because we passed custom javascript code from "+
            "c# code!" +
            "The word Code is red because of custom stylesheet!'" +
            ";" 
            ;
        this.CustomStyleSheet =
            String.Format(
            "Controls/Style/Welcome.css?{0}"
            ,
            Guid.NewGuid().ToString()
            );
    }
}

here I assign custom javascript that should be executed in browser after call. You can move this into separate file and read it from c# to make separation of different languages clearer.

Also I assign a stylesheet url to the CustomStyleSheet variable.

 

One more thing - I wrote the code in the way we can pass some custom value from browser when requesting the user control.

If you remember we requested control from javascript like this: 

{'pageID':2,'data':true}

here the second parameter is custom data, in this case it is just a boolean value, but we can pass array, dictionary, and more.

In User control we can analyze this value, to return back some specific view. For example open PleaseClickProceedButton.ascx file. There you can find line:

<%=(Data!=null && (bool)Data)?"checked=\"checked\"":"" %>

You can see how we parse the data passed from client browser.

I use this architecture in my current project. It is just great ! speed is much higher comparing to the page with UpdatePanels, Page methods are much faster, they don't postback any form data, as UpdatePanel does. Besides this, page method executes static method - comparing to UpdatePanel that executes whole page controls collection life cycle. Just use this carefully, if you need lot of form data and a small number of dynamically loaded user controls, use UpdatePanel.

Ok, just see it live !

Please post some comments, I need feedback ! :)

2 comments:

Laurentiu Macovei said...

Very interesting!
But is very limited if the user controls do any kind of postbacks!
I think it needs a method to replace the whole page postbacks with a sort of a webmethod and then apply the postback only to that user control itself.
Of course this will be tricky when the user control is using updatepanels so we'll need to make the update panel somehow aware of the WebMethod.

Anyway so far looks a promising approach.

Sean Cornell said...

awesome article that got me thinking.. i wanted to load a usercontol within a modal pop-up which was completly self contained (i wanted to open a 'properties page' for a usercontrol so the user could customise it)

the page / master page would have no predefined detail i.e. there might be multiple extenders (including the modalpopup it's self). My solution is very similar to this but makes it alittle more managible (and simpler)

i used a webservice, which returned the HTML markup for my modal popup, which i then append to the form element then attach & show the modalpopup.

1) make your web service with a method which will return string []:

public string[] MakePropertiesModal(string elementPath)

2) within this method produce your html using the server control and calling the 'RenderControl()' method of the containing control. note - you must create a 'dummy' button and return this in the rendered HTML.

Button b = new Button();
b.ID = elementPath + "dummyModal";

3) return a referance to an ID and the compiled HTML

string[] _s = new string[2];
_s[0] = elementPath;
_s[1] = sb.ToString();
return _s;

4) in your client side JS define your function to call the web service:

function MakePropertiesModal(elementPath)
{
[NAMESPACE].MakePropertiesModal(elementPath,MakePropertiesModalRendered,Failed,"Property modal prepare failed");

}

5) make your callback function, which will do 2 things 1) append the returned HTML to the default form object and 2) create the ModalPopupBehavior:


function MakePropertiesModalRendered(result, eventArgs)
{
var theForm = document.forms['aspnetForm'];
if (!theForm)
{
theForm = document.aspnetForm;
}

theForm.innerHTML += result[1];
var Behavior = $create(AjaxControlToolkit.ModalPopupBehavior, {"PopupControlID": result[0] + "PropertiesWrapper","dynamicServicePath":"/default.aspx","BackgroundCssClass":"ModalBackDrop","id":result[0] + "PropertiesModal"}, null, null, $get(result[0] + "dummyModal"));
Behavior.show();
}

Thanks allot for this article.

PS; avoid postbacks in the returned html by using the asp:HyperLink control with its navigateURL pointed to javascript:yourwebservicefunction().