Extending ASP.NET MVC 2 Templates

One of the new features of ASP.NET MVC 2 is Templates (DisplayFor/EditorFor), Brad Wilson did a series of post which explains how the templates works, Please read it before you continue this post.

Although he did an excellent job explaining the inner-details, but one thing you will notice that the object model in those examples are traversed from top to bottom or parent to child which is good for explaining the internal, but in our real application we need a bit more support. Lets take a trivial example, you are creating a create/edit screen for your Product, where each product has a associated Category and you want to show this category in a DropDownList, say we have a Product class like the following:

public class Product
{
    public int Id { get; set; }

    public string Name { get; set; }

    public Category Category { get; set; }

    public decimal Price { get; set; }
}

And we want to show a screen like the following:

taim

How can we create the above screen with the new EditorFor statement like the following:

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<ExtendedTemplating.Product>" %>
<% using (Html.BeginForm()) {%>
    <fieldset>
        <legend>Fields</legend>
        <%= Html.EditorForModel() %>
        <div>
            <input type="submit" value="Save" />
            <%= Html.ActionLink("Cancel", "Index") %>
        </div>
    </fieldset>
<% } %>

If you run the above with default configuration, you will find instead of DropDownList, the Category is appearing in a text box with the category id and if scan the ASP.NET MVC source code you will find there is no out of the box support to show a DropDownList.

There are quite a few way to solve the above. First, let us see the easy one:

In our ProductController we will put the categories in the ViewData and set the current product as Model like the following:

public ActionResult Edit(int id)
{
    ViewData["categories"] = database.Categories;
    return View(database.Products.SingleOrDefault(p => p.Id == id));
}

Then, we will create new Editor Template for the Category Type and put into the EditorTemplates folder. The editor template will use the following code:

<%@ Import Namespace="ExtendedTemplating"%>
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<Category>" %>
<%= Html.DropDownList(string.Empty, new SelectList(ViewData["categories"] as IEnumerable<Category>, "Id", "Name", Model.Id), "[Select category]")%>

Now, when you run the above, the DropDownList will be shown instead of the TextBox.

This is okay if we are only showing one or two DropDownList in our application. But how can we create a generic solution which should work for all kinds of DropDownList.

Lets extend the ASP.NET MVC ModelMetaData, but before that let me show you the final result of this extension and then I will explain the internal details and some of issues that I found in the current implementation.

The Category Property of Product class will have a new attribute DropDownList.

[DropDownList("categories", "Id", "Name", "[Select category]")]
public Category Category { get; set; }

As you can see, we have moved the hard coded strings from the View into this new attribute, then in the view (DropDownList.asax) we have the following code:

<%@ Import Namespace="ExtendedTemplating"%>
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl" %>
<script runat="server">
    DropDownListAttribute GetDropDownListAttribute()
    {
        FieldTemplateMetadata metaData = ViewData.ModelMetadata as FieldTemplateMetadata;

        return (metaData != null) ? metaData.Attributes.OfType<DropDownListAttribute>().SingleOrDefault() : null;
    }
</script>
<% DropDownListAttribute attribute = GetDropDownListAttribute();%>
<% if (attribute != null) {%>
    <%= Html.DropDownList(string.Empty, new SelectList(ViewData[attribute.ViewDataKey] as IEnumerable, attribute.DataValueField, attribute.DataTextField, attribute.GetSelectedValue(Model)), attribute.OptionLabel, attribute.HtmlAttributes) %>
<% }%>
<% else {%>
    <%= Html.DisplayForModel()%>
<% }%>

I expect, except the Html.DropDownList you are not familiar with the other parts of the above code. So let me explain this bit by bit, first in the GetDropDownList method you can see we are casting the View.ModelMetaData to a new type FieldTemplateMetaData, then we are retrieving the DropDownListAttribute from its Attributes collection. Next, we are checking whether DropDownListAttribute is present (this is somewhat unnecessary as this template is only used for DropDownList, but to be in safe side we are adding this check), if present we are showing the DropDownList otherwise we are using the MVC framework to handle it.

In MVC2, there are quite a few new things that are introduced to manage the meta data of model, behind the scene it uses the Provider Pattern that we are already familiar with. Currently the MVC2 framework uses the DataAnnotationsModelMetadata and DataAnnotationsModelMetadataProvider to collect the Data Annotation Attributes, the DataAnnotationsModelMetadata is inherited from the ModelMetadata and DataAnnotationsModelMetadataProvider from ModelMetadataProvider.

The first issue that I found is that when the provider is requested to create ModelMetaData (check the CreateMetadata method), it does not store the attributes of the model in ModelMetaData, instead it only uses the attributes to setup the ModelMetaData and forgets the attributes completely. The reason it is wrong that like above example we can use these attributes in building this view. The class also contains another property AdditionalValues which can hold arbitrary data against a key but I am not sure how to utilize that without writing a custom meta model provider.

The next issue is the DataAnnotationsModelMetadataProvider is not correctly handling the DataTyeAttribute, the impact is even you set the DataType like Currency/Date/Time etc  in your model, you have to explicitly use the DisplayFormatAttribute to supply the format. But the correct behavior should be if DisplayFormatAttribute is used it will use the attribute, otherwise it will use the associated DisplayFormatAttribute of the DataTypeAttribute.

The following line of CreateMetadata method in DataAnnotationsModelMetadataProvider:

DisplayFormatAttribute displayFormatAttribute = attributeList.OfType<DisplayFormatAttribute>().FirstOrDefault();

Should become:

DisplayFormatAttribute displayFormatAttribute = attributeList.OfType<DisplayFormatAttribute>().FirstOrDefault() ??
                                                (dataTypeAttribute != null ? dataTypeAttribute.DisplayFormat : null);

The third and the last issue that I found is, it is not possible to create template with code like the default. Well, it is not a big issue for those who are creating only applications, but for component developer like us, it adds some overheads in deployment, documentation as well as support tickets .

Now lets get back to the implementation, first we will create an interface ITemplateField which we will use to store the template name, Please check the above DropDownListAttribute as you can see that we are not specifying any template with built-in UIHintAttribute, the DropDownListAttribute itself maintains the template name internally.

ITemplateField:

public interface ITemplateField
{
    string TemplateName
    {
        get;
    }
}

DropDownListAttribute:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class DropDownListAttribute : Attribute, ITemplateField
{
    private static string defaultTemplateName;

    public DropDownListAttribute(string viewDataKey, string dataValueField) : this(viewDataKey, dataValueField, null)
    {
    }

    public DropDownListAttribute(string viewDataKey, string dataValueField, string dataTextField) : this(viewDataKey, dataValueField, dataTextField, null)
    {
    }

    public DropDownListAttribute(string viewDataKey, string dataValueField, string dataTextField, string optionLabel) : this(DefaultTemplateName, viewDataKey, dataValueField, dataTextField, optionLabel, null)
    {
    }

    public DropDownListAttribute(string viewDataKey, string dataValueField, string dataTextField, string optionLabel, object htmlAttributes) : this(DefaultTemplateName, viewDataKey, dataValueField, dataTextField, optionLabel, htmlAttributes)
    {
    }

    public DropDownListAttribute(string templateName, string viewDataKey, string dataValueField, string dataTextField, string optionLabel, object htmlAttributes)
    {
        if (string.IsNullOrEmpty(templateName))
        {
            throw new ArgumentException("Template name cannot be empty.");
        }

        if (string.IsNullOrEmpty(viewDataKey))
        {
            throw new ArgumentException("View data key cannot be empty.");
        }

        if (string.IsNullOrEmpty(dataValueField))
        {
            throw new ArgumentException("Data value field cannot be empty.");
        }

        TemplateName = templateName;
        ViewDataKey = viewDataKey;
        DataValueField = dataValueField;
        DataTextField = dataTextField;
        OptionLabel = optionLabel;
        HtmlAttributes = new RouteValueDictionary(htmlAttributes);
    }

    public static string DefaultTemplateName
    {
        get
        {
            if (string.IsNullOrEmpty(defaultTemplateName))
            {
                defaultTemplateName = "DropDownList";
            }

            return defaultTemplateName;
        }
        set
        {
            defaultTemplateName = value;
        }
    }

    public string TemplateName { get; private set; }

    public string ViewDataKey { get; private set; }

    public string DataValueField { get; private set; }

    public string DataTextField { get; private set; }

    public string OptionLabel { get; private set; }

    public IDictionary<string, object> HtmlAttributes { get; private set; }

    public object GetSelectedValue(object model)
    {
        return GetPropertyValue(model, DataValueField);
    }

    public object GetSelectedText(object model)
    {
        return GetPropertyValue(model, !string.IsNullOrEmpty(DataTextField) ? DataTextField : DataValueField);
    }

    private static object GetPropertyValue(object model, string propertyName)
    {
        if (model != null)
        {
            PropertyDescriptor property = GetTypeDescriptor(model.GetType()).GetProperties()
                                                                            .Cast<PropertyDescriptor>()
                                                                            .SingleOrDefault(p => string.Compare(p.Name, propertyName, StringComparison.OrdinalIgnoreCase) == 0);

            if (property != null)
            {
                return property.GetValue(model);
            }
        }

        return null;
    }

    private static ICustomTypeDescriptor GetTypeDescriptor(Type type)
    {
        return new AssociatedMetadataTypeTypeDescriptionProvider(type).GetTypeDescriptor(type);
    }
}

Next, we will create the FieldTemplateMetadata class which inherits from DataAnnotationsModelMetadata to store the attribute collection of the model.

public class FieldTemplateMetadata : DataAnnotationsModelMetadata
{
    public FieldTemplateMetadata(DataAnnotationsModelMetadataProvider provider, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName, DisplayColumnAttribute displayColumnAttribute, IEnumerable<Attribute> attributes) : base(provider, containerType, modelAccessor, modelType, propertyName, displayColumnAttribute)
    {
        Attributes = new List<Attribute>(attributes);
    }

    public IList<Attribute> Attributes
    {
        get;
        private set;
    }
}

Next, we will create a provider that inherits from DataAnnotationsModelMetadataProvider to pass the attribute collection to ModelMetaData as well as setting correct template.

public class FieldTemplateMetadataProvider : DataAnnotationsModelMetadataProvider
{
    protected override ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
    {
        DataAnnotationsModelMetadata result = (DataAnnotationsModelMetadata) base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);

        string templateName = attributes.OfType<ITemplateField>()
                                        .Select(field => field.TemplateName)
                                        .LastOrDefault();

        return new FieldTemplateMetadata(this, containerType, modelAccessor, modelType, propertyName, attributes.OfType<DisplayColumnAttribute>().FirstOrDefault(), attributes)
                   {
                       TemplateHint = !string.IsNullOrEmpty(templateName) ? templateName : result.TemplateHint,
                       HideSurroundingHtml = result.HideSurroundingHtml,
                       DataTypeName = result.DataTypeName,
                       IsReadOnly = result.IsReadOnly,
                       NullDisplayText = result.NullDisplayText,
                       DisplayFormatString = result.DisplayFormatString,
                       ConvertEmptyStringToNull = result.ConvertEmptyStringToNull,
                       EditFormatString = result.EditFormatString,
                       ShowForDisplay = result.ShowForDisplay,
                       ShowForEdit = result.ShowForEdit,
                       DisplayName = result.DisplayName
                   };
    }
}

There are one more thing that we have to do before completing this post, we have register the custom mode meta data provider in the provider collection, just put the following line in the global.asax.

protected void Application_Start()
{
    RegisterRoutes(RouteTable.Routes);
    ModelMetadataProviders.Current = new FieldTemplateMetadataProvider();
}

And that's it.

At the end, templating is an nice edition in ASP.NET MVC2 and it provides us a great opportunity to take ASP.NET MVC to the next level (I am already having few idea in my mind).

You will find the complete source code at the bottom of this post.

Download: ExtendedTemplating.zip

Shout it

3 Comments

  • Thank you in advance for your quick answer ! And well done for your work.

  • Is it OK to put all list of Catefories into ViewData if I have, let's say, 100 categories, each of them is an object and not a string, or does another way exist for View to retrieve this list from Controller?

  • And one more question - I don't think it's a good idea to put attribute like this on a property of your model class, because then model will be tight-coupled with your UI, it'll be less reusable, etc.
    Did you mean we can put this attribute on ViewModel classes?

Comments have been disabled for this content.