Dealing with ASP.NET controls with a lot of sub-controls

October 4, 2006 on 11:39 am | In .NET Coding |

Before I get started here: I know that a lot of you here aren’t big fans of type conversion, and I’m not either… but let me stop you just for a second. The web user interface is slow. It’s the slowest part of your entire process, unless you’ve done something very unusual (or wrong?). Response time is in milliseconds, and sometimes if you’re very bogged down, it’s in seconds, so really, a bit of type conversion extracting values from your web page is not all that big of a performance hit, relatively speaking. This is not to mention that you get to be the one writing the controls on the page, so you can stick to a set of reasonable rules for yourself, and not really have to worry about corner cases tripping you up.

I find myself in the awkward position a lot of having to grab a crap-load of input from a page, and then populate some objects with the values I’ve retreived, and then send them off to a controller of some sort. This is why I hate writing web interfaces… it’s all the same boring repatative unbearable stuff day in and day out… Instantiate an object, get control field value, set object value… rinse, lather, repeat…

Well, at a basically unnoticable performance trade off you can make things a lot easier on yourself by just writing a little bit of generic (not necessarily Generics) code that will make numerous controls and values a little more managable.

Now the code I’m pasting is an example, not a take home and complain if it doesn’t do everything you want saying “That Dave Dolan’s an idiot and doesn’t know anything!” (While you may be right about that for other reasons, this isn’t supposed to be a one fits all solution.)


using System;
using System.Data;
using System.Configuration;
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;
using System.Text;
/// <summary>
/// Summary description for AccessorUserControl
/// </summary>
///
 
namespace MyUserControls
{
    public class AccessorUserControl : System.Web.UI.UserControl
    {
 
        public object GetValue(string Field)
        {
            WebControl ctrl = FindControl(Field) as WebControl;
 
            if (ctrl == null) throw new Exception(string.Format("Specified Control '{0}' was not found.", Field));
 
            if (ctrl as ITextControl != null)
                return (ctrl as ITextControl).Text;
 
            if (ctrl is ListControl)
                return (ctrl as ListControl).SelectedValue;
 
            if (ctrl is CheckBox)
                return (ctrl as CheckBox).Checked;
 
            if (ctrl is Calendar)
                return (ctrl as Calendar).SelectedDate;
 
            throw new Exception(string.Format("Getting the value of the specified control '{0}' is not supported!",Field));
        }
 
        /// <summary>
        /// Try very hard to get the expected type value equivelant to that of
        /// the one represented by the web control. If it's a numeric target type,
        /// try very hard to UNFUZZ any number stuff, like numeric separators (1,000)
        /// or currency signs ($12).  Use the decimal point separator based on the
        /// server's current culture (that's my favorite part!)...
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="Field"></param>
        /// <returns></returns>
        public T GetValue<T>(string Field)
        {
            Type t = typeof(T);
 
            object value = GetValue(Field);
 
            if (value is string && IsNumeric(t))
                value = (object)FilterNumeric((string)value, t);
 
            return FlexType<T>(value);
        }
 
        /// <summary>
        /// Try to cast the value first, then try to convert it, else goto Screwed
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="Field"></param>
        /// <returns></returns>
        public T FlexType<T>(object returnValue)
        {
            if (returnValue is T) return (T)returnValue;
 
            try
            {
                if (typeof(IConvertible).IsAssignableFrom(returnValue.GetType()))
                    return (T)Convert.ChangeType(returnValue, typeof(T));
 
            }
            catch(Exception ex)
            {
                throw new InvalidOperationException(string.Format("Can't support casting OR converting specified instance of {0} to type {1}", returnValue.GetType(), typeof(T)), ex);
            }
 
            throw new InvalidOperationException(string.Format("Can't support casting OR converting specified instance of {0} to type {1}", returnValue.GetType(), typeof(T)));
            
        }
 
        static bool IsNumeric(Type t)
        {
           switch(Type.GetTypeCode(t))
           {
               case TypeCode.Object:
               case TypeCode.String:
               case TypeCode.DBNull:
               case TypeCode.Boolean:
               case TypeCode.Char:
                   return false;
 
               default:
                   return true;
           }
        }
 
        static bool AllowsDecimals(Type t)
        {
            switch(Type.GetTypeCode(t))
            {
                case TypeCode.Decimal:
                case TypeCode.Double:
                case TypeCode.Single:
                    return true;
                default:
                    return false;
            }
        }
 
        static bool isNegativeSign(string negativeSign, string input, int pos)
        {
            if (input.Substring(pos).StartsWith(negativeSign)) return true;
 
            return false;
        }
 
        static string ExtractDigits(string Input, bool allowDecimal)
        {
            // start simple, whack the leading space.
 
            string input = Input.Trim();
 
            StringBuilder sb = new StringBuilder();
 
            bool periodFound = false;
 
            char periodChar = System.Globalization.CultureInfo.CurrentCulture.NumberFormat.CurrencyDecimalSeparator[0];
            string negativeSign = System.Globalization.CultureInfo.CurrentCulture.NumberFormat.NegativeSign;
            
 
            char c = '\n';
 
            const char TOP = '9';
            const char BOTTOM = '0';
 
            for(int x = 0; x < input.Length; x++)
            {
                c = input[x];
 
                if (char.IsWhiteSpace(c))
                    goto outter;
 
                // only allow the negative sign at the 'start' of the thing, ignore it otherwise.
                if (isNegativeSign(negativeSign, input, x) && x == 0)
                {
                    x += (negativeSign.Length - 1);
                    sb.Append(negativeSign);
                }
 
                if ((c <= TOP && c >= BOTTOM) )
                {
                    sb.Append(c);
                }
                else if ( c == periodChar)
                {
                    if (allowDecimal && !periodFound)
                    {
                        periodFound = true;
                        sb.Append(c);
                    }
                    else
                        goto outter;
                }
                
            }
 
        outter:
            string result = sb.ToString();
            
            if (!string.IsNullOrEmpty(result) && result[result.Length - 1] == periodChar)
                result = result + "0";
            
            return result;
        }
 
        string FilterNumeric(string src, Type t)
        {
            string filtered = ExtractDigits(src, AllowsDecimals(t));
 
            // if we filtered everything out, return the default value.
            if (filtered.Length == 0)
                return Activator.CreateInstance(t).ToString();
 
            return filtered;
        }
 
        public void SetValue(string Field, object Value)
        {
            WebControl ctrl = FindControl(Field) as WebControl;
 
            if (ctrl == null) throw new Exception(string.Format("Specified Control '{0}' was not found.", Field));
 
            if (ctrl as ITextControl != null)
            {
                (ctrl as ITextControl).Text = FlexType<string>(Value);
                return;
            }
 
            if (ctrl is ListControl)
            {
                (ctrl as ListControl).SelectedValue = FlexType<string>(Value);
                return;
            }
 
            if (ctrl is CheckBox)
            {
                (ctrl as CheckBox).Checked = FlexType<bool>(Value);
                return;
            }
 
            if (ctrl is Calendar)
            {
                (ctrl as Calendar).SelectedDate = FlexType<DateTime>(Value);
                return;
            }
 
            throw new Exception(string.Format("Getting the value of the specified control '{0}' is not supported!", Field));
        }
 
        public object this[string idx]
        {
            get
            {
                return GetValue(idx);
            }
 
            set
            {
                SetValue(idx, value);
            }
        }
 
        public AccessorUserControl()
        {
            //
            // TODO: Add constructor logic here
            //
        }
    }
}

You’ll want to derive your usercontrols from this thing, and then you can access them from the outside world by string field name, and even coerce the types, if they are compatible at all…

Keypoints here:

  • The GetValue and SetValue take different actions based on the type of controls it finds
  • The GetValue<T> feature allows you to specify what sort of output type you want to try and force the user’s garbled input into — it’s pretty good at it
    • First defuzz the numbers if thats what you wanted…
      • First step is determining if the T you asked for is numeric, if so, it’s checked by the System.TypeCode of the typeof(T). Most of the types are indeed numeric, so we need to be sure to check the string input (if it’s a string) to make sure that we only read upto a terminal character (if your value doesn’t support decmials, we stop at the decimal, otherwise we stop at whitespace, ignoring extra stuff like place value groupings or currency symbols, or even parentheses.)
      • Gotta be careful with the negative sign… also use the local setting for that. We also ignore it if it’s not at the start of the trimmed input. Doesn’t make sense, we’re not evaluating expressions here.. though you could do that with the Jscript.Eval function if you like..
      • At the end of FilterNumeric… check to see if we filtered out everything, and if we did, return the default value for whatever type it is, coverted to a string.
    • FlexType first determines if the T you asked for is the type we got, if so, it just returns it cast to your type (basically a no-op)
    • If not, we’ll start trying to convert.
  • I’ve made the utility helper functions static so they don’t have to be instantiated along with every instance of the control. No need to repeat ourselves, even if it’s only in memory and we can’t see it.
  • IT IS IMPERATIVE THAT THE BASE CLASS NOT BE DECLARED ABSTRACT! Notice that mine isn’t. It’s a flaky thing with asp.net user controls that you can’t see the things in the designer if you have an abstract base class, and no it’s not smart enough to figure out what we’re talking about here. Stupid, yes, true, also yes. If you don’t need the designer, which you may not, then it’s technically ok to use an abstract base class, but if someone else is working on the project with you, be kind and don’t do it.

Now I guess you’d like to see an example: (this is using the codebehind model, I guess you can do it without a code behind, but I can’t take the time at the moment to figure it out : - )

The markup:


<%@ Control Language="C#" AutoEventWireup="true" CodeFile="AddressEditor.ascx.cs" Inherits="AddressEditor" %>
<table>
  <tr>
      <td><asp:Label ID="StreetLabel" runat="server" Text="Street Address"/></td>
      <td><asp:TextBox ID="Street" runat="server" /></td>
  </tr>
  <tr>
      <td><asp:Label ID="CityLabel" runat="server" Text="City"/></td>
      <td><asp:TextBox ID="City" runat="server" /></td>
  </tr>
  <tr>
      <td><asp:Label ID="StateLabel" runat="server" Text="State"/></td>
      <td>
      <asp:DropDownList ID="State" runat="server">
            <asp:ListItem Text="Alabama" />
            <asp:ListItem Text="Alaska" />
            <asp:ListItem Text="Arizona" />
            <asp:ListItem Text="Arkansas" />
      </asp:DropDownList>
      </td>
  </tr>
  <tr>
      <td><asp:Label ID="ZipLabel" runat="server" Text="US Zip Code"/></td>
      <td><asp:TextBox ID="Zip" runat="server" /></td>
  </tr>
</table>

First a usercontrol that’s derived from this monstrosity:


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 AddressEditor : MyUserControls.AccessorUserControl
{
 
     protected void Page_Load(object sender, EventArgs e)
    {
         // look ma, no code!
    }
    
}

A class to be ‘filled’ with the data from the control:


public class Address
{
     string _street;
     string _city;
     string _state;
     int _zip;
 
     public string Street { get { return _street; } set { _street = value; } }
     public string City { get { return _city; } set { _city = value; } }
     public string State { get { return _state; } set { _state = value; } }
     public int Zip { get { return _zip; } set { _zip = value; } }
 
     public Address(string st, string cit, string sta, int zp)
     {
           Street = st;
           City = cit;
           State = sta;
           Zip = zp;
     }
 
    
}

Then a page that actually uses it:

The markup:


<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Default.aspx.cs" Inherits="_Default" %>
 
<%@ Register Src="AddressEditor.ascx" TagName="AddressEditor" TagPrefix="uc1" %>
 
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 
<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title>Untitled Page</title>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <uc1:AddressEditor ID="AddressEditor1" runat="server" />
        <asp:Button id="MakePostBack" runat="server" Text="Test"/>
    </div>
    </form>
</body>
</html>

The codebehind:


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 _Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Page.IsPostBack)
        {
            Address DummyAddress =
                new Address(AddressEditor1.GetValue<string>("Street"),
                            AddressEditor1.GetValue<string>("City"),
                            AddressEditor1.GetValue<string>("State"),
                            AddressEditor1.GetValue<int>("Zip"));
 
            Response.Write("<p>");
            if (DummyAddress.Zip <= 0)
                Response.Write("Hey, you gave me a funny zip code. Very clever of you!");
 
            Response.Write("<br/>Street: " + DummyAddress.Street);
            Response.Write("<br/>City: " + DummyAddress.City);
            Response.Write("<br/>State: " + DummyAddress.State);
            Response.Write("<br/>Zip: " + DummyAddress.Zip);
            Response.Write("</p>");
        }
    }
 
    
}

That should be enough to get you started being happier about Gobs of user input.. and save you a bit of trouble with people who turn of the javascript for their validation. It’s of course possible that the inferences made by this control could produce some undesired values (like zero or something when they enter nothing, or everything incorrectly for numeric types) but 99.9% of the time you still want a value even if it’s not the correct one, so I’m sure you can write the if (x == 0) code yourself for that other 0.1% of the time. Cheers!

No Comments yet »

RSS feed for comments on this post. TrackBack URI

Leave a comment

XHTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Powered by WordPress with Pool theme design by Borja Fernandez.
Entries and comments feeds. Valid XHTML and CSS. ^Top^