Using TemplateHint and other Model Metadata features in MVC
I’ve been wanting to write this blog for quite some time, but somehow it got pushed so far.
Requirement: Display the employee details as per the requirements below:
So, I see the following differences in the designs:
- Employee Id versus Badge Number
- Given Name versus First Name
- Surname versus Last Name
- Date Of Birth format is different (sorry for the bad image editing work!)
- First and Last names are bolded for USA region
- Alternate lines are highlighted for India region
Let’s see how MVC helps us to come up with a ‘configurable’ design to meet these requirements. I say configurable because all this information goes into some configuration file which can be changed on the fly without having to rebuild and redeploy the application.
So I create an MVC application and since I’m having to show the employee details, I create my model object as follows:
1: public class Employee
2: {
3: public int EmployeeId { get; set; }
4: public string FirstName { get; set; }
5: public string LastName { get; set; }
6: public DateTime DateOfBirth { get; set; }
7: public string Department { get; set; }
8: }
One thing to note is that this is a plain .net class, no data annotations or special attributes on either the class or its properties.
For simplistic models, you can use data annotation attributes directly above the properties. For example, you can use [DisplayName], [UIHint], [DisplayFormat] attributes on the properties and MVC’s DataAnnotationsModelMetadataProvider class does just this. But for anything more complex than that, we need to have a custom model metadata provider.
I add a folder ‘Config’ to the root and add an xml file ‘MetadataConfig.xml’ to it that looks something like below. The folder is added just for segregation purposes, you might as well add the xml file directly to the root.
1: <?xml version="1.0" encoding="utf-8" ?>
2: <config>
3: <metadata region="IN">
4: <modelType name='Employee' templateHint='INEmployee'>
5: <properties>
6: <property name='EmployeeId' displayName='Employee Id' displayFormat='' templateHint='' />
7: <property name='FirstName' displayName='Given Name' displayFormat='' templateHint='' />
8: <property name='LastName' displayName='Surname' displayFormat='' templateHint='' />
9: <property name='DateOfBirth' displayName='Date Of Birth' displayFormat='{0:dd/MM/yyyy, dddd}' templateHint='' />
10: </properties>
11: </modelType>
12: </metadata>
13: <metadata region="US">
14: <modelType name='Employee' templateHint='USEmployee'>
15: <properties>
16: <property name='EmployeeId' displayName='Badge Number' displayFormat='' templateHint='' />
17: <property name='FirstName' displayName='First Name' displayFormat='' templateHint='BoldText' />
18: <property name='LastName' displayName='Last Name' displayFormat='' templateHint='BoldText' />
19: <property name='DateOfBirth' displayName='Date Of Birth' displayFormat='{0:MM/dd/yyyy}' templateHint='' />
20: </properties>
21: </modelType>
22: </metadata>
23: </config>
Let’s go over this for a moment here. Just below the root ‘config’ element, are the ‘metadata’ elements. Here’s how to read each of the metadata tags:
The model ‘Employee’ needs to be displayed using the ‘INEmployee’ view template in the ‘IN’ region and the property Employee.EmployeeId needs to be set as ‘Badge Number’ in the ‘US’ region.
Now, if we check the bullet points from our requirements, we seem to have covered almost all of them through the above xml.
- Employee Id versus Badge Number – check
- Given Name versus First Name – check
- Surname versus Last Name – check
- Date Of Birth format is different – check
- First and Last names are bolded for USA region – check
- Alternate lines are highlighted for India region – ‘throw new NotYetCompletedException()’
Although, it might not be clear at first, but the fifth point IS actually taken care of here. You’ll see that for the US region, we’ve set the templateHint attribute for the FirstName and LastName properties. The ‘templateHint’ tells MVC what view template to use. We’ll see how to use this config information and set our model’s metadata.
Building a custom model metadata provider class involves inheriting from one of these base classes:
- ModelMetadataProvider: The abstract base class for all metadata providers.
- AssociatedMetadataProvider: This is used when you don’t have much control over the domain model, like it is built using Entity Framework or something similar. In such cases, you define a ‘buddy’ class with the data annotations for the properties of the domain model class. At runtime, MVC matches the properties of the domain model class and that of the buddy class and applies the metadata information accordingly. I know this sounds like rock-science, but it’s not. Just look at the snippet:
1: public partial class Employee
2: {
3: public int EmployeeId { get; set; }
4: public string FirstName { get; set; }
5: public string LastName { get; set; }
6: }
7:
8: [System.ComponentModel.DataAnnotations.MetadataType(typeof(EmployeeMetadata))]
9: public partial class Employee
10: {
11: private class EmployeeMetadata
12: {
13: [System.ComponentModel.DisplayName("Employee Id")]
14: public int EmployeeId { get; set; }
15: [System.ComponentModel.DisplayName("First name")]
16: public string FirstName { get; set; }
17: [System.ComponentModel.DisplayName("Last name")]
18: public string LastName { get; set; }
19: }
20: }
The AssociatedMetadataProvider class does all the plumbing work for you to identify the classes with MetadataType attributes and do the remaining ‘stuff’ (that’s a technical word, by the way!). But this still does not help us as the data annotations are hard-coded and not configurable, so we go the third way:
- DataAnnotationsModelMetadataProvider: This is the default metadata provider offered by MVC. By overriding the CreateMetadata() method of your inherited class, you can maintain the support for the standard data annotations attributes of the System.ComponentModel namespace plus implement your own custom logic .
Before we move on, let’s add some code to make sure that our MetadataConfig.xml is loaded onto a property that can be used by our custom model metadata provider class. I’ve added the following to the Global.asax.cs class.
1: public static XDocument MetadataXml;
2:
3: protected void Application_Start()
4: {
5: AreaRegistration.RegisterAllAreas();
6:
7: RegisterGlobalFilters(GlobalFilters.Filters);
8: RegisterRoutes(RouteTable.Routes);
9: LoadMetadata();
10: }
11:
12: private static void LoadMetadata()
13: {
14: MetadataXml = XDocument.Load(HttpContext.Current.Server.MapPath("Config/MetadataConfig.xml"));
15: }
So, here’s the flow of the application. On the first page, the user selects the region and this gets stored in a cookie. Our custom model metadata provider class then reads this cookie to get a hint of what view template needs to get rendered on the page.
It’s time to add our CustomModelMetadataProvider class and I’ve chosen to add it to the root of the application.
1: public class CustomModelMetadataProvider : DataAnnotationsModelMetadataProvider
2: {
3: protected override ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
4: {
5: ModelMetadata metadata = base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);
6: string region = GetRegion();
7: if (propertyName == null)
8: {
9: metadata.TemplateHint = GetTemplateHintForModelType(modelType.Name, region, metadata.TemplateHint);
10: }
11: else
12: {
13: metadata.DisplayName = GetMetadata("displayName", propertyName, region, metadata.DisplayName);
14: metadata.DisplayFormatString = GetMetadata("displayFormat", propertyName, region, metadata.DisplayFormatString);
15: metadata.TemplateHint = GetMetadata("templateHint", propertyName, region, metadata.TemplateHint);
16: }
17: return metadata;
18: }
19: }
Line 5 is where we run the default implementation that the DataAnnotationsModelMetadataProvider class provides. So, in case you have any of the data annotations declared on a property, this line reads them. If there’s a scenario say something like.. ‘I want the FirstName property to be displayed as Given Name for most regions except for US where I need to show it as First Name, then you could decorate the FirstName property in your model class as:
1: public class Employee
2: {
3: [System.ComponentModel.DisplayName("Given Name")]
4: public string FirstName { get; set; }
5: }
and only define the displayName attribute for the FirstName in the US region as ‘First Name’. I know this is going against the ‘configurable’ school of thought here, but this way you’ll avoid adding the same value to the xml file over and over again.
Ok let’s continue with the our CustomModelMetadataProvider class. The selected region gets read in Line 6 of the above code.
1: private static string GetRegion()
2: {
3: HttpCookie httpCookie = HttpContext.Current.Request.Cookies["Region"];
4: if (httpCookie != null)
5: {
6: return httpCookie["region"];
7: }
8: return "IN";
9: }
The next thing is to read the modelType’s templateHint attribute. I’m keying off of the fact that the property name is null when you’re rendering the model itself. So in my view, I’m having something like this:
1: <%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<TemplateHintUsage.Models.Employee>" %>
2:
3: <asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
4: Index
5: </asp:Content>
6:
7: <asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
8: <%: Html.DisplayFor(m=>m) %>
9: </asp:Content>
When line 8 is being rendered, the property name will be null and if you observe the line 1, this is view is strongly-typed to the model ‘Employee’. The following gets the templateHint for the model type.
1: private static string GetTemplateHintForModelType(string modelTypeName, string region, string currentValue)
2: {
3: string templateHint = (from metadata in MvcApplication.MetadataXml.Descendants("metadata")
4: from modelType in metadata.Descendants("modelType")
5: where metadata.Attribute("region").Value == region
6: && modelType.Attribute("name").Value == modelTypeName
7: select modelType.Attribute("templateHint").Value).FirstOrDefault();
8:
9: return string.IsNullOrEmpty(templateHint) ? currentValue : templateHint;
10: }
Just as similarly, I get the metadata for the properties as well:
1: private static string GetMetadata(string attributeName, string propertyName, string region, string currentValue)
2: {
3: string metadataValue = (from metadata in MvcApplication.MetadataXml.Descendants("metadata")
4: from property in metadata.Descendants("property")
5: where metadata.Attribute("region").Value == region
6: && property.Attribute("name").Value == propertyName
7: select property.Attribute(attributeName).Value).FirstOrDefault();
8:
9: return string.IsNullOrEmpty(metadataValue) ? currentValue : metadataValue;
10: }
Now the last step to make this all work is to register your CustomModelMetadataProvider class with MVC.
1: protected void Application_Start()
2: {
3: AreaRegistration.RegisterAllAreas();
4:
5: RegisterGlobalFilters(GlobalFilters.Filters);
6: RegisterRoutes(RouteTable.Routes);
7: LoadMetadata();
8: ModelMetadataProviders.Current = new CustomModelMetadataProvider();
9: }
That’s the setup needed to use a custom metadata provider.
We still have to satisfy the last requirement – Alternate lines being highlighted. For this you just set up your view with the appropriate styling. In my INEmployee view, I have the following:
1: <div class="display-label" style="float:left; width:100px; background-color:#dddddd; text-align:right;"><%: Html.LabelFor(m=>m.FirstName)%></div>
2: <div class="display-field" style="float:left; width:180px; background-color:#dddddd;"><%: Html.DisplayFor(m=>m.FirstName)%></div>
Please download the entire application here.
Verdict:
Having a framework that provides as many extensible features, just ROCKS!