Saturday, November 20, 2010

MVC templating, LinkTextAttribute and List templates (Razor and MVC3)

So, while this isn't bad code.

   1:  <ul>
   2:      @foreach (var item in Model)
   3:      {
   4:          <li><a href='Details/' title='@Model.Name'>@Model.Name</a></li>
   5:      }
   6:  </ul>


Frankly, I'm sick of writing it. How often do we write stuff like this? Just about one for every controller. This just isn't using the MVC stack to it's full potential - plus, I've been doing a lot of reading on templating on MVC2/3 and figured I'd give it a shot.

So, first thing I did was write a quick little template that'll work for any IEnumerable. For first pass, I didn't even worry about the links... just get a generic template for a UL. I added a new partial view in ~/Views/Shared/DisplayTemplates and named it UL.cshtml. Sheesh, I love Razor! Anyway it's pretty simple..

   1:  @model  System.Collections.IEnumerable
   2:  <ul>
   3:      @foreach(var item in Model){
   4:          <li>@Html.DisplayFor(m => item)</li>
   5:      }
   6:  </ul>

Note that I'm using Html.DisplayFor... If you're just pumping out values to the client like so @Model.SomePropery, you're really not using the MVC stack like you can be. Display templates give you free hooks to alter the html if the need arises for REALLY cheap, plus it gives you a central place to generate the html for a specific data type, no matter how complex or simple.

When I sent this model through it.

   1:   [DefaultProperty("Name")]
   2:      public class DojoDetails : ObjectWithIdentity 
   3:      {
   5:          public virtual string Name { get; set; }
   6:          public virtual Address Address { get; set; }
   7:      }

It just generated a list of Names for Dojos. So now, what I wanna do is be able to mark the ViewModel with some Attribute that says "Hey, this is the Text and Here is the format and here is the value for the link". So I made this little attribute.

   1:   public class LinkText : Attribute
   2:      {
   3:          public string LinkFormatProperty { get; set; }
   4:          public string LinkFormatString { get; set; }
   5:      }

And I went and marked up my DojoDetails with it.

   1:   public class DojoDetails : ObjectWithIdentity 
   2:      {
   3:          [LinkText(LinkFormatProperty = "Id",LinkFormatString = "~/Dojos/Details/{0}")]
   4:          public virtual string Name { get; set; }
   5:          public virtual Address Address { get; set; }
   6:      }

Next task is to make sure we actually get that data down into the metadata for the view model. To do this, we need to write an implementation of DataAnnotationsModelMetadataProvider. It's got one method that we need to override (CreateMetadata). Not too difficult. The MVC stack put an AdditionalProperties Dictionary on ModelMetaData, so we'll just pump in the attribute right there so we can get to it in our templates. Here's how I did it.

   1:   public class MetadataProvider : DataAnnotationsModelMetadataProvider
   2:      {
   3:          protected override ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
   4:          {
   5:              var metadata = base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);
   6:              var linkText = attributes.OfType<LinkText>().FirstOrDefault();
   7:              if(linkText != null)
   8:                  metadata.AdditionalValues.Add("LinkText",linkText);
   9:              return metadata;
  10:          }
  12:      }
Simple enough, just see if the attribute is there, if so, add it. This code smells like a OCP violation, but I'm gunna leave it alone for now, since I wanna stay on task. 
Next up, I'm gunna go write a new template that'll grab that linktext attribute and create an anchor tag. I created a little ViewModel for the link so I can write a template for it - it's silly simple.

   1:   public class ModelLinkViewModel
   2:      {
   3:          public ModelLinkViewModel()
   4:          {
   6:          }
   7:          public ModelLinkViewModel(string link, string text)
   8:          {
   9:              Url = link;
  10:              Text = text;
  11:          }
  13:          public string Text { get; set; }
  14:          public string Url { get; set; }
  15:      }


Lets go mark up our view model now.


   1:    public class DojoDetails : ObjectWithIdentity 
   2:      {
   3:          [LinkText(LinkFormatProperty = "Id",LinkFormatString = "~/Dojos/Details/{0}")]
   4:          public virtual string Name { get; set; }
   5:          public virtual Address Address { get; set; }
   6:      }


Id is on ObjectWithIdentity, and we're just giving a virtual url to the details page. Now I'll create a little helper method that'll a ModelLinkViewModel from the DojoDetails.


   1:  public static class ModelMetaDataExtensions
   2:      {
   3:          public static ModelLinkViewModel LinkToModelDetails<TModel>(this HtmlHelper<TModel> helper)
   4:          {
   5:              var displayProperty = helper.ViewData.ModelMetadata.Properties.First(p => p.AdditionalValues.ContainsKey("LinkText"));
   6:              var linkTextAttribute = (ModelMetaData.LinkText)displayProperty.AdditionalValues["LinkText"];
   7:              var linkFormat = linkTextAttribute.LinkFormatString;
   8:              var linkProperty = helper.ViewData.ModelMetadata.Properties.First(p => p.PropertyName == linkTextAttribute.LinkFormatProperty);
   9:              var request = ServiceLocation.CurrentContainer.GetInstance<HttpRequestBase>();
  11:              var link = request.Resolve(String.Format(linkFormat, linkProperty.Model));
  12:              if(! (displayProperty.Model is string))
  13:                  throw new InvalidOperationException(String.Format("[LinkText] can only be used on String properties. {0} is a {1}",displayProperty.PropertyName, displayProperty.ContainerType.Name));
  14:              var text = (String)displayProperty.Model;
  15:              return new ModelLinkViewModel(link, text);
  17:          }
  18:      }
So, on link 5, I just go find the property marked with LinkText attribute. Once we have it, we pull all the data out of it. On Line 8, I'm going and getting the property mentioned in the LinkTextAttribute as the propery that has the value to format. Line 11 is just an extension I made that Resolves virtual url's and we just format up the link.
So now that that works, lets go make a template for the list item.

   1:  @model object
   2:  @using myDojo.Infrastructure.Web.HtmlHelpers;
   3:  @if (Model == null) {
   4:      <text>@ViewData.ModelMetadata.NullDisplayText</text>
   5:  }
   7:  else if(ViewData.ModelMetadata.Properties.FirstOrDefault(p => p.AdditionalValues.ContainsKey("LinkText")) != null)
   8:  {
   9:    var x = Html.LinkToModelDetails();
  10:    <text>@Html.DisplayFor(m => x)</text>
  12:  }else if (ViewData.TemplateInfo.TemplateDepth > 1)
  13:  {
  14:      <text>@ViewData.ModelMetadata.SimpleDisplayText</text>
  15:  }
Ok, so this has way too much defensive code, but whatever... really all we care about is line 7. We just go grab the LinkText property from the Model, and use the extension method to get a ModelLinkViewModel, then use our old friend DisplayFor. If there isn't a LinkText propery, it'll just display the DefaultPropery. 
For now, this is just gunna display .ToString() on ModelLinkViewModel - and I doubt our users wanna see the type of ModelLinkViewModel, so lets go write a template for that type.
Once again, I go add another view to ~/Views/Shared/DisplayTemplates, and I name it the Type that I wanna template.
Here it is. Really couldn't be much simpler.

   1:  @model myDojo.Infrastructure.Web.HtmlHelpers.ModelLinkViewModel
   2:  <a href='@Model.Url' title='@Model.Text'>@Model.Text</a>
There we go! 
So now, we go change the original list page to this.

   1:  @inherits System.Web.Mvc.WebViewPage<IEnumerable<MyDojo.Query.ViewModels.Dojos.DojoDetails>>
   3:  @{
   4:      View.Title = "List";
   5:      Layout = "~/Views/Shared/_Layout.cshtml";
   6:  }
   8:      <h2>All our Schools</h2>
  10:      @Html.ActionLink("Create new school", "Create")
  12:      @Html.DisplayForModel("UL")
Note that I just tell it the template name (file minus extension name in ~Views/Shared/DisplayTemplates) and there we go... It all works.
There's a WHOLE lot I could do to clean this up... and I will, but I really wanted to see if I could get this to work. Lots and lots of goodies in the MVC stack, we really need to use them more!
If you wanna see it you can Git the code.
Have fun!

No comments:

Post a Comment