As a lot of you who have read stuff I have written about before will know that I am mainly interested in desktop development, WPF in fact. You may have also noticed that my blog has been a bit quite lately, which is due to the fact I am working on a pretty large open source project using ASP MVC 3, which is eating all my spare time, as such I have not been writing as many articles as I had been. The open source project is coming along nicely, and I am getting closer to completing it.
During the course of working on this open source project I have been trying to write ASP MVC 3 code using best practices and have an excellent ASP MVC clued up colleague who answers my dumb questions.
Now the other day I asked him why the standard HtmlHelper.DropDown and HtmlHelper.DropDownFor extension methods require you to use some really filthy objects such as SelectListItem.
Now I don’t know who will be reading this post, but I come from WPF land which kind of paved the way for the ModelViewViewModel pattern, which ASP MVC seems to borrow for its Model approach these days. I think it is fair to say that most ASP MVC devs will be treating their Models and View specific Models AKA ViewModels.
So we have this Model for the View (AKA ViewModel), which gets bound to a Razor view using ASP MVCs Model Binding (assuming you are using Razor view engine and not just some REST/JSON/JQuery Ajax approach where you miss the view engine out altogether, that’s cool stuff too, but not the subject of this post). Ok so far, but now I want to create a DropDownList (HTML Select tag) using one of the existing HtmlHelper.DropDownList and HtmlHelper.DropDownListFor extension methods. This is where the fun starts, I now have to pollute my nice clean view specific model (ViewModel) with crap which in my opinion, is a view concern.
Ok you could argue that the Model is view specific so what is the harm in having a IEnumerable<SelectList> property in there. Well it just doesn’t sound right to me, and lets push the boat out a bit more, and imagine I want to test my Models. They now have UI Specific stuff in them, so my tests now have to know about UI specific stuff. Which again sounds wrong to me.
Ok I don’t know what your tests look like, but mine have separate test suites for models, than I do for my controller tests. In my test suite where I am testing my controllers I half expect a bit of stuff to do with view specifics such as ViewData, Session, ActionResults etc etc, but in my models I just don’t want any UI stuff in there.
So what can we do about it.
Well the answer is surprisingly simple, we just create our own HtmlHelper which does a better job and allows us to maintain a nice clean model and allows better separation of concerns.
Ok lets have a look at the finished code then shall we. Oh one thing before we start, I am using a standard ASP MVC 3 project that you get when you start a new ASP MVC Razor based project from within Visual Studio 2010, so I hope you are familiar with that and the objects it creates.
The Model
This is dead easy, and just exposes the model data. Notice how I do not have an UI stuff like SelectList in here. Its just standard data. Obviously this will vary for your specific requirements
This is a demo model
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace MvcApplication1.Models
{
public class Person
{
public int Id { get; private set; }
public string Name { get; private set; }
public Person(int id, string name)
{
this.Id = id;
this.Name = name;
}
public override string ToString()
{
return String.Format("Id : {0}, Name : {1}", Id, Name);
}
}
public class PeopleViewModel
{
public int SelectedPersonId { get; set; }
public List<Person> Staff { get; set; }
public PeopleViewModel()
{
}
public PeopleViewModel(int selectedPersonId, List<Person> staff)
{
this.SelectedPersonId = selectedPersonId;
this.Staff = staff;
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
The HtmlHelper
This is where the real work is done, and I came up with 2 approaches which are both shown below. The attached source code has them both in but only one is active, the other is commented out. I will leave it to the user to decide which they prefer.
However one thing both approaches have in common is that they use Expression<Func<T,Object>> to obtain all the information required to create the html select using the models properties. This can be seen below where I show a demo of it within the demo view.
Option 1 : Defer to the standard HtmlHelper
This option defers to the standard HtmlHelper.DropDownList but this new HtmlHelper creates the SelectList(s) for the standard HtmlHelper.DropDownList
using System;
using System.Text;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Web.Mvc;
using System.Web.Mvc.Html;
using MvcApplication1.Helpers;
namespace MvcApplication1.MyHelpers
{
public static class HtmlHelperExtensions
{
/// <summary>
/// Provides a better HtmlHelper dropdown list function
/// that is type safe and does not fill your model with View specific gunk
/// </summary>
/// <typeparam name="TModel">The Model type</typeparam>
/// <typeparam name="TItem">An item type from the source list</typeparam>
/// <typeparam name="TValue">A value type for one of the items
/// in the source list</typeparam>
/// <typeparam name="TKey">A text type for one of the items
/// in the source list</typeparam>
/// <param name="htmlHelper">The actual HtmlHelper</param>
/// <param name="selectedItemExpr">An expression which selects
/// the selected item from the model</param>
/// <param name="enumerableExpr">An expression which selects
/// the source list from the model</param>
/// <param name="valueExpr">An expression that selects a
/// value from an item</param>
/// <param name="keyExpr">An expression that selects text
/// value from an item</param>
/// <param name="htmlAttributes">The Html Attributes to apply
/// to the overall select string which gets rendered</param>
/// <returns>A HTML encoded string, which represents a HTML
/// select tag with the attributes provided</returns>
public static MvcHtmlString ComboFor<TModel, TItem, TValue, TKey>(
this HtmlHelper<TModel> htmlHelper,
Expression<Func<TModel, TValue>> selectedItemExpr,
Expression<Func<TModel, IEnumerable<TItem>>> enumerableExpr,
Expression<Func<TItem, TValue>> valueExpr,
Expression<Func<TItem, TKey>> keyExpr,
IDictionary<string, object> htmlAttributes) where TValue : IComparable
{
TModel model = (TModel)htmlHelper.ViewData.Model;
string id = ExpressionUtils.GetPropertyName(selectedItemExpr);
TValue selectedItem = selectedItemExpr.Compile()(model);
IEnumerable<TItem> sourceItems = enumerableExpr.Compile()(model);
Func<TItem, TKey> keyFunc = keyExpr.Compile();
Func<TItem, TValue> valueFunc = valueExpr.Compile();
List<SelectListItem> selectList =
(from item in sourceItems
let itemValue = valueFunc(item)
let itemKey = keyFunc(item)
select new SelectListItem()
{
Selected = selectedItem.CompareTo(itemValue) == 0,
Text = itemKey.ToString(),
Value = itemValue.ToString()
}).ToList();
return htmlHelper.DropDownList(
id.ToString(), selectList, htmlAttributes);
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
Option 2 : Do all the work ourselves
This option means we MUST do everything that a standard HtmlHelper extension method would do, which means taking care of things like “Name†and “Id†for the generated html, and also ensuring its rendering correctly using a html encoded string
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Web.Mvc;
using System.Web.Mvc.Html;
using MvcApplication1.Helpers;
using System.Text;
namespace MvcApplication1.MyHelpers
{
public static class HtmlHelperExtensions
{
/// <summary>
/// Provides a better HtmlHelper dropdown list function
/// that is type safe and does not fill your model with View specific gunk
/// </summary>
/// <typeparam name="TModel">The Model type</typeparam>
/// <typeparam name="TItem">An item type from the source list</typeparam>
/// <typeparam name="TValue">A value type for one of the items
/// in the source list</typeparam>
/// <typeparam name="TKey">A text type for one of the items
/// in the source list</typeparam>
/// <param name="htmlHelper">The actual HtmlHelper</param>
/// <param name="selectedItemExpr">An expression which selects
/// the selected item from the model</param>
/// <param name="enumerableExpr">An expression which selects
/// the source list from the model</param>
/// <param name="valueExpr">An expression that selects a
/// value from an item</param>
/// <param name="keyExpr">An expression that selects text
/// value from an item</param>
/// <param name="htmlAttributes">The Html Attributes to apply
/// to the overall select string which gets rendered</param>
/// <returns>A HTML encoded string, which represents a HTML
/// select tag with the attributes provided</returns>
public static MvcHtmlString ComboFor<TModel, TItem, TValue, TKey>(
this HtmlHelper<TModel> htmlHelper,
Expression<Func<TModel, TValue>> selectedItemExpr,
Expression<Func<TModel, IEnumerable<TItem>>> enumerableExpr,
Expression<Func<TItem, TValue>> valueExpr,
Expression<Func<TItem, TKey>> keyExpr,
IDictionary<string, object> htmlAttributes) where TValue : IComparable
{
TModel model = (TModel)htmlHelper.ViewData.Model;
string id = ExpressionUtils.GetPropertyName(selectedItemExpr);
TValue selectedItem = selectedItemExpr.Compile()(model);
IEnumerable<TItem> sourceItems = enumerableExpr.Compile()(model);
Func<TItem, TKey> keyFunc = keyExpr.Compile();
Func<TItem, TValue> valueFunc = valueExpr.Compile();
TagBuilder selectBuilder = new TagBuilder("select");
selectBuilder.GenerateId(id.ToString());
selectBuilder.MergeAttribute("name", id, true);
selectBuilder.MergeAttributes(htmlAttributes, true);
StringBuilder optionsBuilder = new StringBuilder();
foreach (TItem item in sourceItems)
{
TagBuilder optionBuilder = new TagBuilder("option");
object itemValue = valueFunc(item);
object itemKey = keyFunc(item);
optionBuilder.MergeAttribute("value", itemValue.ToString());
optionBuilder.SetInnerText(itemKey.ToString());
if (selectedItem.CompareTo(itemValue) == 0)
{
optionBuilder.MergeAttribute("selected", "selected");
}
optionsBuilder.AppendLine(MvcHtmlString.Create(
optionBuilder.ToString(TagRenderMode.Normal)).ToString());
}
selectBuilder.InnerHtml = optionsBuilder.ToString();
MvcHtmlString x = MvcHtmlString.Create(
selectBuilder.ToString(TagRenderMode.Normal).ToString());
return x;
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
This code also makes use of this utility class to grab the name of a property from an Expression
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Linq.Expressions;
using System.Reflection;
namespace MvcApplication1.Helpers
{
public static class ExpressionUtils
{
public static string GetPropertyName<TModel, TItem>(
Expression<Func<TModel, TItem>> propertyExpression)
{
var lambda = propertyExpression as LambdaExpression;
MemberExpression memberExpression;
if (lambda.Body is UnaryExpression)
{
var unaryExpression = lambda.Body as UnaryExpression;
memberExpression = unaryExpression.Operand as MemberExpression;
}
else
{
memberExpression = lambda.Body as MemberExpression;
}
var propertyInfo = memberExpression.Member as PropertyInfo;
return propertyInfo.Name;
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
A Demo View
This is a small demo view that goes with the model/controller supplied at the bottom of this post. Again you will need to change this depending on your own requirements
Note the “using†at the top of the view which lets us include our new HtmlHelper extension method
Also note how we use the new HtmlHelper extension method, the selection of the
- Source list (The IEnumerable<T>)
- Option key value
- Option text value
Is all done using Expression<Func<T,Object>> which is nice a type safe.
@model MvcApplication1.Models.PeopleViewModel
@using MvcApplication1.MyHelpers
@{
ViewBag.Title = "PeopleView";
}
@using (Html.BeginForm("Create", "Home", FormMethod.Post))
{
<h2>PeopleView</h2>
<p>Select an item and click "Submit" button</p>
@Html.ComboFor(
x => x.SelectedPersonId,
x => x.Staff,
x => x.Id,
x => x.Name,
new Dictionary<string, object> { { "class", "SomeSelectorClass" } })
<br />
<br />
<br />
if (ViewData["result"] != null)
{
<p>@ViewData["result"].ToString()</p>
}
<input id="peopleSubmit" type="submit" value="submit" />
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
So when the HtmlHelper presented with this post does its job this is what the Razor view engine outputs
<select class="SomeSelectorClass" id="SelectedPersonId" name="SelectedPersonId">
<option value="1">Sam Brown</option>
<option selected="selected" value="2">Bob Right</option>
<option value="3">Henry Dune</option>
</select>
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
A Demo Controller
This is a small demo controller that goes with the model/controller supplied at the bottom of this post. Again you will need to change this depending on your own requirements
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using MvcApplication1.Models;
namespace MvcApplication1.Controllers
{
public class HomeController : Controller
{
public ActionResult Create(PeopleViewModel vm)
{
List<Person> staff = new List<Person>();
staff.Add(new Person(1, "Sam Brown"));
staff.Add(new Person(2, "Bob Right"));
staff.Add(new Person(3, "Henry Dune"));
PeopleViewModel vmnew =
new PeopleViewModel(
vm.SelectedPersonId, staff);
ViewData["result"] =
string.Format("The selected item was {0}",
(from x in staff
where x.Id == vm.SelectedPersonId
select x).Single().ToString());
return View("PeopleView", vmnew);
}
public ActionResult PeopleView()
{
List<Person> staff = new List<Person>();
staff.Add(new Person(1, "Sam Brown"));
staff.Add(new Person(2, "Bob Right"));
staff.Add(new Person(3, "Henry Dune"));
PeopleViewModel vm = new PeopleViewModel(2, staff);
return View("PeopleView", vm);
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, “Courier New”, courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
ScreenShot
As always here is a small demo project. Enjoy
http://dl.dropbox.com/u/2600965/Blogposts/2011/11/SelectMVC.zip