Saturday, November 8, 2014

Multiple DisplayFor templates in asp.net MVC

When working with MVC and display templates I have time and again experienced a shortcoming of the DisplayFor method of rendering objects, or maybe I shouldn't call it a shortcoming, lets call it a design decision for simplicity: The inability to easily define and use multiple display templates for one model type.

I work with a CMS, lets call it EPiServer, where a common scenario is that I have a number of different page models, all inheriting a common ancestor. The most basic properties of a page would be a name and its url, a more advanced page could be an article where there is also a image and a preamble. These pages are mixed in the tree structure of my site, and are used in a number of different ways, like in the support navigation at the top of the page, a page listing, or perhaps a sidebar teaser list.

Above I have already defined three different display templates I want to use when showing information about my pages to the visitor while not visiting the acutal page in where the page controller index method will be used. But MVC does only support one, with a possible local overload in the controller folder: /Views/Shared/DisplayTemplates/MyPageTypeClassName.cshtml.

First of all


Lets recap some ground rules for the display template system used in MVC. Assume you have a data model:
public class MyItem {
  public string Name { get; set; }
  public string Image { get; set; }
  public string Preamble { get; set; }
  public string Url { get; set; }
}

And in a view model of some sort:
public class MyViewModel {
  public MyItem Teaser { get; set; }
}

Now to render this in a view, you would call:
@model MyViewModel
@Html.DisplayFor(m => m.Teaser)

This would just simply render out each individual property from the MyItem object. To enhance it, you would do something like so:
@Html.DisplayFor(m => m.Teaser, "MyTeaserView")

And the view /Views/Shared/DisplayTemplates/MyTeaserView.cshtml would be used.
There is also another possibility to just add a display template with the same name as the class to the /views/shared/displaytemplates/ folder and that will be used if I call:
@Html.DisplayFor(m => m.Teaser)


Another option is to use a UIHint:

public class MyViewModel {
  [UIHint("MyDisplayTemplate")]
  public MyItem Teaser { get; set; }
}

and when calling DisplayFor using @Html.DisplayFor(m => m.Teaser), the display template /views/shared/displaytemplates/MyDisplayTemplate.cshtml would be used.
Another powerful convention using DisplayFor is when you use it on objects with the same ancestry or interfaces, consider the following items:

public abstract class ItemBase {
  public string Name { get; set; }
  public string Url { get; set; }
}
public class MyItem : ItemBase {
  public string Image { get; set; }
  public string Preamble { get; set; }
}
public class MySimpleItem : ItemBase {
}
public class MyItemListViewModel {
  IEnumerable<itembase> Items { get; set; }
}

Using conventions, I can add some display templates to tweak the display of my items: MyItem.cshtml:
@model MyItem
@Html.DisplayFor(m => m.Name)
@Html.DisplayFor(m => m.Image)
@Html.DisplayFor(m => m.Preamble)
@Html.DisplayFor(m => m.Url)

MySimpleItem.cshtml:
@model MySimpleItem
@Html.DisplayFor(m => m.Name)
@Html.DisplayFor(m => m.Url)

I can even add a template for the base class, so if I later add another implementation of ItemBase, it will display like the base item.
ItemBase.cshtml:
@model ItemBase
@Html.DisplayFor(m => m.Name)
@Html.DisplayFor(m => m.Url)


The problem


Remember when I talked about my pages in the beginning of this post, where I commonly had different page types that need to be rendered differently in the same list?

Now, combine the display templates of MVC and my pages, and I could create a page list using a foreach loop and Html.DisplayFor(m => page). For each page I create a display template and the page will be rendered in its own unique way in my page listing depending on what properties I have available, like so:
@foreach (var page in Model.PagesToList) {
 @Html.DisplayFor(m => page)
}

Next I want to list other pages as teasers in the sidebar, using different html for each type ofcourse:
@foreach (var page in Model.SideBarTeasers) {
 @Html.DisplayFor(m => page)
}

But I can't! Because the display template for each page is already occupied by my page listing, what do I do?
There are a number of solutions:
  • I can create a whole new controller for just the side bar listing, as controllers can have their own display templates. 
  • I can create a new view model for each page type, combine them with a new base type, and use those in my listing instead, even though there will be no new data so I am effectively duplicating my models just to gain access to a new display template. 
  • I can use a specific display template, using @Html.DisplayFor(m => item, "MySideBarPageModel") , but this would limit me to just one view for all different page types, which would fail my requirement of having different html per model. 
  • I could use something like item.GetType().Name as template argument, but that could easily break if I later add another page type to the list as there is no fallback to base. 

There are probably more clever ways to achieve this, but all ways I tried have revolving around duplicated code (view model bonanza!), or just been a case of killing ants with cannons to stay within the conventions of MVC and DisplayFor.

The solution


Cue the DisplayForWithPrefix html helper:
public static class DisplayExtensions {
  public const string DEFAULT_PREFIX_VIEW_FORMAT = "{0}.{1}";
  public const string DIRECTORY_PREFIX_VIEW_FORMAT = "{0}/{1}";

  /// <summary>
  /// <see cref="System.Web.Mvc.Html.DisplayExtensions.DisplayFor{TModel,TValue}(System.Web.Mvc.HtmlHelper{TModel},System.Linq.Expressions.Expression{System.Func{TModel,TValue}})"/>
  /// overload using a template prefix. Used to display for the value type using a custom prefix so one object type can have different templates. Will fall back to base types.
  /// </summary>
  /// <remarks>
  /// Works similar to regular <see cref="System.Web.Mvc.Html.DisplayExtensions.DisplayFor{TModel,TValue}(System.Web.Mvc.HtmlHelper{TModel},System.Linq.Expressions.Expression{System.Func{TModel,TValue}})"/> 
  /// where the template type is searched recursively using <see cref="Type.BaseType"/>. If no template can be found, it will fallback to the default System.Web.Mvc.Html.DisplayExtensions.DisplayFor method 
  /// using no template at all.
  /// </remarks>
  /// <typeparam name="TModel">The model type</typeparam>
  /// <typeparam name="TValue">The value type</typeparam>
  /// <param name="html">The html helper</param>
  /// <param name="expression">The value expression</param>
  /// <param name="templatePrefix">The template prefix, ex using "MyStringList" for <typeparamref name="TValue"/> string would, using default <paramref name="viewFormat" />, look for the display template named MyStringList.string.cshtml</param>
  /// <param name="viewFormat">How to format the view template name, format arguments: {0} - <paramref name="templatePrefix"/>, {1} - model type name. / can also be used to make the prefix be a directory</param>
  /// <param name="fallbackTemplate">If set, will be used if no display template can be found, otherwise DisplayFor without template will be called as fallback</param>
  /// <param name="preferInterfaceBeforeBaseClass">If true, the display template search prefers interfaces before base classes, otherwise all base classes will be checked before any interface. Also, only interfaces defined directly on the current type being searched is considered each <see cref="Type.BaseType"/> iteration</param>
  /// <returns>The rendered html</returns>
  public static MvcHtmlString DisplayForWithPrefix<TModel, TValue>(this HtmlHelper<TModel> html, Expression<Func<TModel, TValue>> expression, string templatePrefix,
    string viewFormat = DEFAULT_PREFIX_VIEW_FORMAT, string fallbackTemplate = null, bool preferInterfaceBeforeBaseClass = false)
  {
    var metaData = ModelMetadata.FromLambdaExpression(expression, html.ViewData);
    var modelType = metaData.Model.GetType(); // this is the most derived type of the model

    Func<string, MvcHtmlString> renderer = templateName => System.Web.Mvc.Html.DisplayExtensions.DisplayFor(html, expression, templateName);

    // first, check all concrete implementations
    while(modelType != null) {
      var viewName = string.Format(viewFormat, templatePrefix, modelType.Name);

      if(DisplayTemplateExist(html.ViewContext, viewName)) {
        return renderer(viewName);
      }

      if(preferInterfaceBeforeBaseClass) {
        // check interfaces defined on the model type directly
        var exceptions = modelType.BaseType != null ? modelType.BaseType.GetInterfaces() : Enumerable.Empty<Type>();
        foreach(var @interface in metaData.Model.GetType().GetInterfaces().Except(exceptions)) {
          viewName = string.Format(viewFormat, templatePrefix, @interface.Name);
          if(DisplayTemplateExist(html.ViewContext, viewName)) {
            return renderer(viewName);
          }
        }
      }

      modelType = modelType.BaseType;
    }

    // check all interfaces, if not already checked per implementation
    if(!preferInterfaceBeforeBaseClass) {
      foreach(var @interface in metaData.Model.GetType().GetInterfaces()) {
        var viewName = string.Format(viewFormat, templatePrefix, @interface.Name);
        if(DisplayTemplateExist(html.ViewContext, viewName)) {
          return renderer(viewName);
        }
      }
    }

    // fallback
    return renderer(fallbackTemplate);
  }

  private static bool DisplayTemplateExist(ViewContext context, params string[] templateNames)
  {
    foreach(var templateName in templateNames) {
      // the FindPartialView will not automatically search the displaytemplates folder, but the DisplayFor method will
      var viewResult = ViewEngines.Engines.FindPartialView(context, "DisplayTemplates/" + templateName);
      return viewResult.View != null;
    }
    return false;
  }
}

The html helper is quite simple, it works like DisplayFor, infact it uses DisplayFor to do the actual rendering, but it will behave slightly different when looking for display templates. The helper will use the prefix when looking for display templates, enabling me to use different templates to render the same objects using my requirement to automatically choose template based on what page type model is being rendered.

Taking my previous example, I can now use my helper:
@foreach (var page in Model.PagesToList) {
 @Html.DisplayForWithPrefix(m => page, "PageList")
}

@foreach (var page in Model.SideBarTeasers) {
 @Html.DisplayForWithPrefix(m => page, "SideBar")
}

And the display templates the view engine looks for will be, for eaxmple:

  • PageList.MyPageType.cshtml
  • PageList.BasePageType.cshtml
  • SideBar.MyPageType.cshtml
  • SideBar.SomeOtherPageType.cshtml
  • SideBar.IMyPageTypeInterface.cshtml

Bonus!

I can also, if I change the viewFormat parameter to "{0}/{1}", use each prefix as a subfolder in the DisplayTemplates folder, very neat and tidy if I have a lot of templates.

Questions and answers

-"Why not just a DisplayFor overload?"
As you can see, the helper uses an expression and a string as arguments, which would cause an ambiguity with the "real" DisplayFor, and I do not want any of the optional arguments to be required. Besides, this way any new person opening your project will immediately see there is some special handing being done when rendering some properties.

-"What about the silly preferInterfaceBeforeBaseClass flag?"
I added this as a switch to change the order the system searches for templates, enabling it make the helper search for interfaces directly defined on the current type in the chain first, instead of only searching for interfaces if it fail to find any views based on base implementations. If you know the correct order, please share.

-"What about speed?"
Well, I don't know, there is one instance of reflection when interfaces are being searched for, but those lookups should be cached by asp.net, so I don't think there is any problem, but I did not performance test this.

Finally

What do you think? Is there a better, built-in way of achieving this? If so I admit my google-fu isn't up to speed as I have tried to find something like this online but failed. Or is the whole helper a horrible crime against MVC conventions? Do not hesitate to let me know what you think.


You can download the full helper class here: http://pastebin.com/Y1ES6gg7

(Sorry about the tag with my name, Blogger does not support feed filtering based on author)