Practical .NET

Building a Better MVC Helper

Readers suggest ways to build a powerful HtmlHelper that simplifies creating typical application Views. Along the way, Peter uses a workaround for extending an anonymous object with additional properties and shows how to extract values from a lambda expression in a View.

Readers are great. For example: One of the most common things I do when creating a View in ASP.NET MVC is to add a block of HTML that includes a label, an input control and a validation control. I've done that so often, that I created an HtmlHelper extension to take care of it. I even wrote a column about it. That column was popular enough that a couple of readers provided some suggestions for improvement, which I applied to my personal version of the helper. This column provides that better version.

Here's another example: In a recent tip, I showed how to use JavaScript/jQuery code to give you something that many Windows developers take for granted: a Dirty flag that tells you when the user has made a change to the page. Readers pointed out that my code would continue to flag a page as Dirty even if users backed out their changes…and also provided a solution for that problem.

So, in addition to providing that better version, I'm bringing those two columns together to create a better version of my original HtmlHelper. This will also let me demonstrate some other technology. For example, my jQuery "dirty" code required the developer to decide which types of tags they wanted to catch change events for. I'll show an alternative solution that catches all user input elements with a single line of jQuery code. Plus, I'll demonstrate some useful tools you'll need when creating your own HtmlHelper extensions, including managing anonymous objects and retrieving the value for a Model property specified in a lambda expression.

A Better HTMLHelper
First, I'll fix the original version of my HtmlHelper. During the process of incorporating reader's suggestions I also discovered that I was doing something dumb inside the helper: I was passing a useless parameter to the MVC EditorFor parameter. My new version incorporates reader's input and eliminates that error. Listing 1 and Listing 2 show the improved code for my original HtmlHelper in Visual Basic and C#.

Listing 1: HTMLHelper in Visual Basic
Public Function TextBlockFor(Of T, TValue)(helper As Mvc.HtmlHelper(Of T),
                                         prop As Expression(Of System.Func(Of T, TValue)),
                                         Optional htmlAttributes As Object = Nothing) As IHtmlString
      Dim html1 As HtmlString
      Dim html2 As HtmlString
      Dim html3 As HtmlString
        
      html1 = helper.LabelFor(prop)
      html2 = helper.TextBoxFor(prop, htmlAttributes)
      html3 = helper.ValidationMessageFor(prop)

      Return New HtmlString(html1.ToString & ": " &
                            html2.ToString & " " &
                            html3.ToString)

  End Function
End Module
Listing 2: HTMLHelper Extension in C#
public static class PHVExtensions
{
  public static IHtmlString TextBlockFor<T, TValue>(this HtmlHelper<T> helper,  
                                                    Expression<System.Func<T, TValue>> prop, 
                                                    Object htmlAttributes = null) 
  {
    HtmlString html1;
    HtmlString html2;
    HtmlString html3;

    html1 = helper.LabelFor(prop);
    html2 = helper.TextBoxFor(prop, htmlAttributes);
    html3 = helper.ValidationMessageFor(prop);
        
    return new HtmlString(html1.ToString() + ": " +
                          html2.ToString() + " " +
                          html3.ToString());
  }
}

A developer using my helper would, in a View, write code like the following example to add a label/textbox/validation combo to the page:

@Html.TextBoxFor(function(m)  m.FirstName, new With {.class = "HighLight"})

In this example, the developer is working with the FirstName property of the object in the View's Model and adding a CSS class attribute (set to "HighLight") to the generated HTML.

The generated HTML would look like this:

<label for="FirstName">First Name</label>: 
<input class="HighLight" id="FirstName" name="FirstName" type="text" value="Peter" /> 
<span class="field-validation-valid" data-valmsg-for="FirstName" 
  data-valmsg-replace="true"></span>

A State Tracking Input Element
Now to include my readers' latest suggestions around determining whether a page is truly "dirty." In order to only flag the page as dirty when a real change occurs, readers suggested that I add a new attribute to the input element to hold the tag's original value. Something like the data-originalvalue attribute in this example would do the trick:

<input data-originalvalue="Peter" id="FirstName" name="FirstName" type="text" value="Peter" /> 

To make that happen, I need to pass an anonymous object like this to the TextBoxFor method in my helper:

html2 = helper.TextBoxFor(prop, new {.data-originalvalue = "Peter"});

While the solution is simple, incorporating it into my helper isn't as simple, because I want the developer using my helper to be able to pass their own anonymous object into my method (that lets the developer specify a CSS class as I did in my earlier example). Unfortunately, I can't just add a new property to the anonymous object passed in through my htmlAttributes parameter. Second, in order to set the value for my data-originalvalue attribute, I need to retrieve the current value for whatever property the developer has specified in the lambda expression passed in my helper's prop parameter.

Fortunately, the second parameter I pass to the TextBlockFor method inside my helper will accept two things: an anonymous object or a dictionary of name/value pairs. It shouldn't be surprising, therefore, to discover that HtmlHelper object includes a static method that converts an anonymous object to a Dictionary object (the method is cleverly called AnonymousObjectToHtmlAttributes). The output of the method is a RouteValueDictionary.

With that method in hand, my helper code just needs to do two things: First, I need to check if the developer using my helper passed an anonymous object. Second, I need to convert that anonymous object to a RouteValueDictionary or I need to create an empty RouteValueDictionary object if the developer didn't provide one. Here's the code to do that:

If htmlAttributes IsNot Nothing Then
  Attributes = HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes)
Else
  Attributes = New RouteValueDictionary
End If

Retrieving the current value for the property specified in the lambda expression passed to my method is also supported by ASP.NET MVC methods, this time through the FromLambdaExpression method on the ModelMetaData object. That method needs to be passed a lambda expression and the current ViewData object. Fortunately, the ViewData object is available through the HtmlHelper object's ViewData property and, even more fortunately, the HtmlHelper object is automatically passed to my helper method as the method's first parameter.

But I need to declare a variable to hold the retrieved value from the lambda expression, which could, of course, be any datatype. My HtmlHelper extension is a generic method whose datatypes are set automatically by the compiler when the developer calls my method. In my method declaration, the TValue placeholder holds the datatype of the property passed in the lambda expression. Putting that together, I can both declare my variable and retrieve the value with these two lines of code:

Dim val As TValue
val = ModelMetadata.FromLambdaExpression(prop, helper.ViewData).Model

Now, I just have to add my value (encoded for any non-HTML-compatible characters) to my Dictionary along with my attribute name. Then I pass my updated dictionary to the TextBoxFor method to get the HTML I (and the developer) want:

Attributes.Add("data-originalvalue", helper.AttributeEncode(val))
html2 = helper.TextBoxFor(prop, Attributes)

The input element generated by the TextBoxFor will include my data-originalvalue attribute and any attributes provided by the developer when they call my helper.

A jQuery Dirty Function
With the HTML containing the original value now being generated, my next step is to add some JavaScript code to the View to leverage that attribute and set my Dirty flag.

I begin by declaring a variable to hold the "dirty" status of the form and then use jQuery to add a function to run as soon as the page is fully loaded. In that function, I wire up another function to the change event of all of the input-related elements on the page (inputs, selects and textareas). My function will execute when any of those elements is changed by the user:

var Dirty
  $(function () 
    {
      $(":input").change(function () 
      {

Inside my function, I first set my Dirty flag to false. I then capture all of the input elements on the page and loop through them. For each element, I retrieve its data-originalvalue attribute and compare its value to the element's current value. If I find one element that's different, I set the Dirty flag to true (or take any other action that makes sense, like displaying an "unsaved changes" message):

var oVal = $(this).data("originalvalue");
var cVal = $(this).val();
if (oVal != undefined &&
    oVal != cVal) {            
  Dirty = true;
}

The test against the undefined value is required because the :input selector catches change events for more tags than you'll probably want to put the data-originalvalue attribute on. You probably, for example, won't bother to add the attribute to your submit button and the :input selector catches that element. In my code, any element without a data-originalvalue will have an oVal of "undefined" and a cVal with a value, causing the Dirty flag to be set to true even if nothing has changed. The test I have for undefined eliminates those false positives by skipping those elements without a data-originalvalue.

You should be aware that, for textboxes and textareas, the change event doesn't fire until the user leaves the control. Unfortunately, jQuery doesn't have an event that catches keystrokes as soon as they happen (the KeyUp event ignores spelling corrections, for example). If you want to have the Dirty event fire as soon as the user makes a change then you'll need to look at wiring up the HTML oninput event.

But the real point of this column is that now, thanks to the readers of those previous columns, you have a more powerful HtmlHelper extension method that supports the most common block of HTML you'll add when creating a View.

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus

Featured

  • Diving Deep into .NET MAUI

    Ever since someone figured out that fiddling bits results in source code, developers have sought one codebase for all types of apps on all platforms, with Microsoft's latest attempt to further that effort being .NET MAUI.

  • Copilot AI Boosts Abound in New VS Code v1.96

    Microsoft improved on its new "Copilot Edit" functionality in the latest release of Visual Studio Code, v1.96, its open-source based code editor that has become the most popular in the world according to many surveys.

  • AdaBoost Regression Using C#

    Dr. James McCaffrey from Microsoft Research presents a complete end-to-end demonstration of the AdaBoost.R2 algorithm for regression problems (where the goal is to predict a single numeric value). The implementation follows the original source research paper closely, so you can use it as a guide for customization for specific scenarios.

  • Versioning and Documenting ASP.NET Core Services

    Building an API with ASP.NET Core is only half the job. If your API is going to live more than one release cycle, you're going to need to version it. If you have other people building clients for it, you're going to need to document it.

  • TypeScript Tops New JetBrains 'Language Promise Index'

    In its latest annual developer ecosystem report, JetBrains introduced a new "Language Promise Index" topped by Microsoft's TypeScript programming language.

Subscribe on YouTube