Tag Helper for Display Templates
I was minding my own business when @dahlbyk (you may know him from such hits as PoshGit) dropped this comment on an already merged pull request.
Display Templates? Now there’s a name I haven’t heard in a long time. As a refresher, Display and Editor Templates were first introduced as part of ASP.NET MVC. You could place partial views in the Views/Shared/DisplayTemplates
folder (as well as a Controller specific subfolder) named after the type that the partial view is meant to render. So if you wanted all your booleans rendered in a particular way, You’d add a Boolean.cshtml
partial view. Then make sure to call the Html.DisplayFor(m => m.BooleanProperty)
helper.
Now you may think I’m being a bit precious with my complaint about the lack of a non-declarative way to call a display template. And you’re right, I am precious.
Nevertheless, it bothers me that when you call a display template in a loop, you build an expression that doesn’t use the expression variable. If that doesn’t make sense, let me clarify with a code sample.
Suppose you want to use a display helper for the current model in a view. You can do something like this.
@model Member
@Html.DisplayFor(m => m.Welcomed)
That’s pretty straightforward. The m
in the expression is the current Model
which is a Member
type.
But if you’re in a loop, you have to build an expression that doesn’t use the expression variable.
@model IEnumerable<Member>
@foreach (var member in Model.Members) {
<li>@Html.DisplayFor(_ => member.Welcomed)</li>
}
What if we could do this instead?
@model IEnumerable<Member>
@foreach (var member in Model.Members) {
<li><display for="@member.Welcomed" /></li>
}
That would be pretty cool, right?
To do so, we need to build a Tag Helper. Tag Helpers take advantage of the code model that the Razor parser builds when it parses your Razor template.
Here’s how I started.
[HtmlTargetElement("display")]
public class DisplayForTagHelper : TagHelper
{
readonly IHtmlHelper _htmlHelper;
/// <summary>
/// An expression to be evaluated against the current model.
/// </summary>
[HtmlAttributeName("for")]
public ModelExpression? For { get; set; }
}
The real interesting part here is the For
property. Note that it’s bound to the for
attribute of the display
tag and is a ModelExpression
. So when we call the tag helper passing in @member.Welcomed
, we’re not getting the value of member.Welcomed
, we’re getting so much more!
But, I’ve run into a problem here. All the Html.DisplayFor
methods take in an Expression<Func<TModel, TResult>>
. And the Html.Display
methods take in a string. I could not find a method that takes a ModelExpression
. However, digging into the ASP.NET Core source code, I did discover that all the Html.Display*
methods eventually delegate to a protected virtual GenerateDisplay
method which takes a ModelExplorer
to actually render a display template.
Unfortunately, that method is not public, so I can’t just call it. But, since it’s protected virtual
, and the ASP.NET Core team rarely want to break consumers of their APIs, it’s relatively safe to call that method with Reflection (now I have two problems?).
Let’s complete the code.
using System;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Serious.Abbot.Infrastructure.TagHelpers;
[HtmlTargetElement("display")]
public class DisplayForTagHelper : TagHelper
{
readonly IHtmlHelper _htmlHelper;
const string ForAttributeName = "for";
/// <summary>
/// An expression to be evaluated against the current model.
/// </summary>
[HtmlAttributeName(ForAttributeName)]
public ModelExpression? For { get; set; }
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
public DisplayForTagHelper(IHtmlHelper htmlHelper)
{
_htmlHelper = htmlHelper;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(output);
if (For is null)
{
return;
}
(_htmlHelper as IViewContextAware)?.Contextualize(ViewContext);
// I could not find a way to call `DisplayFor` with a ModelExplorer parameter. It all expects an Expression.
var generateDisplayMethod = _htmlHelper
.GetType()
.GetMethod("GenerateDisplay", BindingFlags.Instance | BindingFlags.NonPublic);
var content = generateDisplayMethod?.Invoke(_htmlHelper, new[] {For.ModelExplorer, (object?)null, null, null})
as IHtmlContent;
output.TagName = null;
output.Content.SetHtmlContent(content);
}
}
Perhaps an even better approach is to write a class that inherits HtmlHelper
and makes exposes a public version of GenerateDisplay
. IIRC, there’s a way to replace the built in IHtmlHelper
with your own, but I forget how to do it. I’ll leave that as an exercise to the reader.
Likewise, I’ll probably write an <editor for="..." />
tag helper as well. If you know of a better approach, do let me know! Till next time.
UPDATE: I couldn’t help myself. Here’s the better approach.
using System;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Serious.AspNetCore;
namespace Serious.Abbot.Infrastructure.TagHelpers;
[HtmlTargetElement("display")]
public class DisplayForTagHelper : DisplayTemplateTagHelper
{
public DisplayForTagHelper(IHtmlHelper htmlHelper) : base(htmlHelper)
{
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
Process(DisplayTemplateType.Display, context, output);
}
}
[HtmlTargetElement("editor")]
public class EditorForTagHelper : DisplayTemplateTagHelper
{
public EditorForTagHelper(IHtmlHelper htmlHelper) : base(htmlHelper)
{
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
Process(DisplayTemplateType.Editor, context, output);
}
}
public abstract class DisplayTemplateTagHelper : TagHelper
{
readonly IHtmlHelper _htmlHelper;
protected enum DisplayTemplateType { Display, Editor }
/// <summary>
/// An expression to be evaluated against the current model.
/// </summary>
[HtmlAttributeName("for")]
public ModelExpression? For { get; set; }
[HtmlAttributeName("html-field-name")]
public string? HtmlFieldName { get; set; }
[HtmlAttributeName("template-name")]
public string? TemplateName { get; set; }
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
protected DisplayTemplateTagHelper(IHtmlHelper htmlHelper)
{
_htmlHelper = htmlHelper;
}
protected void Process(DisplayTemplateType displayTemplateType, TagHelperContext context, TagHelperOutput output)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(output);
if (For is null)
{
return;
}
(_htmlHelper as IViewContextAware)?.Contextualize(ViewContext);
var content = _htmlHelper is ISeriousHtmlHelper seriousHtmlHelper
? seriousHtmlHelper.DisplayFor(For, HtmlFieldName, TemplateName, null)
: Generate(displayTemplateType);
output.TagName = null;
output.Content.SetHtmlContent(content);
}
IHtmlContent? Generate(DisplayTemplateType displayTemplateType)
{
var methodName = $"Generate{displayTemplateType}";
var generateDisplayMethod = _htmlHelper
.GetType()
.GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
var parameters = new[] {For!.ModelExplorer, HtmlFieldName, TemplateName, (object?)null};
return generateDisplayMethod?.Invoke(_htmlHelper, parameters)
as IHtmlContent;
}
}
/// <summary>
/// Html Helpers that includes <see cref="DisplayFor"/> and <see cref="EditorFor"/> methods that accept
/// a <see cref="ModelExpression" />.
/// </summary>
public interface ISeriousHtmlHelper : IHtmlHelper
{
IHtmlContent DisplayFor(
ModelExpression modelExpression,
string? htmlFieldName,
string? templateName,
string? additionalViewData);
IHtmlContent EditorFor(
ModelExpression modelExpression,
string? htmlFieldName,
string? templateName,
string? additionalViewData);
}
Now you just need an implementation of ISeriousHtmlHelper
.
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers;
using Serious.Abbot.Infrastructure.TagHelpers;
namespace Serious.AspNetCore;
/// <summary>
/// Html Helpers that includes <see cref="DisplayFor"/> and <see cref="EditorFor"/> methods that accept
/// a <see cref="ModelExpression" />.
/// </summary>
public class SeriousHtmlHelper : HtmlHelper, ISeriousHtmlHelper
{
public SeriousHtmlHelper(
IHtmlGenerator htmlGenerator,
ICompositeViewEngine viewEngine,
IModelMetadataProvider metadataProvider,
IViewBufferScope bufferScope,
HtmlEncoder htmlEncoder,
UrlEncoder urlEncoder) : base(htmlGenerator, viewEngine, metadataProvider, bufferScope, htmlEncoder, urlEncoder)
{
}
/// <summary>
/// Renders a display template using the supplied <see cref="ModelExpression"/>.
/// </summary>
/// <param name="modelExpression">The <see cref="ModelExpression"/>.</param>
/// <param name="htmlFieldName">The name of the html field.</param>
/// <param name="templateName">The name of the template.</param>
/// <param name="additionalViewData">The additional view data.</param>
/// <returns><see cref="IHtmlContent"/>.</returns>
public IHtmlContent DisplayFor(
ModelExpression modelExpression,
string? htmlFieldName,
string? templateName,
string? additionalViewData)
{
return GenerateDisplay(modelExpression.ModelExplorer, htmlFieldName, templateName, additionalViewData);
}
/// <summary>
/// Renders a display template using the supplied <see cref="ModelExpression"/>.
/// </summary>
/// <param name="modelExpression">The <see cref="ModelExpression"/>.</param>
/// <param name="htmlFieldName">The name of the html field.</param>
/// <param name="templateName">The name of the template.</param>
/// <param name="additionalViewData">The additional view data.</param>
/// <returns><see cref="IHtmlContent"/>.</returns>
public IHtmlContent EditorFor(
ModelExpression modelExpression,
string? htmlFieldName,
string? templateName,
string? additionalViewData)
{
return GenerateEditor(modelExpression.ModelExplorer, htmlFieldName, templateName, additionalViewData);
}
}
And finally, just register ISeriousHtmlHelper
with your DI container.
services.AddTransient<IHtmlHelper, SeriousHtmlHelper>();
And there you have it. A complete implementation of both <display for="..." />
nad <editor for="..." />
that doesn’t require reflection (but will fallback to reflection). Once again, thanks to Keith Dahlby for the inspiration and some of the suggestions here.
__UPDATE 2: __ Turns out, there’s a nice TagHelperPack
nuget package that already has a display
and editor
tag helper. Check out the website here: https://taghelperpack.net/
Comments
2 responses