Search code examples
c#asp.net-mvchtml-helpermodel-bindingcheckboxlist

Custom CheckBoxListFor Html helper. How do I bind to a viewmodel property?


I'm creating a custom Html helper for handling a checkbox list. I know there is a ton of info on this topic online, but I haven't seen much on trying to generate the HTML the way I am below.

Desired Rendered HTML

<ul>
    <li>
         <label>
             <input type="checkbox" name="Genres" value="SF" />
             Science Fiction
         </label>
    </li>
    <li>
         <label>
             <input type="checkbox" name="Genres" value="HR" />
             Horror
         </label>
    </li>
    <!-- more genres -->
</ul>

I am using distinct checkbox values rather than just booleans, because I want to take advantage of the fact that on form post, I would get a comma-separated list of selected values. For example, if Sci-Fi and Horror were both selected, I'd get "SF,HR".

View Model

public class MovieViewModel
{
    public string Name { get;set; }
    public string Year { get;set; }
    public IEnumerable<string> Genres { get;set; }
    public IEnumerable<SelectListItem> GenreOptions { get;set; }
}

View

In my view, I'd like to basically do this:

@Html.EditorFor(model => model.Name)
@Html.EditorFor(model => model.Year)
@Html.CheckBoxListFor(model => model.Genres, Model.GenreOptions)

Custom Html Helper

So, I started creating a custom Html helper, but I don't have a ton of experience with these, and this is where I need the help:

    public static MvcHtmlString CheckboxListFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,
        Expression<Func<TModel, TProperty>> expression,
        IEnumerable<SelectListItem> items)
    {
        if (items == null) return null;

        var itemsList = items.ToList();
        if (!itemsList.Any()) return null;

       // How do I get "Genres" name from the passed expression from viewmodel, without passing the hardcoded string "genres"?
        var checkboxGroupName = expression.what????

        var sb = new StringBuilder();
        sb.Append("<ul>");

        foreach (var item in itemsList)
        {
            var checkbox = $"<input type=\"checkbox\" name=\"{checkboxGroupName}\" value=\"{item.Value}\" />";
            sb.Append($"<li class=\"checkbox\"><label>{checkbox} {HttpUtility.HtmlEncode(item.Text)}</label></li>");
        }

        sb.Append("</ul>");

        return MvcHtmlString.Create(sb.ToString());
    }

As you can see in the comment above, I don't know how to dynamically assign the viewmodel property name "Genres" to the checkboxes' name attribute, without hardcoding it. How do I get it from model => model.Genres expression?


Solution

  • To get the property name, use

    var name = ExpressionHelper.GetExpressionText(expression);
    

    which will give the fully qualified name, for example @CheckboxListFor(m => m.Movies.Genres, ....) will return Movies.Genres.

    But to account for cases where your using an custom EditorTemplate for you model (which is a property of a parent model), then you also need to get the `HtmlFieldPrefix and if it exists, prepend it the name.

    You current code does not give you correct binding, for example if Genres contains values matching the Value property of a SelectListItem, the associated checkbox should be checked when the view is rendered. Your code should be

    public static MvcHtmlString CheckBoxListFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, IEnumerable<SelectListItem> items)
    {
        if (items == null)
        {
            throw new ArgumentException("...");
        }
        var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
        var model = metadata.Model as IEnumerable<string>;
        if (model == null)
        {
            throw new ArgumentException("...");
        }
        // Get the property name
        var name = ExpressionHelper.GetExpressionText(expression);
        // Get the prefix in case using a EditorTemplate for a nested property
        string prefix = htmlHelper.ViewData.TemplateInfo.HtmlFieldPrefix;
        if (!String.IsNullOrEmpty(prefix))
        {
            name = String.Format("{0}.{1}", prefix, name);
        }
        StringBuilder html = new StringBuilder();
        foreach (var item in items)
        {
            StringBuilder innerHtml = new StringBuilder();
            TagBuilder checkbox = new TagBuilder("input");
            checkbox.MergeAttribute("type", "checkbox");
            checkbox.MergeAttribute("name", name);
            checkbox.MergeAttribute("value", item.Value);
            if (model.Contains(item.Value))
            {
                checkbox.MergeAttribute("checked", "checked");
            }
            innerHtml.Append(checkbox.ToString());
            TagBuilder text = new TagBuilder("span");
            text.InnerHtml = item.Text;
            innerHtml.Append(text.ToString());
            TagBuilder label = new TagBuilder("label");
            label.InnerHtml = innerHtml.ToString();
            TagBuilder li = new TagBuilder("li");
            li.AddCssClass("checkbox");
            li.InnerHtml = label.ToString();
            html.Append(li);
        }
        TagBuilder ul = new TagBuilder("ul");
        ul.InnerHtml = html.ToString();
        return MvcHtmlString.Create(ul.ToString());
    }