ASP.NET MVC 3 IMetadataAware and custom ModelMetadata attributes

UPDATE: I've added a simple example project up on bitbucket to demonstrate the ColorPicker attribute

ASP.NET MVC 3 introduces a new interface, IMetadataAware, for providing additional values to the model metadata at creation time:

/* ****************************************************************************
 *
 * Copyright (c) Microsoft Corporation. All rights reserved.
 *
 * This software is subject to the Microsoft Public License (Ms-PL). 
 * A copy of the license can be found in the license.htm file included 
 * in this distribution.
 *
 * You must not remove this notice, or any other, from this software.
 *
 * ***************************************************************************/

namespace System.Web.Mvc {
    // This interface is implemented by attributes which wish to contribute to the
    // ModelMetadata creation process without needing to write a custom metadata
    // provider. It is consumed by AssociatedMetadataProvider, so this behavior is
    // automatically inherited by all classes which derive from it (notably, the
    // DataAnnotationsModelMetadataProvider).
    public interface IMetadataAware {
        void OnMetadataCreated(ModelMetadata metadata);
    }
}

The default ModelMetadataProvider, DataAnnotationsModelMetadataProvider, derives from AssociatedMetadataProvider, which, after creating the metadata for a type or type property, runs through all of the IMetadataAware types (i.e. attributes) applied to the type or property type, passing it the created metadata and allowing each IMetadataAware type to add additional values to the metadata. This is a nice change in MVC 3 as it means that you no longer need to write your own ModelMetadataProvider deriving from DataAnnotationsModelMetadataProvider to add in this functionality as you needed to in MVC 2.

The framework uses this new interface for two new model metadata attributes, AllowHtmlAttribute and AdditionalMetadataAttribute; AllowHtmlAttribute can be applied to a property to prevent that property from going through request validation, which can be very handy if you are receiving HTML or XML from the client and only want to white-list certain model/viewmodel properties to prevent request validation from being applied to them. AdditionalMetadataAttribute does pretty much what it's name implies; that is to allow you to add additional values to the modelmetadata's additional values dictionary.

Creating a custom ColourPicker ModelMetadataAttribute

We're going to look at creating our own jQuery ColourPicker ModelMetadata attribute using the IMetadataAware interface. This will allow us to attribute our model / viewmodel property and automagically hook up a jQuery colour picker to the text box in the rendered view. For this, I'll be using the awesome colorpicker plugin by Stefan Petre. Note that I will be using the american spelling of colour to align with the script :)

First off, the ColorPickerAttribute

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class ColorPickerAttribute : Attribute, IMetadataAware
{
    private const string Template =
        "$('#{0}').ColorPicker({{onSubmit: function(hsb, hex, rgb, el) {{" + 
        "var self = $(el); self.val(hex);self.ColorPickerHide();}}, onBeforeShow: function () " + 
        "{{$(this).ColorPickerSetColor(this.value);}}}}).bind('keyup', function(){{ $(this).ColorPickerSetColor(this.value); }});";
        
    public const string ColorPicker = "_ColorPicker";

    private int _count;

    // if using IoC container, you could inject this into the attribute
    internal HttpContextBase Context
    {
        get { return new HttpContextWrapper(HttpContext.Current); }
    }

    public string Id
    {
        get { return "jquery-colorpicker-" + _count;  }
    }

    public void OnMetadataCreated(ModelMetadata metadata)
    {
        var list = Context.Items["Scripts"] as IList<string> ?? new List<string>();
        _count = list.Count;

        metadata.TemplateHint = ColorPicker;
        metadata.AdditionalValues[ColorPicker] = Id;

        list.Add(string.Format(CultureInfo.InvariantCulture, Template, Id));

        Context.Items["Scripts"] = list;
    }
}

Now let's say we have a simple controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        return View(new HomeModel());
    }

}

and view model:

public class HomeModel
{
    [ColorPicker]
    public string ColorPicker { get; set; }
}

In order to get this all hooked up, we're going to use an EditorTemplate named _ColorPicker.cshtml:

@model System.String

@{ var picker = ViewData.GetModelAttribute<ColorPickerAttribute>(); 
   if (picker != null) {  
      @Html.LabelForModel()
      @Html.TextBoxFor(m => m, new { id = ViewData.ModelMetadata.AdditionalValues[ColorPickerAttribute.ColorPicker] }) 
   }
}

EditorTemplates should be put in a location where they will be accessible to the corresponding view engine. In this example, the _ColorPicker razor partial view is in Views/Shared/EditorTemplates. The template uses an extension method on ViewDataDictionary, GetModelAttribute to keep the view clean and allow us to get an attribute of a particular type back. The extension method is defined as so:

public static class ViewDataDictionaryExtensions
{
    public static TAttribute GetModelAttribute<TAttribute>(this ViewDataDictionary viewData, bool inherit = false) where TAttribute : Attribute
    {
        if (viewData == null) throw new ArgumentNullException("viewData");

        var containerType = viewData.ModelMetadata.ContainerType;

        return ((TAttribute[])containerType.GetProperty(viewData.ModelMetadata.PropertyName)
                                            .GetCustomAttributes(typeof (TAttribute), inherit)).FirstOrDefault();
    }
}

To make this extension method available in the view, we need to add the namespace to the system.web.webPages.razor section (if using the razor view engine, which I assume you would be with MVC 3!) in the web.config that can be found in the Views folder:

  
  <system.web.webPages.razor>
    <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
    <pages pageBaseType="System.Web.Mvc.WebViewPage">
      <namespaces>
        <add namespace="Namespace.For.ViewDataDictionaryExtensions" />
      </namespaces>
    </pages>
  </system.web.webPages.razor>  

And finally the view:

<h2>Index</h2>
@Html.EditorFor(m => m.ColorPicker)

And the _Layout that the view is using:

<!DOCTYPE html>
<html>
<head>
    <title>@ViewBag.Title</title>
    <link href="@Url.Content("~/Content/css/Site.css")" rel="stylesheet" type="text/css" />
    <link href="@Url.Content("~/Content/css/colorpicker.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.4.4.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/colorpicker.js")" type="text/javascript"></script>
</head>
<body>
    @RenderBody()
    @Html.RenderScripts()
</body>
</html>

A HtmlHelper extension method runs after the body is rendered and this is responsible for writing out all of the scripts:

public static IHtmlString RenderScripts(this HtmlHelper htmlHelper)
{
    var scripts = htmlHelper.ViewContext.HttpContext.Items["Scripts"] as IList<string>;

    if (scripts != null)
    {
        var builder = new StringBuilder();

        builder.AppendLine("<script type='text/javascript'>");
        builder.AppendLine("$(function() {");
        foreach (var script in scripts)
        {
            builder.AppendLine(script);
        }
        builder.AppendLine("});");
        builder.AppendLine("</script>");

        return new MvcHtmlString(builder.ToString());
    }
    return null;
}

The end result and markup are as follows:

colorpicker end result in browser

colorpicker end markup in browser

Putting the script into HttpContext.Items means that we can write the script out at the very end of the view before the closing body tag, which works for when scripts are put in both the head section of the document or before the closing body tag (so long as the call to RenderScripts comes after any required scripts, such as jQuery and the colorpicker script in this case). It also plays well when you have partial views that have markup requiring client side script and can be used for other plugins too, such as jQuery DatePicker.

Comments

comments powered by Disqus