My Technical Notes

Monday, 14 June 2010

MVC: SelectListFor extension method bug for Indexed Values

At my current company I am using the MVC Framework. Although it is fairly mature, (I am using version 2), there are still loads of little bugs. One such bug is the SelectListFor extension method which when given an indexed or indexer value, for example:


<%= Html.DropDownListFor(x => x.Tasks[i].Name, selectList) %>

it will generate the correct html but the selected value of the drop down list is ignored on the first GET request. On a POST request, since we receive a form value of the name "Tasks[0].Name", the extension method will then fill in the correct selected value since it can look this up. However we are interested in solving the problem for the initial GET request.

One problem I had with developing a solution was the fact that in the MVC source, everything is either internal or private, therefore I cannot reuse the MVC code, and instead I have to copy and paste huge chunks of it into my own application. Another major drawback of this is that if there are any bug fixes within MVC, then my application will not benefit from them.

Anyhow, the following class solves the problem. The extension method is called "SelectListFor2" which is understandable because we do not want a naming conflict between our own extension method and Mvc's built in one:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Collections;
using System.Text;
using System.Globalization;
using System.Linq.Expressions;

namespace MyAppNameSpace.Mvc
{
    public static class SelectExtensions
    {
        public static MvcHtmlString DropDownListFor2<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, IEnumerable<SelectListItem> selectList)
        {
            return DropDownListFor2(htmlHelper, expression, selectList, null /* optionLabel */, null /* htmlAttributes */);
        }

        public static MvcHtmlString DropDownListFor2<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, IEnumerable<SelectListItem> selectList, string optionLabel, IDictionary<string, object> htmlAttributes)
        {
            if (expression == null)
            {
                throw new ArgumentNullException("expression");
            }
            var model = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData).Model;
            // return DropDownListHelper2(htmlHelper, ExpressionHelper.GetExpressionText(expression), selectList, optionLabel, htmlAttributes, model);

            return SelectInternal2(htmlHelper, optionLabel, ExpressionHelper.GetExpressionText(expression), selectList, false, htmlAttributes, model);
        }

        private static MvcHtmlString SelectInternal2(this HtmlHelper htmlHelper, string optionLabel, string name, IEnumerable<SelectListItem> selectList, bool allowMultiple, IDictionary<string, object> htmlAttributes, object model)
        {
            name = htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(name);
            if (String.IsNullOrEmpty(name))
            {
                throw new ArgumentException("name");
            }

            bool usedViewData = false;

            // If we got a null selectList, try to use ViewData to get the list of items.
            if (selectList == null)
            {
                selectList = htmlHelper.GetSelectData(name);
                usedViewData = true;
            }

            object defaultValue = (allowMultiple) ? htmlHelper.GetModelStateValue(name, typeof(string[])) : htmlHelper.GetModelStateValue(name, typeof(string));

            // If we haven't already used ViewData to get the entire list of items then we need to
            // use the ViewData-supplied value before using the parameter-supplied value.
            if (!usedViewData)
            {
                if (defaultValue == null)
                {
                    defaultValue = htmlHelper.ViewData.Eval(name);
                    if (defaultValue == null)
                    {
                        defaultValue = model;
                    }
                }
            }

            if (defaultValue != null)
            {
                IEnumerable defaultValues = (allowMultiple) ? defaultValue as IEnumerable : new[] { defaultValue };
                IEnumerable<string> values = from object value in defaultValues select Convert.ToString(value, CultureInfo.CurrentCulture);
                HashSet<string> selectedValues = new HashSet<string>(values, StringComparer.OrdinalIgnoreCase);
                List<SelectListItem> newSelectList = new List<SelectListItem>();

                foreach (SelectListItem item in selectList)
                {
                    item.Selected = (item.Value != null) ? selectedValues.Contains(item.Value) : selectedValues.Contains(item.Text);
                    newSelectList.Add(item);
                }
                selectList = newSelectList;
            }

            // Convert each ListItem to an <option> tag
            StringBuilder listItemBuilder = new StringBuilder();

            // Make optionLabel the first item that gets rendered.
            if (optionLabel != null)
            {
                listItemBuilder.AppendLine(ListItemToOption(new SelectListItem() { Text = optionLabel, Value = String.Empty, Selected = false }));
            }

            foreach (SelectListItem item in selectList)
            {
                listItemBuilder.AppendLine(ListItemToOption(item));
            }

            TagBuilder tagBuilder = new TagBuilder("select")
            {
                InnerHtml = listItemBuilder.ToString()
            };
            tagBuilder.MergeAttributes(htmlAttributes);
            tagBuilder.MergeAttribute("name", name, true /* replaceExisting */);
            tagBuilder.GenerateId(name);
            if (allowMultiple)
            {
                tagBuilder.MergeAttribute("multiple", "multiple");
            }

            // If there are any errors for a named field, we add the css attribute.
            ModelState modelState;
            if (htmlHelper.ViewData.ModelState.TryGetValue(name, out modelState))
            {
                if (modelState.Errors.Count > 0)
                {
                    tagBuilder.AddCssClass(HtmlHelper.ValidationInputCssClassName);
                }
            }

            return tagBuilder.ToMvcHtmlString(TagRenderMode.Normal);
        }

        internal static string ListItemToOption(SelectListItem item)
        {
            TagBuilder builder = new TagBuilder("option")
            {
                InnerHtml = HttpUtility.HtmlEncode(item.Text)
            };
            if (item.Value != null)
            {
                builder.Attributes["value"] = item.Value;
            }
            if (item.Selected)
            {
                builder.Attributes["selected"] = "selected";
            }
            return builder.ToString(TagRenderMode.Normal);
        }

        public static MvcHtmlString ToMvcHtmlString(this TagBuilder tagBuilder, TagRenderMode renderMode)
        {
            return MvcHtmlString.Create(tagBuilder.ToString(renderMode));
        }

        private static IEnumerable<SelectListItem> GetSelectData(this HtmlHelper htmlHelper, string name)
        {
            object o = null;
            if (htmlHelper.ViewData != null)
            {
                o = htmlHelper.ViewData.Eval(name);
            }
            if (o == null)
            {
                throw new InvalidOperationException();
                //throw new InvalidOperationException(
                //    String.Format(
                //        CultureInfo.CurrentUICulture,
                //        MvcResources.HtmlHelper_MissingSelectData,
                //        name,
                //        "IEnumerable<SelectListItem>"));
            }
            IEnumerable<SelectListItem> selectList = o as IEnumerable<SelectListItem>;
            if (selectList == null)
            {
                throw new InvalidOperationException();
                //throw new InvalidOperationException(
                //    String.Format(
                //        CultureInfo.CurrentUICulture,
                //        MvcResources.HtmlHelper_WrongSelectDataType,
                //        name,
                //        o.GetType().FullName,
                //        "IEnumerable<SelectListItem>"));
            }
            return selectList;
        }

        public static object GetModelStateValue(this HtmlHelper htmlHelper, string key, Type destinationType)
        {
            ModelState modelState;
            if (htmlHelper.ViewData.ModelState.TryGetValue(key, out modelState))
            {
                if (modelState.Value != null)
                {
                    return modelState.Value.ConvertTo(destinationType, null /* culture */);
                }
            }
            return null;
        }
    }
}
You would then use it by adding the relevant namespace into your web.config file and then using it as:

<%= Html.DropDownListFor2(x => x.Tasks[i].Name, selectList) %>
(Notice the 2).

No comments: