Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / XHTML

xacc.propertygrid

4.94/5 (77 votes)
21 Mar 2007CPOL6 min read 1   2.3K  
An ASP.NET PropertyGrid

Screenshot - xaccpg.png

Contents

  1. Introduction
  2. Features
  3. Limitations
  4. Design
  5. Implementation
  6. Installation
  7. Usage example
  8. Points of interest
  9. Conclusion
  10. References

Introduction

xacc.propertygrid is an ASP.NET custom control with partial design-time support. It makes use of a host of front- and back-end technologies to provide a dynamic, yet responsive experience to the user. Go to http://xacc.qsh.eu/ (open in new window) right now for a demo! Fiddle, go wild, change the values!

Features

  • ASP.NET server control
  • Easy to use as the WinForms counterpart
  • Contains all the functionality of the WinForms PropertyGrid, with the exclusion of editors and designers
  • Automatic databinding
  • Automatic error checking
  • Customizable
  • Collapsible
  • 'Live Mode'
  • Very lean (15kb Javascript and CSS)
  • Very responsive
  • Works in ASP.NET 1.1 and 2.0
  • Works in all modern browsers (IE 6, Firefox 1.5+, Opera 8+)
  • W3C XHTML 1.0 compliant
  • W3C CSS complaint

Limitations

  • Can't add sub properties without refresh (the control will auto-reload the page in this case)
  • No editor support for the time being
  • Limited design-time support
  • IE 7 seems to be broken, I will support this browser when they release a 'stable' version

Design

The 1st implementation

This was a very crude proof of concept, based on Anthem and using its controls. This proved to work well, but the AJAX interaction was too bloated for my liking. Also, the inability to apply CSS in an efficient manner to WebControls made this not very flexible, but it worked.

The real thing

  1. Planning - this phase involved pondering over many things, exactly visualizing the path to end result.
  2. Layout - this phase involved simply breaking down all the elements needed for the control. Being a grid, this was rather easy with a simple DIV table/grid.
  3. Mock-up - this phase involved mocking up the static HTML with CSS and Javascript.
  4. ASP.NET server control - this phase involved refactoring the static HTML into reusable controls, also Anthem was transformed to 'Skinny' at this stage.
  5. Putting everything together - finally there was some tweaks needed to make everything work together.

Implementation

ASP.NET server control

The control itself is not very exciting, except for the actual binding. Lets look at the code:

C#
object selobj;

[Browsable(false)]
public object SelectedObject
{
  get {return selobj;}
  set
  {
    if (selobj != value)
    {
      selobj = value;
      CreateGrid();
    }
  }
}

ArrayList proplist = new ArrayList();
Hashtable properties = new Hashtable();
ArrayList catlist = new ArrayList();

int catcounter = 0;
int subcounter = 0;
int itemcounter = 0;

void CreateGrid()
{
  if (selobj == null)
  {
    return;
  }

  Controls.Clear();
  properties.Clear();
  proplist.Clear();

  itemcounter =
  catcounter =
  subcounter = 0;

  Controls.Add( new PropertyGridHeader());

  Hashtable cats = new Hashtable();

  foreach (PropertyDescriptor pd in TypeDescriptor.GetProperties(selobj))
  {
    if (!pd.IsBrowsable)
    {
      continue;
    }

    string cat = pd.Category;

    Hashtable mems = cats[cat] as Hashtable;
    if (mems == null)
    {
      cats[cat] = mems = new Hashtable();
    }
    try
    {
      PropertyGridItem pgi = new PropertyGridItem(pd);
      pgi.controlid = ClientID + "_" + itemcounter++;

      properties[pgi.controlid] = pgi;

      object o = selobj;
      object subo = null;

      try
      {
        subo = pd.GetValue(o);
      }
      catch
      {}

      if ( pd.Converter.GetPropertiesSupported())
      {
        foreach (PropertyDescriptor spd in pd.Converter.GetProperties(subo))
        {
          if (spd.IsBrowsable)
          {
            PropertyGridItem pgsi = new PropertyGridSubItem(spd, pgi);
            pgsi.controlid = ClientID + "_" + itemcounter++;
            pgi.subitems.Add(pgsi);

            properties[pgsi.controlid] = pgsi;
          }
        }
      }

      mems.Add(pd.Name, pgi);
    }
    catch (Exception ex)
    {
      Page.Response.Write(ex);
    }
  }

  this.catlist.Clear();
  ArrayList catlist = new ArrayList(cats.Keys);
  catlist.Sort();

  HtmlContainerControl cc = new HtmlGenericControl("div");
  cc.ID = "cats";

  Controls.Add(cc);

  foreach (string cat in catlist)
  {
    PropertyGridCategory pgc = new PropertyGridCategory();
    pgc.CategoryName = cat;

    this.catlist.Add(pgc);

    cc.Controls.Add(pgc);

    Hashtable i = cats[cat] as Hashtable;

    ArrayList il = new ArrayList(i.Keys);
    il.Sort();

    foreach (string pginame in il)
    {
      PropertyGridItem pgi = i[pginame] as PropertyGridItem;

      proplist.Add(pgi);

      pgc.Controls.Add(pgi);

      if (pgi.subitems.Count > 0)
      {
        SubItems si = new SubItems();
        pgi.Controls.Add(si);

        foreach (PropertyGridItem spgi in pgi.subitems)
        {
          si.Controls.Add(spgi);

          proplist.Add(spgi);
        }
      }
    }
  }

  Controls.Add( new PropertyGridFooter());
}

Here I simply iterate through the PropertyDescriptorCollection provided from the TypeDescriptor. Using a PropertyDescriptor is much more useful in this scenario, as it takes a lot of boring reflection code out of the game. It also has the added benefit of caching reflection calls, speeding up reuse when the same controls are recreated.

Firstly, all the properties (and sub properties) are added, and their categories tracked. Then categories get sorted alphabetically, category containers are created, and properties are added to them.

Instead of using WebControls, I decided to use custom controls (deriving from Control). This has the advantage that I can practically emit any HTML that I require. As the design was re-factored, I simply have a few to controls to control the emission. They are as follows:

  • PropertyGridHeader - carries the info at the top of the control.
  • PropertyGridCategory - created for each category.
  • PropertyGridItem - 'Top level' properties of the selected object.
  • SubItems - container for child properties.
  • PropertyGridSubItem - 'Child level' properties.
  • PropertyGridFooter - contains help and bottom bar.

Lets look how the PropertyGridItem works, specifically its RenderEditor method.

C#
void RenderEditor(HtmlTextWriter writer)
{
  if (propdesc.IsReadOnly || ParentGrid.ReadOnly)
  {
    writer.Write(@"<span title=""{1}""><span" + 
           @" id=""{0}"" style=""color:gray"">{1}" + 
           @"</span></span>",
      controlid,
      PropertyValue);
  }
  else
  {
    TypeConverter tc = propdesc.Converter;
    if ( tc.GetStandardValuesSupported())
    {
      string pv = PropertyValue;
      writer.Write(@"<a onclick=""{2}.BeginEdit" + 
        @"(this); return false;"" href=""#""" +
        @" title=""Click to edit""><span" + 
        @" id=""{0}"">{1}</span></a>",
        controlid,
        pv,
        ParentGrid.ClientID);

      writer.Write(@"<select style=""display" + 
        @":none"" onblur=""{0}.CancelEdit(this)"""+
        @" onchange=""{0}.EndEdit(this)"">",
        ParentGrid.ClientID);

      foreach (object si in tc.GetStandardValues())
      {
        string val = tc.ConvertToString(si);
        if (val == pv)
        {
          writer.Write(@"<option " + 
            @"selected=""selected"">{0}</option>", val);
        }
        else
        {
          writer.Write(@"<option>{0}</option>", val);
        }
      }

      writer.Write("</select>");
    }
    else
    {
      if (tc.CanConvertFrom(typeof(string)))
      {
        writer.Write(@"<a onclick=""{2}.BeginEdit" + 
          @"(this);return false"" href=""#""" +
          @" title=""Click to edit""><span " + 
          @"id=""{0}"">{1}</span></a>",
          controlid,
          PropertyValue,
          ParentGrid.ClientID);

        writer.Write(@"<input onkeydown=""return {0}." + 
          @"HandleKey(this,event)"" onblur=""{0}.CancelEdit"""+
          @"(this) style=""display:none"" type" + 
          @"=""text"" onchange=""{0}.EndEdit(this)"" />",
          ParentGrid.ClientID);
      }
      else
      {
        writer.Write(@"<span title=""{1}""><span " + 
          @"id=""{0}"" style=""color:gray"">{1}</span></span>",
          controlid,
          PropertyValue);
      }
    }
  }
}

This is a simple elimination tree, deciding what should be done.

  1. If property or the PropertyGrid is read-only, render as a 'label'.
  2. If property supports 'SupportedValues', render as 'label' with hidden DropDownList (SELECT). (Note: this isn't really correct, and I will try find a solution for those properties supporting values and conversion from string).
  3. If property supports converting from type string, render as 'label' with hidden TextBox (INPUT).
  4. Render as 'label'.

If either option 2 or 3 was chosen, when the 'label' gets clicked, the 'label' is hidden, and the 'edit' control shown till the control loses focus or changes its value.

AJAX

C#
[Skinny.Method]
public string[] GetValues()
{
  string[] output = new string[proplist.Count];

  for (int i = 0; i < output.Length; i++)
  {
    output[i] = (proplist[i] as PropertyGridItem).PropertyValue;
  }
  return output;
}

[Skinny.Method]
public string[] SetValue(string id, string val)
{
  if (!ReadOnly)
  {
    PropertyGridItem pgi = properties[ClientID + 
                           "_" + id] as PropertyGridItem;
    pgi.PropertyValue = val;
  }

  return GetValues();
}

[Skinny.Method]
public string[] GetDescription(string id)
{
  PropertyGridItem pgi = properties[ClientID + 
                         "_" + id] as PropertyGridItem;
  PropertyDescriptor pd = pgi.Descriptor;

  string[] output = new string[] { pd.DisplayName + " : " + 
                    pd.PropertyType.Name, pd.Description };
  return output;
}

Initially I used Anthem for AJAX support, but their model seemed too bloated. I really wanted a cut-down version. The result, "Skinny" (I renamed it to prevent conflicts), is a bare bones version of Anthem, and only supports calling methods on a Control, but with the added feature that static methods can be called as well. The PropertyGrid's methods are designed to do as much as possible per 'callback', minimizing traffic needed.

Javascript

JavaScript
Element =
{
extended: true,

visible: function(vis)
{
  if (vis != null)  {
    if (typeof vis == 'boolean')
      vis = vis ? '' : 'none';
    this.style.display = vis;
  }
  return this.style.display != 'none';
},

kids: function(index)
{
  if (index == null) {
    var c = [];
    for (var i = 0; i < this.childNodes.length; i++)
      if (this.childNodes[i].nodeType != 3)
        c.push($(this.childNodes[i]));
    return c;
  }
  else
  {
    for (var i = 0, j = 0; i < this.childNodes.length; i++) {
      if (this.childNodes[i].nodeType != 3) {
        if (j == index)
          return $(this.childNodes[i]);
        j++;
      }
    }
    return null;
  }
},

parent: function()
{
  return $((this.parentNode == 'undefined') ? 
            this.parentElement : this.parentNode);
},

prev: function()
{
  var p = this.previousSibling;
  while (p.nodeType == 3)
    p = p.previousSibling;
  return $(p);
},

next: function()
{
  var p = this.nextSibling;
  while (p.nodeType == 3)
    p = p.nextSibling;
  return $(p);
}
};

function $(e)
{
  function extend(dst,src)
  {
    if (!dst.extended)
      for (var i in src)
        dst[i] = src[i];
    return dst;
  }
  return extend( (typeof e == 'string') ?  
         document.getElementById(e) : e , Element);
}

The $ function was inspired by the Prototype lib, allowing me to use cross browser safe JavaScript. JavaScript has beautiful functional bits. This might not work for everybody, but it has proved handy for me.

The rest of the JavaScript contains three functions for AJAX, and finally the main PropertyGrid prototype. This allows me to handle everything in a per-instance way, and allows me to dynamically inject the styles into the control. There are two classes of functions, the event handlers responding to user input, and the 'editing' functions consisting of BeginEdit, EndEdit and CancelEdit.

CSS

The CSS is also not very exciting, except for the CSS injected when the control loads to apply its style. This unfortunately is not supported in Opera, but I have a fix in place for that, which emits the style into the HTML body (but breaking XHTML conformance). Let's look how this is done:

JavaScript
ApplyStyles: function(stylesheet)
{
  var self = this;
  function rule(sel,val)
  {
    var sels = sel.split(',');
    for (var i = 0; i < sels.length; i++)
    {
      var s = sels[i];
      var re = /\s/;
      var res = re.exec(s);
      if (res)
        s = s.replace(re, '_' + self.id + ' ');
      else
        s = s + '_' + self.id;
      if (stylesheet.addRule) //IE
        stylesheet.addRule(s, val);
      else if (stylesheet.insertRule) // Moz
        stylesheet.insertRule(s + '{' + val + '}', 
        stylesheet.cssRules.length);
      else
        return; //opera
    }
  }
  rule('.PG','width:' + this.width + 'px');
  rule('.PG *','color:' + this.fgcolor + ';font-family:' + 
       this.family + ';font-size:' + this.fontsize);
  rule('.PGH,.PGF,.PGC,.PGF2','border-color:' + 
       this.headerfgcolor + ';background-color:' + this.bgcolor);
  rule('.PGC *','line-height:' + this.lineheight + 
       'px;height:' + this.lineheight +'px');
  rule('.PGC a,.PGC_OPEN,.PGC_CLOSED',
       'width:' + this.padwidth + 'px');
  rule('.PGC_HEAD span','color:' + this.headerfgcolor);
  rule('.PGI_NONE,.PGI_OPEN,.PGI_CLOSED','width:'+ 
       this.padwidth+'px;height:'+this.LineHeightMargin()+'px');
  rule('.PGI_NAME,.PGI_VALUE,.PGI_NAME_SUB','width:'+
       this.HalfWidth()+'px;background-color:'+this.itembgcolor);
  rule('.PGI_VALUE a,.PGI_VALUE select','width:100%');
  rule('.PGI_NAME_SUB span','margin-left:' + this.padwidth + 'px');
  rule('.PGI_VALUE a:hover','background-color:' + this.selcolor);
  rule('.PGI_VALUE input','width:' + this.HalfWidthLess3() +
    'px;line-height:' + this.InputLineHeight() + 
    'px;height:' + this.InputLineHeight() + 'px');
}

The rules are added to the last style sheet in the browser window. To make CSS 'instance' like, class names are appended with the ClientID of the PropertyGrid.

Installation

  1. Copy the 'pg' directory (images, css and script) into your webroot directory.
  2. Add reference to project, or add to toolbox.

Usage example

Object code

Sample properties demonstrating attribute usage to control the output:

C#
[Category("Appearance")]
[Description("Change this value, and see the ones below change too." +
  "Change a value from below and see how this one changes.")]
public Rectangle Bounds
{
  get {return bounds;}
  set {bounds = value;}
}
[TypeConverter(typeof(ExpandableObjectConverter))]
public Nested2 NestedStruct
{
  get {return n2;}
  set {n2 = value;}
}

A more advanced example allowing string[] to be set via a comma delimited string (as there is no access to an Editor dialog currently):

C#
string[] buddies = {"Tom","Dick","Harry"};

[TypeConverter(typeof(StringArrayConverter))]
public string[] Buddies
{
  get {return buddies ; }
  set {buddies = value; }
}

public class StringArrayConverter : 
       System.ComponentModel.ArrayConverter
{
  public override bool CanConvertTo(ITypeDescriptorContext 
                       context, Type destinationType)
  {
    if (destinationType == typeof(string))
    {
      return true;
    }
    return base.CanConvertTo (context, destinationType);
  }

  public override bool CanConvertFrom(ITypeDescriptorContext 
                       context, Type sourceType)
  {
    if (sourceType == typeof(string))
    {
      return true;
    }
    return base.CanConvertFrom (context, sourceType);
  }


  public override object ConvertFrom(ITypeDescriptorContext 
                  context, CultureInfo culture, object value)
  {
    if (value is string)
    {
      return (value as string).Split(',');
    }

    return base.ConvertFrom (context, culture, value);
  }

  public override object ConvertTo(ITypeDescriptorContext 
    context, CultureInfo culture,
    object value, Type destinationType)
  {
    if (destinationType == typeof(string))
    {
      return string.Join(",", value as string[]);
    }
    return base.ConvertTo (context, culture, value, destinationType);
  }
}

NOTE: Your classes must be public, or else you will get SecurityAccess exceptions if you run under a limited ASPNET account (ie. most servers).

ASP.NET

In the code-infront:

ASP.NET
<%@ Page language="c#" 
Codebehind="default.aspx.cs" 
AutoEventWireup="false" 
Inherits="PropertyGridWeb.WebForm1" 
enableViewState="true" %>
<%@ Register TagPrefix="xacc" Namespace="Xacc" Assembly="xacc.propertygrid" %>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" >
<HTML lang="en">
  <HEAD>
    <title>ASP.NET PropertyGrid Demo</title>
  </HEAD>
  <body>
    <form id="Form1" method="post" runat="server">
      <xacc:propertygrid id="pg1" runat="server" ShowHelp="True"></
        xacc:propertygrid>
      <xacc:propertygrid id="pg2" runat="server" ReadOnly="True" Width="350"
        SelectionColor="CadetBlue" BackgroundColor="NavajoWhite"
        FontFamily="Tahoma" FontSize="9pt" ForeColor="DimGray"
        HeaderForeColor="Brown" ItemBackgroundColor="WhiteSmoke">
      </xacc:propertygrid>
    </form>
  </body>
</HTML>

The design-time support is limited. When you drag and drop the control into a form, the above will be 'generated'. To get the grids looking better, add a designtime link to the style sheet.

The code-behind:

C#
void Page_Load(object sender, System.EventArgs e)
{
  pg1.SelectedObject = Global.STATIC;
  pg2.SelectedObject = Global.STATIC;
}

Just as you would use the normal PropertyGrid. Keep in mind you are using a 'stateless' environment, hence why I simply used static members in the example.

Points of interest

  • Javascript rocks!
  • DevBoi (offline) - all the standards right in the browser
  • FireFox WebDeveloper - extremely handy, but fails on the added style sheets
  • FireBug - very handy DOM explorer
  • IE Web Toolbar - very handy, works where Firefox fails
  • VS.NET JavaScript debugging - to debug JavaScript in VS.NET, just enable it in IE Then set a breakpoint in a .js file (the .aspx wont work), and viola!

Conclusion

Possible enhancements:

  • Editor support.
  • Folding/collapsing animations.
  • Exposing 'verb' methods from selected object (ie. foo.Save() ).
  • Much better design-time support without bloating the control.
  • More Javascript clientside utilization.

Thanks to Paul Watson for help on JavaScript and CSS.

Thanks to the authors of Anthem.

References

  • EMCA 262
  • W3C CSS
  • W3C XHTML
  • Prototype lib

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)