MVC 2 Model Metadata to Render Dynamic UI
Recently we had a project where we needed to render certain questions on the screen dynamically based on answers to previous questions on previous screens. For questions that need to dynamically be visible/invisible on the same screen, this can simply be controlled with jQuery. However, in this case, based on the user's input on previous screens we know there are certain questions that will not be applicable before the current screen even loads. In this case, we could use jQuery to set those questions invisible as well – but given we know the questions will not be applicable for this user, it would be more efficient to filter those questions out to start with which minimizes the HTML that is sent down to the browser. Additionally, the client wanted a way to easily map the question answers to the requirements.
For this example, suppose that on a previous screen, the user indicated that they are married. On the current screen, some of the information that needs to be collected is information on the spouse – but this should only be part of the screen if the user indicated they were married on the previous screen. The original code from the view models looked something like this:
public bool ShouldShowNB0021FirstName { get; set; } public string NB0021FirstName { get; set; } public bool ShouldShowNB0022LastName { get; set; } public string NB0022LastName { get; set; } public bool ShouldShowNB0023Age { get; set; } public int NB0023Age { get; set; }
A couple of things immediately jump out about this code. First, the property names are prefixed with a cryptic identifier (e.g., "NB0021"). This identifier is probably very meaningful to some business analyst somewhere and helps map back to the requirements doc but it's somewhat distracting on the property names. Further, it's going to render in the HTML mark up on the html element names which is a little odd. Also, each of these properties has a corresponding Boolean property which controls visibility for it. This resulted in views that looked somewhat like this:
<% if (this.Model.ShouldShowNB0021FirstName) { %> <div> <%: Html.LabelFor(m => m.NB0021FirstName) %> <%: Html.TextBoxFor(m => m.NB0021FirstName) %> </div> <% } %> <% if (this.Model.ShouldShowNB0022LastName) { %> <div> <%: Html.LabelFor(m => m.NB0022LastName) %> <%: Html.TextBoxFor(m => m.NB0022LastName)%> </div> <% } %> <% if (this.Model.ShouldShowNB0023Age) { %> <div> <%: Html.LabelFor(m => m.NB0023Age) %> <%: Html.TextBoxFor(m => m.NB0023Age)%> </div> <% } %>
While this certainly works, it's a little verbose and not particularly DRY. Further, there is a lot of procedural code necessary to appropriately assign the Boolean visibility properties before rendering the view.
So let's see how we can refactor this a little to save ourselves some work. First off, let's create a custom attribute called "QuestionId" so that we can simplify the property names:
[QuestionId("NB0021")] public string FirstName { get; set; } [QuestionId("NB0022")] public string LastName { get; set; } [QuestionId("NB0023")] public int Age { get; set; }
The implementation of the attribute is trivial:
public class QuestionIdAttribute : Attribute { public string Id { get; set; } public QuestionIdAttribute(string id) { this.Id = id; } }
Next, let's just add a single property to the view model (let's call it "ApplicableQuestions") which contains the collection of QuestionId's that are still applicable given all the user's answers on the previous screens. This "applicability logic" is encapsulated in another layer and beyond the scope of this post. What is relevant is that it will return an array of QuestionId's that are still applicable. This now allows are views to look like this:
<% if (Model.ApplicableQuestions.Contains("NB0022")) { %> <%: Html.EditorFor(m => m.LastName)%> <% } %>
This is somewhat better because we don't have to have all of the Boolean visibility properties, but I've still got a bunch of IF statement in the view and the identifier is still cryptic. To solve this, let's created a custom EditorTemplate called "ScalarQuestion.ascx" that is meant for all questions that have basic text boxes. We can complete our view model by adding [UIHint] attributes as well as [DisplayName] attributes. Now the properties of our view model look like this:
[UIHint("ScalarQuestion")] [DisplayName("First Name")] [QuestionId("NB0021")] public string FirstName { get; set; } [UIHint("ScalarQuestion")] [DisplayName("Last Name")] [QuestionId("NB0022")] public string LastName { get; set; } [UIHint("ScalarQuestion")] [QuestionId("NB0023")] public int Age { get; set; } public IEnumerable<string> ApplicableQuestions { get; set; }
Ultimately, we want to be able to call the EditorFor() method without having to wrap it in an IF statement which we can now do like this:
<%: Html.EditorFor(m => m.FirstName, new { applicableQuestions = Model.ApplicableQuestions })%> <%: Html.EditorFor(m => m.LastName, new { applicableQuestions = Model.ApplicableQuestions })%> <%: Html.EditorFor(m => m.Age, new { applicableQuestions = Model.ApplicableQuestions })%>
Since we're passing in the list of applicable questions, the editor template can check to see if it should display:
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl" %> <%@ Import Namespace="DynamicQuestions.Models" %> <%@ Import Namespace="System.Linq" %> <% var applicableQuestions = this.ViewData["applicableQuestions"] as IEnumerable<string>; var questionAttr = this.ViewData.ModelMetadata.ContainerType.GetProperty(this.ViewData.ModelMetadata.PropertyName).GetCustomAttributes(typeof(QuestionIdAttribute), true) as QuestionIdAttribute[]; string questionId = null; if (questionAttr.Length > 0) { questionId = questionAttr[0].Id; } if (questionId != null && applicableQuestions.Contains(questionId)) { %> <div> <%: Html.Label("") %> <%: Html.TextBox("", this.Model)%> </div> <% } %>
One line #5 we get the applicable question that were passed in. Then we check for the [QuestionId] attribute on the view model property – if it's there, then we know we need to display the HTML – if not, we don't. At this point we've 1) eliminated all of the Boolean visibility properties, 2) eliminated the cryptic identifier from the property name and made it part of metadata instead by using a declarative attribute, 3) eliminated all of the IF statements in the views, and 4) automatically mapped the applicability identifiers to the metadata specified on the view model. The only thing that still bugs me a little is the fact that having to create the anonymous type to pass the ApplicableQuestions property for every property means our code still violates DRY. To improve this, let's create a custom Html helper called QuestionFor():
public static MvcHtmlString QuestionFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression) where TModel : class, IApplicable { return htmlHelper.EditorFor(expression, new { applicableQuestions = htmlHelper.ViewData.Model.ApplicableQuestions }); }
Note that I introduced an interface called IApplicable which simply has a single property:
public interface IApplicable { IEnumerable<string> ApplicableQuestions { get; set; } }
I can have my view models that have the ApplicableQuestions property implement this interface so that we can utilize the generic constraint on the HTML helper. Now my view becomes simplicity:
<%: Html.QuestionFor(m => m.FirstName) %> <%: Html.QuestionFor(m => m.LastName) %> <%: Html.QuestionFor(m => m.Age) %>
A final consideration to keep in mind is validation. Given that some properties of the view model won't be present when the user saves the screen, validating a dynamic UI must be taken into account. The code sample for this post can be downloaded here.