Saturday, August 15, 2009

ASP.NET : Using ClientID in external JavaScript files

In the past year I have been writing a lots of JavaScript code. Mostly because I have been working on same ASP.NET WebForms application for more then 10 months by now. We use JavaScript for things like validation, async HTTP requests etc. To keep our code clean,we try to keep all of our JavaScript in external files. The problem with external JavaScript files is that you cannot use server tags in them, so you cannot obtain ClientID of ASP.NET controls by using <%= control.ClientID %> syntax. My first workaround for this problem was to add JavaScript variables manually on every page by using ClientScript.RegisterClientScriptBlock() method. Every page needed to have a collection of controls which was called JSControls. In the Page_Load event of the page I would add to JSControl collection all controls for which I need access from JavaScript. Code would look like this:

private List<Control> JSControls = new List<Control>();
protected void Page_Load(object sender, EventArgs e)
{
     JSControls.Add(txtName);
     JSControls.Add(txtLastname);
}

Then I had a function that would generate JavaScript code for each control in JSControl collection:

public static string GetClientScriptBlock()
{
StringBuilder sb = new StringBuilder();
sb.Append("<script type=\"text/javascript\">");
foreach (Control c in JSControls)
{
    if (c != null)
    {
        sb.Append(string.Format("var {0} = '{1}';", c.ID + "ClientID", c.ClientID));
    }
}
sb.Append("</script>");
return sb.ToString();
}

Then, again in the Page_Load event I needed to call RegisterClientScriptBlock with JavaScript code block:

protected void Page_Load(object sender, EventArgs e)
{
JSControls.Add(txtName);
JSControls.Add(txtLastname);                         
Page.ClientScript.RegisterStartupScript(this.GetType(), "myClientBlock", GetClientScriptBlock(), false);
}

This works fine, so if I need to access for example ClientID of TextBox control called txtName I would just refer to txtNameClientID variable, like this:

document.getElementById(txtNameClientID); 

While this approach is fine it has some disadvantages. One problem is that there could be controls with same ID but in different parent controls. Another disadvantage is that this approach is not very reusable, it would force me to violate DRY principle, since the same code had to be added to every page.

A better solution

There is a better solution. A reusable custom control can be created to automate JavaScript variable creation for us. The idea is to put the control on a Page, define for which controls from the page you need ClientID’s and the control would do the rest of the work. The custom control would also have some kind of Namespace property, that would solve the problem with same ID on different controls. Design-Time support would also be nice to have here, since we would not need to write control id's manually. Let’s make our idea to realization!

Control syntax

Goal is to have control with syntax as simple as possible. Suppose that we call our control JSClientIDList, we want to have syntax like this:

<ReducingComplexity:JSClientIDList runat="server" ID="JSClientIDList1" Namespace="namespace1">
<JSControls>
    <ReducingComplexity:ControlItem ControlID="btnOK" />
</JSControls>
</ReducingComplexity:JSClientIDList>
We see that this syntax is very straightforward. JSClientIDList control has two important properties. The first one is Namespace property, which defines some kind of prefix for each of the controls. The second important property is JSControls which defines list of ASP.NET controls for which JavaScript variables will be created. We will need another class for representing a control in our JSControls list. We will call that class ControlItem. The whole code would look like this:
using System;
using System.Collections.Generic;
using System.Web;
using System.Web.UI;
using System.ComponentModel;
using System.Diagnostics;
using System.Web.UI.WebControls;
using System.Text;

namespace ReducingComplexity.Web.Controls
{
[PersistChildren(false)]
[ParseChildren(true)]
public class JSClientIDList : Control
{
    private string m_Namespace;
    [Browsable(true)]
    [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
    public string Namespace
    {
        get
        {
            return m_Namespace;
        }
        set
        {
            m_Namespace = value;
        }
    }

    [Browsable(true)]
    [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
    [PersistenceMode(PersistenceMode.InnerProperty)]
    public List<ControlItem> JSControls { get; set; }

    public JSClientIDList()
    {
        JSControls = new List<ControlItem>();
    }
    protected override void OnPreRender(EventArgs e)
    {
        Page.ClientScript.RegisterClientScriptBlock(this.Parent.GetType(),this.ClientID, GetClientScriptBlock(), true);
        base.OnPreRender(e);
    }
    protected override void AddParsedSubObject(object obj)
    {
        if (obj is ControlItem)
        {
            this.JSControls.Add((ControlItem)obj);
            return;
        }
    }
    private string GetClientScriptBlock()
    {
        StringBuilder sb = new StringBuilder();
        sb.AppendFormat("var {0}=new Object();", this.Namespace);
        foreach (ControlItem ci in this.JSControls)
        {
            Control c = this.FindControl(ci.ControlID);
            string clientId = c != null ? c.ClientID : "";
            sb.AppendFormat("{0}.{1}='{2}';", this.Namespace, ci.ControlID, clientId);
        }
        Debug.WriteLine(sb.ToString());
        return sb.ToString();
    }
}
public class ControlItem
{
    [TypeConverter(typeof(ControlIDConverter))]
    public string ControlID { get; set; }
}
}

I’ll try to explain most important parts of the code. To enable clean syntax we needed to use PersistChildren and ParseChildren attributes. These attributes define how nested content of the control will be interpreted. More details can be found here. Next we also needed to override AddParsedSubObject method, which will add nested controls to JSControls collection.
protected override void AddParsedSubObject(object obj)
{
if (obj is ControlItem)
{
      this.JSControls.Add((ControlItem)obj);
      return;
}
}
Another interesting part is usage of ControlIDConverter as TypeConverter. This will enable us to use Design-Time support for ControlID property, or to be more precise it will provide dropdown list of all controls available for addition, so that we can choose from the list.

image

When I first started implementing this functionality I didn’t really know about ControlIDConverter class. My plan was to write my own Type Converter which would provide such functionality, but I failed. To cut the long story short,the reason I failed was I didn’t know I could use GetService() function of ITypeDescriptorContext interface to get the instance of IDesignerHost. Anyway, Reflector reveals all the secrets:

image

We can also see that the actual registration of JavaScript code block is done in OnPreRender event since in this event all controls in Control collection are available.

For example if we define Namespace property to “Namespace1” and have a ASP.NET button control with ID "btnOK" then to get ClientID of the btnOK button from external JavaScript file we would use following syntax:

document.getElementById(Namespace1.btnOK);

And of course JSClientIDList control would have to be declared like this:

<ReducingComplexity:JSClientIDList runat="server" ID="JSClientIDList1" Namespace="namespace1">
      <JSControls>
          <ReducingComplexity:ControlItem ControlID="btnOK" />
      </JSControls>
</ReducingComplexity:JSClientIDList>

By using this simple control our goal of using external files for JavaScript code has been achieved.

No comments:

Post a Comment