diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/DefaultHtmlGenerator.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/DefaultHtmlGenerator.cs index 97443f5e05..bacbfa51c8 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/DefaultHtmlGenerator.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/DefaultHtmlGenerator.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Reflection; using System.Text; using Microsoft.AspNet.Mvc.Core; using Microsoft.AspNet.Mvc.ModelBinding; @@ -20,6 +21,8 @@ namespace Microsoft.AspNet.Mvc.Rendering public class DefaultHtmlGenerator : IHtmlGenerator { private const string HiddenListItem = @"
  • "; + private static readonly MethodInfo ConvertEnumFromStringMethod = + typeof(DefaultHtmlGenerator).GetTypeInfo().GetDeclaredMethod(nameof(ConvertEnumFromString)); private readonly AntiForgery _antiForgery; private readonly IScopedInstance _bindingContextAccessor; @@ -377,16 +380,16 @@ namespace Microsoft.AspNet.Mvc.Rendering bool allowMultiple, object htmlAttributes) { - ICollection ignored; + var currentValues = GetCurrentValues(viewContext, modelExplorer, expression, allowMultiple); return GenerateSelect( viewContext, modelExplorer, optionLabel, expression, selectList, + currentValues, allowMultiple, - htmlAttributes, - selectedValues: out ignored); + htmlAttributes); } /// @@ -396,9 +399,9 @@ namespace Microsoft.AspNet.Mvc.Rendering string optionLabel, string expression, IEnumerable selectList, + IReadOnlyCollection currentValues, bool allowMultiple, - object htmlAttributes, - out ICollection selectedValues) + object htmlAttributes) { var fullName = GetFullHtmlFieldName(viewContext, expression); if (string.IsNullOrEmpty(fullName)) @@ -407,7 +410,6 @@ namespace Microsoft.AspNet.Mvc.Rendering } // If we got a null selectList, try to use ViewData to get the list of items. - var usedViewData = false; if (selectList == null) { if (string.IsNullOrEmpty(expression)) @@ -419,39 +421,13 @@ namespace Microsoft.AspNet.Mvc.Rendering } selectList = GetSelectListItems(viewContext, expression); - usedViewData = true; } - var type = allowMultiple ? typeof(string[]) : typeof(string); - var defaultValue = GetModelStateValue(viewContext, fullName, type); - - // If ModelState did not contain a current value, fall back to ViewData- or ModelExplorer-supplied value. - if (defaultValue == null) + modelExplorer = modelExplorer ?? + ExpressionMetadataProvider.FromStringExpression(expression, viewContext.ViewData, _metadataProvider); + if (currentValues != null) { - if (modelExplorer == null) - { - // Html.DropDownList() and Html.ListBox() helper case. - // Cannot use ViewData if it contains the select list. - if (!usedViewData) - { - defaultValue = viewContext.ViewData.Eval(expression); - } - } - else - { - // , Html.DropDownListFor() and Html.ListBoxFor() helper case. Do not use ViewData. + rawValue = modelExplorer.Model; + } + + if (rawValue == null) + { + return null; + } + } + + // Convert raw value to a collection. + IEnumerable rawValues; + if (allowMultiple) + { + rawValues = rawValue as IEnumerable; + if (rawValues == null || rawValues is string) + { + throw new InvalidOperationException( + Resources.FormatHtmlHelper_SelectExpressionNotEnumerable(nameof(expression))); + } + } + else + { + rawValues = new[] { rawValue }; + } + + modelExplorer = modelExplorer ?? + ExpressionMetadataProvider.FromStringExpression(expression, viewContext.ViewData, _metadataProvider); + + var enumNames = modelExplorer.Metadata.EnumNamesAndValues; + var isTargetEnum = modelExplorer.Metadata.IsEnum; + var innerType = + Nullable.GetUnderlyingType(modelExplorer.Metadata.ModelType) ?? modelExplorer.Metadata.ModelType; + + // Convert raw value collection to strings. + var currentValues = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var value in rawValues) + { + // Add original or converted string. + var stringValue = (value as string) ?? Convert.ToString(value, CultureInfo.CurrentCulture); + + // Do not add simple names of enum properties here because whitespace isn't relevant for their binding. + // Will add matching names just below. + if (enumNames == null || !enumNames.ContainsKey(stringValue.Trim())) + { + currentValues.Add(stringValue); + } + + // Remainder handles isEnum cases. Convert.ToString() returns field names for enum values but select + // list may (well, should) contain integer values. + var enumValue = value as Enum; + if (isTargetEnum && enumValue == null && value != null) + { + var valueType = value.GetType(); + if (typeof(long).IsAssignableFrom(valueType) || typeof(ulong).IsAssignableFrom(valueType)) + { + // E.g. user added an int to a ViewData entry and called a string-based HTML helper. + enumValue = ConvertEnumFromInteger(value, innerType); + } + else if (!string.IsNullOrEmpty(stringValue)) + { + // E.g. got a string from ModelState. + var methodInfo = ConvertEnumFromStringMethod.MakeGenericMethod(innerType); + enumValue = (Enum)methodInfo.Invoke(obj: null, parameters: new[] { stringValue }); + } + } + + if (enumValue != null) + { + // Add integer value. + var integerString = enumValue.ToString("d"); + currentValues.Add(integerString); + + // Add all simple names for this value. + var matchingNames = enumNames + .Where(kvp => string.Equals(integerString, kvp.Value, StringComparison.Ordinal)) + .Select(kvp => kvp.Key); + foreach (var name in matchingNames) + { + currentValues.Add(name); + } + } + } + + return (IReadOnlyCollection)currentValues; + } + internal static string EvalString(ViewContext viewContext, string key, string format) { return Convert.ToString(viewContext.ViewData.Eval(key, format), CultureInfo.CurrentCulture); @@ -990,6 +1085,32 @@ namespace Microsoft.AspNet.Mvc.Rendering return UnobtrusiveValidationAttributesGenerator.GetValidationAttributes(clientRules); } + private static Enum ConvertEnumFromInteger(object value, Type targetType) + { + try + { + return (Enum)Enum.ToObject(targetType, value); + } + catch (Exception exception) + when (exception is FormatException || exception.InnerException is FormatException) + { + // The integer was too large for this enum type. + return null; + } + } + + private static object ConvertEnumFromString(string value) where TEnum : struct + { + TEnum enumValue; + if (Enum.TryParse(value, out enumValue)) + { + return enumValue; + } + + // Do not return default(TEnum) when parse was unsuccessful. + return null; + } + private static bool EvalBoolean(ViewContext viewContext, string key) { return Convert.ToBoolean(viewContext.ViewData.Eval(key), CultureInfo.InvariantCulture); @@ -1060,48 +1181,26 @@ namespace Microsoft.AspNet.Mvc.Rendering } private static IEnumerable UpdateSelectListItemsWithDefaultValue( + ModelExplorer modelExplorer, IEnumerable selectList, - object defaultValue, - bool allowMultiple, - out ICollection selectedValues) + IReadOnlyCollection currentValues) { - IEnumerable defaultValues; - if (allowMultiple) - { - defaultValues = defaultValue as IEnumerable; - if (defaultValues == null || defaultValues is string) - { - throw new InvalidOperationException( - Resources.FormatHtmlHelper_SelectExpressionNotEnumerable("expression")); - } - } - else - { - defaultValues = new[] { defaultValue }; - } - - var values = - defaultValues.OfType().Select(value => Convert.ToString(value, CultureInfo.CurrentCulture)); - - // ToString() by default returns an enum value's name. But selectList may use numeric values. - var enumValues = defaultValues.OfType().Select(value => value.ToString()); - values = values.Concat(enumValues); - - selectedValues = new HashSet(values, StringComparer.OrdinalIgnoreCase); - // Perform deep copy of selectList to avoid changing user's Selected property values. var newSelectList = new List(); foreach (SelectListItem item in selectList) { - var newItem = new SelectListItem + var value = item.Value ?? item.Text; + var selected = currentValues.Contains(value); + var copy = new SelectListItem { Disabled = item.Disabled, Group = item.Group, - Selected = selectedValues.Contains(item.Value ?? item.Text), + Selected = selected, Text = item.Text, Value = item.Value, }; - newSelectList.Add(newItem); + + newSelectList.Add(copy); } return newSelectList; diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/HtmlHelper.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/HtmlHelper.cs index 2191b575b3..5510e41f90 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/HtmlHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/HtmlHelper.cs @@ -691,8 +691,8 @@ namespace Microsoft.AspNet.Mvc.Rendering ViewContext, modelExplorer, optionLabel, - expression: expression, - selectList: selectList, + expression, + selectList, allowMultiple: false, htmlAttributes: htmlAttributes); if (tagBuilder == null) diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/IHtmlGenerator.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/IHtmlGenerator.cs index 4646b6e8ad..cab71cfb76 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/IHtmlGenerator.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Rendering/Html/IHtmlGenerator.cs @@ -159,6 +159,40 @@ namespace Microsoft.AspNet.Mvc.Rendering object routeValues, object htmlAttributes); + /// + /// Generate a <select> element for the . + /// + /// A instance for the current scope. + /// + /// for the . If null, determines validation + /// attributes using and the . + /// + /// Optional text for a default empty <option> element. + /// Expression name, relative to the current model. + /// + /// A collection of objects used to populate the <select> element with + /// <optgroup> and <option> elements. If null, finds this collection at + /// ViewContext.ViewData[expression]. + /// + /// + /// If true, includes a multiple attribute in the generated HTML. Otherwise generates a + /// single-selection <select> element. + /// + /// + /// An that contains the HTML attributes for the <select> element. Alternatively, an + /// instance containing the HTML attributes. + /// + /// A new describing the <select> element. + /// + /// + /// Combines and to set + /// <select> element's "name" attribute. Sanitizes to set element's "id" + /// attribute. + /// + /// + /// See for information about how current values are determined. + /// + /// TagBuilder GenerateSelect( [NotNull] ViewContext viewContext, ModelExplorer modelExplorer, @@ -168,15 +202,55 @@ namespace Microsoft.AspNet.Mvc.Rendering bool allowMultiple, object htmlAttributes); + /// + /// Generate a <select> element for the . + /// + /// A instance for the current scope. + /// + /// for the . If null, determines validation + /// attributes using and the . + /// + /// Optional text for a default empty <option> element. + /// Expression name, relative to the current model. + /// + /// A collection of objects used to populate the <select> element with + /// <optgroup> and <option> elements. If null, finds this collection at + /// ViewContext.ViewData[expression]. + /// + /// + /// An containing values for <option> elements to select. If + /// null, selects <option> elements based on values in + /// . + /// + /// + /// If true, includes a multiple attribute in the generated HTML. Otherwise generates a + /// single-selection <select> element. + /// + /// + /// An that contains the HTML attributes for the <select> element. Alternatively, an + /// instance containing the HTML attributes. + /// + /// A new describing the <select> element. + /// + /// + /// Combines and to set + /// <select> element's "name" attribute. Sanitizes to set element's "id" + /// attribute. + /// + /// + /// See for information about how the + /// collection may be created. + /// + /// TagBuilder GenerateSelect( [NotNull] ViewContext viewContext, ModelExplorer modelExplorer, string optionLabel, string expression, IEnumerable selectList, + IReadOnlyCollection currentValues, bool allowMultiple, - object htmlAttributes, - out ICollection selectedValues); + object htmlAttributes); TagBuilder GenerateTextArea( [NotNull] ViewContext viewContext, @@ -216,5 +290,45 @@ namespace Microsoft.AspNet.Mvc.Rendering [NotNull] ViewContext viewContext, ModelExplorer modelExplorer, string expression); + + /// + /// Gets the collection of current values for the given . + /// + /// A instance for the current scope. + /// + /// for the . If null, calculates the + /// result using . + /// + /// Expression name, relative to the current model. + /// + /// If true, require a collection result. Otherwise, treat result as a + /// single value. + /// + /// + /// + /// null if no result is found. Otherwise an + /// containing current values for the given + /// . + /// + /// + /// Converts the result to a . If that result is an + /// type, instead converts each item in the collection and returns + /// them separately. + /// + /// + /// If the result or the element type is an , returns a + /// containing the integer representation of the value as well + /// as all names for that value. Otherwise returns the default + /// conversion of the value. + /// + /// + /// + /// See for information about how the return value may be used. + /// + IReadOnlyCollection GetCurrentValues( + [NotNull] ViewContext viewContext, + ModelExplorer modelExplorer, + string expression, + bool allowMultiple); } } diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/SelectTagHelper.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/SelectTagHelper.cs index 54b9e5077a..8dd5ac79c2 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/SelectTagHelper.cs +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/SelectTagHelper.cs @@ -79,16 +79,20 @@ namespace Microsoft.AspNet.Mvc.TagHelpers // Ensure GenerateSelect() _never_ looks anything up in ViewData. var items = Items ?? Enumerable.Empty(); - ICollection selectedValues; + var currentValues = Generator.GetCurrentValues( + ViewContext, + For.ModelExplorer, + expression: For.Name, + allowMultiple: allowMultiple); var tagBuilder = Generator.GenerateSelect( ViewContext, For.ModelExplorer, optionLabel: null, expression: For.Name, selectList: items, + currentValues: currentValues, allowMultiple: allowMultiple, - htmlAttributes: null, - selectedValues: out selectedValues); + htmlAttributes: null); if (tagBuilder != null) { @@ -98,7 +102,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers // Whether or not (not being highly unlikely) we generate anything, could update contained